add ability to use material 3. (#286)

This commit is contained in:
Jojo Feng
2023-11-01 19:48:09 -07:00
committed by GitHub
parent 3fbf5d4eea
commit b3fdc20fc5
23 changed files with 475 additions and 298 deletions

View File

@ -0,0 +1,4 @@
- Ability to use Material 3.
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -20,6 +20,8 @@ abstract class Constants {
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.'; '$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/'; static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/'; static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String hackerNewsItemLinkPrefix =
'https://news.ycombinator.com/item?id=';
static const String supportEmail = 'georgefung98@gmail.com'; static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images'; static const String _imagePath = 'assets/images';

View File

@ -9,6 +9,7 @@ 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';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
@ -61,6 +62,10 @@ class CommentsCubit extends Cubit<CommentsState> {
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final Logger _logger; final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
/// The [StreamSubscription] for stream (both lazy or eager) /// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story. /// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
@ -349,11 +354,20 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true); init(useCommentCache: true);
} }
void scrollTo({
required int index,
double alignment = 0.0,
}) {
debugPrint('Scrolling to: $index, alignment: $alignment');
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: Durations.ms400,
);
}
/// Scroll to next root level comment. /// Scroll to next root level comment.
void scrollToNextRoot( void scrollToNextRoot() {
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
final int totalComments = state.comments.length; final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value .itemPositions.value
@ -398,10 +412,7 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
/// Scroll to previous root level comment. /// Scroll to previous root level comment.
void scrollToPreviousRoot( void scrollToPreviousRoot() {
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
final List<Comment> onScreenComments = itemPositionsListener final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value .itemPositions.value
// The header is also a part of the list view, // The header is also a part of the list view,
@ -436,6 +447,23 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
} }
void search(String query) {
resetSearch();
final String lowercaseQuery = query.toLowerCase();
for (final int i in 0.to(state.comments.length, inclusive: false)) {
final Comment cmt = state.comments.elementAt(i);
if (cmt.text.toLowerCase().contains(lowercaseQuery)) {
emit(
state.copyWith(
matchedComments: <int>[...state.matchedComments, i],
),
);
}
}
}
void resetSearch() => emit(state.copyWith(matchedComments: <int>[]));
List<int> _sortKids(List<int> kids) { List<int> _sortKids(List<int> kids) {
switch (state.order) { switch (state.order) {
case CommentsOrder.natural: case CommentsOrder.natural:

View File

@ -12,6 +12,7 @@ class CommentsState extends Equatable {
const CommentsState({ const CommentsState({
required this.item, required this.item,
required this.comments, required this.comments,
required this.matchedComments,
required this.status, required this.status,
required this.fetchParentStatus, required this.fetchParentStatus,
required this.fetchRootStatus, required this.fetchRootStatus,
@ -28,6 +29,7 @@ class CommentsState extends Equatable {
required this.fetchMode, required this.fetchMode,
required this.order, required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
matchedComments = <int>[],
status = CommentsStatus.idle, status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle, fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle, fetchRootStatus = CommentsStatus.idle,
@ -45,9 +47,13 @@ class CommentsState extends Equatable {
final bool isOfflineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
/// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments;
CommentsState copyWith({ CommentsState copyWith({
Item? item, Item? item,
List<Comment>? comments, List<Comment>? comments,
List<int>? matchedComments,
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus, CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus, CommentsStatus? fetchRootStatus,
@ -60,6 +66,7 @@ class CommentsState extends Equatable {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
matchedComments: matchedComments ?? this.matchedComments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus, fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus, fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
status: status ?? this.status, status: status ?? this.status,
@ -86,5 +93,6 @@ class CommentsState extends Equatable {
isOfflineReading, isOfflineReading,
currentPage, currentPage,
comments, comments,
matchedComments,
]; ];
} }

View File

@ -70,6 +70,8 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>(); bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>();
double get textScaleFactor => double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val; preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
@ -103,31 +104,26 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return SafeArea( return SafeArea(
child: ColoredBox( child: Column(
color: Theme.of(context).canvasColor, mainAxisSize: MainAxisSize.min,
child: Material( children: <Widget>[
child: Column( ListTile(
mainAxisSize: MainAxisSize.min, onTap: () => context.pop(item.url),
children: <Widget>[ title: const Text('Link to article'),
ListTile(
onTap: () => context.pop(item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => context.pop(
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
), ),
), ListTile(
onTap: () => context.pop(
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
),
title: const Text('Link to HN'),
),
],
), ),
); );
}, },
); );
} else { } else {
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}'; linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
} }
if (linkToShare != null) { if (linkToShare != null) {

View File

@ -233,10 +233,13 @@ class HackiApp extends StatelessWidget {
buildWhen: (PreferenceState previous, PreferenceState current) => buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor || previous.appColor != current.appColor ||
previous.font != current.font || previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor, previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled,
builder: (BuildContext context, PreferenceState state) { builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme( return AdaptiveTheme(
key: ValueKey<String>('${state.appColor}${state.font}'), key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}''',
),
light: ThemeData( light: ThemeData(
primaryColor: state.appColor, primaryColor: state.appColor,
colorScheme: ColorScheme.fromSwatch( colorScheme: ColorScheme.fromSwatch(
@ -287,7 +290,46 @@ class HackiApp extends StatelessWidget {
title: 'Hacki', title: 'Hacki',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith( theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
useMaterial3: false, useMaterial3: state.material3Enabled,
dividerTheme: state.material3Enabled
? DividerThemeData(
color: Palette.grey.withOpacity(0.2),
)
: null,
switchTheme: state.material3Enabled
? SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states
.contains(MaterialState.selected)) {
return null;
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
)
: null,
bottomSheetTheme: state.material3Enabled
? BottomSheetThemeData(
modalElevation: 0,
backgroundColor: isDarkModeEnabled
? Palette.black
: Palette.white,
shape: const RoundedRectangleBorder(),
)
: null,
inputDecorationTheme: state.material3Enabled
? InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
)
: null,
), ),
routerConfig: router, routerConfig: router,
), ),

View File

@ -25,13 +25,18 @@ enum DiscoverableFeature {
featureId: 'jump_up_button_with_long_press', featureId: 'jump_up_button_with_long_press',
title: 'Shortcut', title: 'Shortcut',
description: 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.''', '''Tapping on this button will take you to the previous root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
), ),
jumpDownButton( jumpDownButton(
featureId: 'jump_down_button_with_long_press', featureId: 'jump_down_button_with_long_press',
title: 'Shortcut', title: 'Shortcut',
description: 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.''', '''Tapping on this button will take you to the next root level comment.\n\nLong press on it to jump to the end of this thread.''',
),
searchInThread(
featureId: 'search_in_thread',
title: 'Search in Thread',
description: '''Search for comments in this thread.''',
); );
const DiscoverableFeature({ const DiscoverableFeature({

View File

@ -43,6 +43,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const ReaderModePreference(), const ReaderModePreference(),
const CustomTabPreference(), const CustomTabPreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
const Material3Preference(),
], ],
); );
@ -73,6 +74,7 @@ const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true; const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false; const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false; const bool _customTabModeDefaultValue = false;
const bool _material3ModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1; const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index; final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index; final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@ -285,6 +287,26 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.'; String get subtitle => 'some sort of magic.';
} }
class Material3Preference extends BooleanPreference {
const Material3Preference({bool? val})
: super(val: val ?? _material3ModeDefaultValue);
@override
Material3Preference copyWith({required bool? val}) {
return Material3Preference(val: val);
}
@override
String get key => 'material3Mode';
@override
String get title => 'Enable Material 3';
@override
String get subtitle =>
'''experiment feature. Please open an issue on GitHub if you notice anything weird.''';
}
/// Whether or not to use Custom Tabs for launching URLs. /// Whether or not to use Custom Tabs for launching URLs.
/// If false, default browser will be used. /// If false, default browser will be used.
/// ///

View File

@ -49,7 +49,7 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext(); super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType == if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) { DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache'); locator.get<Logger>().i('Resetting comments in CommentCache');
Future<void>.delayed( Future<void>.delayed(
Durations.ms500, Durations.ms500,
locator.get<CommentCache>().resetComments, locator.get<CommentCache>().resetComments,

View File

@ -142,9 +142,6 @@ class _ItemScreenState extends State<ItemScreen>
final TextEditingController commentEditingController = final TextEditingController commentEditingController =
TextEditingController(); TextEditingController();
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener = final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create(); ScrollOffsetListener.create();
final Throttle storyLinkTapThrottle = Throttle( final Throttle storyLinkTapThrottle = Throttle(
@ -187,6 +184,7 @@ class _ItemScreenState extends State<ItemScreen>
DiscoverableFeature.openStoryInWebView.featureId, DiscoverableFeature.openStoryInWebView.featureId,
DiscoverableFeature.jumpUpButton.featureId, DiscoverableFeature.jumpUpButton.featureId,
DiscoverableFeature.jumpDownButton.featureId, DiscoverableFeature.jumpDownButton.featureId,
DiscoverableFeature.searchInThread.featureId,
}, },
); );
}) })
@ -272,8 +270,6 @@ class _ItemScreenState extends State<ItemScreen>
children: <Widget>[ children: <Widget>[
Positioned.fill( Positioned.fill(
child: MainView( child: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener, scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController, commentEditingController: commentEditingController,
authState: authState, authState: authState,
@ -313,13 +309,10 @@ class _ItemScreenState extends State<ItemScreen>
); );
}, },
), ),
Positioned( const Positioned(
right: Dimens.pt12, right: Dimens.pt12,
bottom: Dimens.pt36, bottom: Dimens.pt36,
child: CustomFloatingActionButton( child: CustomFloatingActionButton(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
),
), ),
Positioned( Positioned(
bottom: Dimens.zero, bottom: Dimens.zero,
@ -348,8 +341,6 @@ class _ItemScreenState extends State<ItemScreen>
fontSizeIconButtonKey: fontSizeIconButtonKey, fontSizeIconButtonKey: fontSizeIconButtonKey,
), ),
body: MainView( body: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener, scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController, commentEditingController: commentEditingController,
authState: authState, authState: authState,
@ -358,10 +349,7 @@ class _ItemScreenState extends State<ItemScreen>
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
), ),
floatingActionButton: CustomFloatingActionButton( floatingActionButton: const CustomFloatingActionButton(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
),
bottomSheet: ReplyBox( bottomSheet: ReplyBox(
textEditingController: commentEditingController, textEditingController: commentEditingController,
focusNode: focusNode, focusNode: focusNode,
@ -437,42 +425,36 @@ class _ItemScreenState extends State<ItemScreen>
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return SafeArea( return SafeArea(
child: ColoredBox( child: Column(
color: Theme.of(context).canvasColor, mainAxisSize: MainAxisSize.min,
child: Material( children: <Widget>[
color: Palette.transparent, ListTile(
child: Column( leading: const Icon(Icons.av_timer),
mainAxisSize: MainAxisSize.min, title: const Text('View ancestors'),
children: <Widget>[ onTap: () {
ListTile( context.pop();
leading: const Icon(Icons.av_timer), onTimeMachineActivated(comment);
title: const Text('View ancestors'), },
onTap: () { enabled:
context.pop(); comment.level > 0 && !(comment.dead || comment.deleted),
onTimeMachineActivated(comment);
},
enabled:
comment.level > 0 && !(comment.dead || comment.deleted),
),
ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
context.pop();
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
), ),
), ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
context.pop();
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
), ),
); );
}, },

View File

@ -34,6 +34,7 @@ class CustomAppBar extends AppBar {
), ),
const Spacer(), const Spacer(),
], ],
const InThreadSearchIconButton(),
IconButton( IconButton(
key: fontSizeIconButtonKey, key: fontSizeIconButtonKey,
icon: Text( icon: Text(

View File

@ -7,18 +7,12 @@ 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';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CustomFloatingActionButton extends StatelessWidget { class CustomFloatingActionButton extends StatelessWidget {
const CustomFloatingActionButton({ const CustomFloatingActionButton({
required this.itemScrollController,
required this.itemPositionsListener,
super.key, super.key,
}); });
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<CommentsCubit, CommentsState>( return BlocBuilder<CommentsCubit, CommentsState>(
@ -45,10 +39,8 @@ class CustomFloatingActionButton extends StatelessWidget {
color: Palette.white, color: Palette.white,
), ),
child: InkWell( child: InkWell(
onLongPress: () => itemScrollController.scrollTo( onLongPress: () =>
index: 0, context.read<CommentsCubit>().scrollTo(index: 0),
duration: Durations.ms400,
),
child: FloatingActionButton.small( child: FloatingActionButton.small(
backgroundColor: backgroundColor:
Theme.of(context).scaffoldBackgroundColor, Theme.of(context).scaffoldBackgroundColor,
@ -58,10 +50,7 @@ class CustomFloatingActionButton extends StatelessWidget {
heroTag: UniqueKey().hashCode, heroTag: UniqueKey().hashCode,
onPressed: () { onPressed: () {
HapticFeedbackUtil.selection(); HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToPreviousRoot( context.read<CommentsCubit>().scrollToPreviousRoot();
itemScrollController,
itemPositionsListener,
);
}, },
child: Icon( child: Icon(
Icons.keyboard_arrow_up, Icons.keyboard_arrow_up,
@ -77,10 +66,9 @@ class CustomFloatingActionButton extends StatelessWidget {
color: Palette.white, color: Palette.white,
), ),
child: InkWell( child: InkWell(
onLongPress: () => itemScrollController.scrollTo( onLongPress: () => context
index: state.comments.length, .read<CommentsCubit>()
duration: Durations.ms400, .scrollTo(index: state.comments.length),
),
child: FloatingActionButton.small( child: FloatingActionButton.small(
backgroundColor: backgroundColor:
Theme.of(context).scaffoldBackgroundColor, Theme.of(context).scaffoldBackgroundColor,
@ -89,10 +77,7 @@ class CustomFloatingActionButton extends StatelessWidget {
heroTag: UniqueKey().hashCode, heroTag: UniqueKey().hashCode,
onPressed: () { onPressed: () {
HapticFeedbackUtil.selection(); HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot( context.read<CommentsCubit>().scrollToNextRoot();
itemScrollController,
itemPositionsListener,
);
}, },
child: Icon( child: Icon(
Icons.keyboard_arrow_down, Icons.keyboard_arrow_down,

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class InThreadSearchIconButton extends StatelessWidget {
const InThreadSearchIconButton({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>.value(
value: context.read<CommentsCubit>(),
child: IconButton(
tooltip: 'Search in thread',
icon: const CustomDescribedFeatureOverlay(
tapTarget: Icon(
Icons.search,
color: Palette.white,
),
feature: DiscoverableFeature.searchInThread,
child: Icon(
Icons.search,
),
),
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: Theme.of(context).canvasColor,
builder: (BuildContext _) {
return BlocProvider<CommentsCubit>.value(
value: context.read<CommentsCubit>(),
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState previous, CommentsState current) =>
previous.matchedComments != current.matchedComments,
builder: (BuildContext context, CommentsState state) {
return Container(
height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt8,
),
child: TextField(
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search in this thread',
suffixText: state.matchedComments.isEmpty
? ''
: '${state.matchedComments.length} results',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: context.read<CommentsCubit>().search,
),
),
Expanded(
child: ListView(
children: <Widget>[
if (state.matchedComments.isEmpty)
const Padding(
padding: EdgeInsets.only(top: Dimens.pt120),
child: CenteredText.empty(),
)
else
for (final int i in state.matchedComments)
CommentTile(
comment: state.comments.elementAt(i),
fetchMode: FetchMode.lazy,
actionable: false,
onTap: () {
context.pop();
context.read<CommentsCubit>().scrollTo(
index: i + 1,
alignment: 0.1,
);
},
),
],
),
),
],
),
);
},
),
);
},
);
},
),
);
}
}

View File

@ -1,4 +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/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';
@ -27,7 +28,7 @@ class LinkIconButton extends StatelessWidget {
), ),
), ),
onPressed: () => LinkUtil.launch( onPressed: () => LinkUtil.launch(
'https://news.ycombinator.com/item?id=$storyId', '${Constants.hackerNewsItemLinkPrefix}$storyId',
context, context,
useHackiForHnLink: false, useHackiForHnLink: false,
), ),

View File

@ -18,8 +18,6 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MainView extends StatelessWidget { class MainView extends StatelessWidget {
const MainView({ const MainView({
required this.itemScrollController,
required this.itemPositionsListener,
required this.scrollOffsetListener, required this.scrollOffsetListener,
required this.commentEditingController, required this.commentEditingController,
required this.authState, required this.authState,
@ -30,8 +28,6 @@ class MainView extends StatelessWidget {
super.key, super.key,
}); });
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
final ScrollOffsetListener scrollOffsetListener; final ScrollOffsetListener scrollOffsetListener;
final TextEditingController commentEditingController; final TextEditingController commentEditingController;
final AuthState authState; final AuthState authState;
@ -67,8 +63,10 @@ class MainView extends StatelessWidget {
}, },
child: ScrollablePositionedList.builder( child: ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemScrollController: itemScrollController, itemScrollController:
itemPositionsListener: itemPositionsListener, context.read<CommentsCubit>().itemScrollController,
itemPositionsListener:
context.read<CommentsCubit>().itemPositionsListener,
itemCount: state.comments.length + 2, itemCount: state.comments.length + 2,
padding: EdgeInsets.only(top: topPadding), padding: EdgeInsets.only(top: topPadding),
scrollOffsetListener: scrollOffsetListener, scrollOffsetListener: scrollOffsetListener,
@ -130,7 +128,6 @@ class MainView extends StatelessWidget {
}, },
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
itemScrollController: itemScrollController,
), ),
); );
}, },
@ -185,7 +182,7 @@ class _ParentItemSection extends StatelessWidget {
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
static const double _viewParentButtonWidth = 100; static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 80; static const double _viewRootButtonWidth = 85;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -66,175 +66,167 @@ class MorePopupMenu extends StatelessWidget {
builder: (BuildContext context, VoteState voteState) { builder: (BuildContext context, VoteState voteState) {
final bool upvoted = voteState.vote == Vote.up; final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down; final bool downvoted = voteState.vote == Vote.down;
return ColoredBox( return Column(
color: Theme.of(context).canvasColor, mainAxisSize: MainAxisSize.min,
child: Material( children: <Widget>[
color: Palette.transparent, BlocProvider<UserCubit>(
child: Column( create: (BuildContext context) =>
mainAxisSize: MainAxisSize.min, UserCubit()..init(userId: item.by),
children: <Widget>[ child: BlocBuilder<UserCubit, UserState>(
BlocProvider<UserCubit>( builder: (BuildContext context, UserState state) {
create: (BuildContext context) => return Semantics(
UserCubit()..init(userId: item.by), excludeSemantics: state.status == Status.inProgress,
child: BlocBuilder<UserCubit, UserState>( child: ListTile(
builder: (BuildContext context, UserState state) { leading: const Icon(
return Semantics( Icons.account_circle,
excludeSemantics: state.status == Status.inProgress, ),
child: ListTile( title: Text(item.by),
leading: const Icon( subtitle: Text(
Icons.account_circle, state.user.description,
), ),
title: Text(item.by), onTap: () {
subtitle: Text( context.pop();
state.user.description, showDialog<void>(
), context: context,
onTap: () { builder: (BuildContext context) => AlertDialog(
context.pop(); semanticLabel:
showDialog<void>( '''About ${state.user.id}. ${state.user.about}''',
context: context, title: Text(
builder: (BuildContext context) => AlertDialog( 'About ${state.user.id}',
semanticLabel: ),
'''About ${state.user.id}. ${state.user.about}''', content: state.user.about.isEmpty
title: Text( ? const Row(
'About ${state.user.id}', mainAxisAlignment:
), MainAxisAlignment.center,
content: state.user.about.isEmpty children: <Widget>[
? const Row( Text(
mainAxisAlignment: 'empty',
MainAxisAlignment.center, style: TextStyle(
children: <Widget>[ color: Palette.grey,
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
), ),
linkStyle: TextStyle(
color:
Theme.of(context).primaryColor,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(
link.url,
context,
),
semanticsLabel: state.user.about,
), ),
actions: <Widget>[ ],
TextButton( )
onPressed: () { : SelectableLinkify(
locator text: HtmlUtil.parseHtml(
.get<AppReviewService>() state.user.about,
.requestReview();
context.pop();
onSearchUserTapped(context);
},
child: const Text(
'Search',
), ),
), linkStyle: TextStyle(
TextButton( color: Theme.of(context).primaryColor,
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
},
child: const Text(
'Okay',
), ),
onOpen: (LinkableElement link) =>
LinkUtil.launch(
link.url,
context,
),
semanticsLabel: state.user.about,
), ),
], actions: <Widget>[
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
), ),
); TextButton(
}, onPressed: () {
), locator
); .get<AppReviewService>()
}, .requestReview();
), context.pop();
), },
ListTile( child: const Text(
leading: Icon( 'Okay',
FeatherIcons.chevronUp, ),
color: upvoted ? Theme.of(context).primaryColor : null, ),
), ],
title: Text( ),
upvoted ? 'Upvoted' : 'Upvote', );
style: upvoted },
? TextStyle(color: Theme.of(context).primaryColor) ),
: null, );
), },
subtitle: ),
item is Story ? Text(item.score.toString()) : null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
leading: Icon(
FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
downvoted ? 'Downvoted' : 'Downvote',
style: downvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => context.pop(MenuAction.fav),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
'Share',
),
onTap: () => context.pop(MenuAction.share),
),
ListTile(
leading: const Icon(Icons.local_police),
title: const Text(
'Flag',
),
onTap: () => context.pop(MenuAction.flag),
),
ListTile(
leading: Icon(
isBlocked ? Icons.visibility : Icons.visibility_off,
),
title: Text(
isBlocked ? 'Unblock' : 'Block',
),
onTap: () => context.pop(MenuAction.block),
),
ListTile(
leading: const Icon(Icons.close),
title: const Text(
'Cancel',
),
onTap: () => context.pop(MenuAction.cancel),
),
],
), ),
), ListTile(
leading: Icon(
FeatherIcons.chevronUp,
color: upvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
upvoted ? 'Upvoted' : 'Upvote',
style: upvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
subtitle: item is Story ? Text(item.score.toString()) : null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
leading: Icon(
FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
downvoted ? 'Downvoted' : 'Downvote',
style: downvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => context.pop(MenuAction.fav),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
'Share',
),
onTap: () => context.pop(MenuAction.share),
),
ListTile(
leading: const Icon(Icons.local_police),
title: const Text(
'Flag',
),
onTap: () => context.pop(MenuAction.flag),
),
ListTile(
leading: Icon(
isBlocked ? Icons.visibility : Icons.visibility_off,
),
title: Text(
isBlocked ? 'Unblock' : 'Block',
),
onTap: () => context.pop(MenuAction.block),
),
ListTile(
leading: const Icon(Icons.close),
title: const Text(
'Cancel',
),
onTap: () => context.pop(MenuAction.cancel),
),
],
); );
}, },
), ),
@ -245,6 +237,8 @@ class MorePopupMenu extends StatelessWidget {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Theme.of(context).canvasColor,
showDragHandle: true,
builder: (BuildContext context) { builder: (BuildContext context) {
return BlocProvider<SearchCubit>( return BlocProvider<SearchCubit>(
create: (_) => SearchCubit() create: (_) => SearchCubit()
@ -256,19 +250,10 @@ class MorePopupMenu extends StatelessWidget {
child: Container( child: Container(
height: MediaQuery.of(context).size.height - Dimens.pt120, height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
margin: const EdgeInsets.only(top: Dimens.pt12), child: const Material(
child: Material(
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Container( Expanded(
height: Dimens.pt4,
width: Dimens.pt24,
decoration: BoxDecoration(
color: Palette.grey,
borderRadius: BorderRadius.circular(Dimens.pt16),
),
),
const Expanded(
child: SearchScreen( child: SearchScreen(
fromUserDialog: true, fromUserDialog: true,
), ),

View File

@ -1,6 +1,7 @@
export 'custom_app_bar.dart'; export 'custom_app_bar.dart';
export 'custom_floating_action_button.dart'; export 'custom_floating_action_button.dart';
export 'fav_icon_button.dart'; export 'fav_icon_button.dart';
export 'in_thread_search_icon_button.dart';
export 'link_icon_button.dart'; export 'link_icon_button.dart';
export 'login_dialog.dart'; export 'login_dialog.dart';
export 'main_view.dart'; export 'main_view.dart';

View File

@ -32,6 +32,12 @@ class CenteredText extends StatelessWidget {
text: 'blocked', text: 'blocked',
); );
const CenteredText.empty({Key? key})
: this(
key: key,
text: 'empty',
);
final String text; final String text;
final Color color; final Color color;

View File

@ -10,7 +10,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.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';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CommentTile extends StatelessWidget { class CommentTile extends StatelessWidget {
const CommentTile({ const CommentTile({
@ -27,7 +26,6 @@ class CommentTile extends StatelessWidget {
this.selectable = true, this.selectable = true,
this.level = 0, this.level = 0,
this.onTap, this.onTap,
this.itemScrollController,
}); });
final String? opUsername; final String? opUsername;
@ -37,7 +35,6 @@ class CommentTile extends StatelessWidget {
final bool collapsable; final bool collapsable;
final bool selectable; final bool selectable;
final FetchMode fetchMode; final FetchMode fetchMode;
final ItemScrollController? itemScrollController;
final void Function(Comment)? onReplyTapped; final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped; final void Function(Comment, Rect?)? onMoreTapped;
@ -370,14 +367,14 @@ class CommentTile extends StatelessWidget {
..collapse(onStateChanged: HapticFeedbackUtil.selection); ..collapse(onStateChanged: HapticFeedbackUtil.selection);
if (collapseCubit.state.collapsed && if (collapseCubit.state.collapsed &&
preferenceCubit.state.autoScrollEnabled) { preferenceCubit.state.autoScrollEnabled) {
final List<Comment> comments = final CommentsCubit commentsCubit = context.read<CommentsCubit>();
context.read<CommentsCubit>().state.comments; final List<Comment> comments = commentsCubit.state.comments;
final int indexOfNextComment = comments.indexOf(comment) + 1; final int indexOfNextComment = comments.indexOf(comment) + 1;
if (indexOfNextComment < comments.length) { if (indexOfNextComment < comments.length) {
Future<void>.delayed( Future<void>.delayed(
Durations.ms300, Durations.ms300,
() { () {
itemScrollController?.scrollTo( commentsCubit.itemScrollController.scrollTo(
index: indexOfNextComment, index: indexOfNextComment,
alignment: 0.1, alignment: 0.1,
duration: Durations.ms300, duration: Durations.ms300,

View File

@ -15,13 +15,19 @@ class CustomChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool useMaterial3 = Theme.of(context).useMaterial3;
return FilterChip( return FilterChip(
shadowColor: Palette.transparent, shadowColor: Palette.transparent,
selectedShadowColor: Palette.transparent, selectedShadowColor: Palette.transparent,
backgroundColor: Palette.transparent, backgroundColor: Palette.transparent,
shape: StadiumBorder( side: useMaterial3 && !selected
side: BorderSide(color: Theme.of(context).primaryColor), ? BorderSide(color: Theme.of(context).colorScheme.onSurface)
), : null,
shape: Theme.of(context).useMaterial3
? null
: StadiumBorder(
side: BorderSide(color: Theme.of(context).primaryColor),
),
label: Text(label), label: Text(label),
labelStyle: TextStyle( labelStyle: TextStyle(
color: selected ? Theme.of(context).colorScheme.onPrimary : null, color: selected ? Theme.of(context).colorScheme.onPrimary : null,

View File

@ -37,6 +37,7 @@ class _TapDownWrapperState extends State<TapDownWrapper>
onTapDown: onTapDown, onTapDown: onTapDown,
onTapUp: onTapUp, onTapUp: onTapUp,
onTapCancel: onTapCancel, onTapCancel: onTapCancel,
behavior: HitTestBehavior.opaque,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animation:
CurvedAnimation(parent: controller, curve: Curves.decelerate), CurvedAnimation(parent: controller, curve: Curves.decelerate),

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.0.1+126 version: 2.1.0+127
publish_to: none publish_to: none
environment: environment: