Compare commits

...

7 Commits

19 changed files with 524 additions and 255 deletions

View File

@ -0,0 +1,5 @@
- Ability to use manual pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -370,7 +370,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
/// Scroll to next root level comment.
void scrollToNextRoot() {
void scrollToNextRoot({VoidCallback? onError}) {
final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
@ -422,6 +422,10 @@ class CommentsCubit extends Cubit<CommentsState> {
return;
}
}
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
}
/// Scroll to previous root level comment.
@ -460,27 +464,49 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
void search(String query) {
void search(String query, {String author = ''}) {
resetSearch();
if (query.isEmpty) return;
late final bool Function(Comment cmt) conditionSatisfied;
final String lowercaseQuery = query.toLowerCase();
if (query.isEmpty && author.isEmpty) {
return;
} else if (author.isEmpty) {
conditionSatisfied =
(Comment cmt) => cmt.text.toLowerCase().contains(lowercaseQuery);
} else if (query.isEmpty) {
conditionSatisfied = (Comment cmt) => cmt.by == author;
} else {
conditionSatisfied = (Comment cmt) =>
cmt.text.toLowerCase().contains(lowercaseQuery) && cmt.by == author;
}
emit(
state.copyWith(
inThreadSearchQuery: query,
inThreadSearchAuthor: author,
),
);
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)) {
if (conditionSatisfied(cmt)) {
emit(
state.copyWith(
matchedComments: <int>[...state.matchedComments, i],
inThreadSearchQuery: query,
),
);
}
}
}
void resetSearch() =>
emit(state.copyWith(matchedComments: <int>[], inThreadSearchQuery: ''));
void resetSearch() => emit(
state.copyWith(
matchedComments: <int>[],
inThreadSearchQuery: '',
inThreadSearchAuthor: '',
),
);
List<int> _sortKids(List<int> kids) {
switch (state.order) {
@ -509,6 +535,7 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
@ -517,7 +544,16 @@ class CommentsCubit extends Cubit<CommentsState> {
comment.copyWith(hidden: hidden),
];
emit(state.copyWith(comments: updatedComments));
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit(
state.copyWith(
comments: updatedComments,
idToCommentMap: updatedIdToCommentMap,
),
);
}
}

View File

@ -13,6 +13,7 @@ class CommentsState extends Equatable {
required this.item,
required this.comments,
required this.matchedComments,
required this.idToCommentMap,
required this.status,
required this.fetchParentStatus,
required this.fetchRootStatus,
@ -22,6 +23,7 @@ class CommentsState extends Equatable {
required this.isOfflineReading,
required this.currentPage,
required this.inThreadSearchQuery,
required this.inThreadSearchAuthor,
});
CommentsState.init({
@ -31,15 +33,18 @@ class CommentsState extends Equatable {
required this.order,
}) : comments = <Comment>[],
matchedComments = <int>[],
idToCommentMap = <int, Comment>{},
status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false,
currentPage = 0,
inThreadSearchQuery = '';
inThreadSearchQuery = '',
inThreadSearchAuthor = '';
final Item item;
final List<Comment> comments;
final Map<int, Comment> idToCommentMap;
final CommentsStatus status;
final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus;
@ -49,6 +54,7 @@ class CommentsState extends Equatable {
final bool isOfflineReading;
final int currentPage;
final String inThreadSearchQuery;
final String inThreadSearchAuthor;
/// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments;
@ -57,6 +63,7 @@ class CommentsState extends Equatable {
Item? item,
List<Comment>? comments,
List<int>? matchedComments,
Map<int, Comment>? idToCommentMap,
CommentsStatus? status,
CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus,
@ -66,6 +73,7 @@ class CommentsState extends Equatable {
bool? isOfflineReading,
int? currentPage,
String? inThreadSearchQuery,
String? inThreadSearchAuthor,
}) {
return CommentsState(
item: item ?? this.item,
@ -81,11 +89,40 @@ class CommentsState extends Equatable {
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage,
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
);
}
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
static final Map<int, bool> _isResponseCache = <int, bool>{};
bool isResponse(Comment comment) {
if (_isResponseCache.containsKey(comment.id)) {
return _isResponseCache[comment.id]!;
}
if (comment.isRoot) {
_isResponseCache[comment.id] = false;
return false;
}
final Comment? precedingComment = idToCommentMap[comment.parent];
if (precedingComment == null) {
_isResponseCache[comment.id] = false;
return false;
} else if (item.id == precedingComment.parent && item.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else if (idToCommentMap[precedingComment.parent]?.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else {
_isResponseCache[comment.id] = false;
return false;
}
}
@override
List<Object?> get props => <Object?>[
item,
@ -100,5 +137,7 @@ class CommentsState extends Equatable {
comments,
matchedComments,
inThreadSearchQuery,
inThreadSearchAuthor,
idToCommentMap,
];
}

View File

@ -72,7 +72,11 @@ class PreferenceState extends Equatable {
bool get material3Enabled => _isOn<Material3Preference>();
bool get paginationEnabled => _isOn<PaginationPreference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void onExactMatchToggled() {
emit(
state.copyWith(
params: state.params.copyWith(
exactMatch: !state.params.exactMatch,
),
),
);
search(state.params.query);
}
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,

View File

@ -19,6 +19,7 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
@ -229,16 +230,22 @@ class HackiApp extends StatelessWidget {
)..init(),
),
],
child: BlocBuilder<PreferenceCubit, PreferenceState>(
child: BlocConsumer<PreferenceCubit, PreferenceState>(
listenWhen: (PreferenceState previous, PreferenceState current) =>
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
listener: (_, PreferenceState state) {
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
},
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled,
previous.material3Enabled != current.material3Enabled ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}''',
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -254,7 +261,7 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor,
brightness: Brightness.dark,
),
canvasColor: Palette.black,
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
fontFamily: state.font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,

View File

@ -41,10 +41,12 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const CollapseModePreference(),
const ReaderModePreference(),
const CustomTabPreference(),
const EyeCandyModePreference(),
const Material3Preference(),
const PaginationPreference(),
const ManualPaginationPreference(),
const SwipeGesturePreference(),
const HapticFeedbackPreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
const Material3Preference(),
],
);
@ -68,6 +70,8 @@ const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _hapticFeedbackModeDefaultValue = true;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
@ -101,7 +105,7 @@ class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode';
@override
String get title => 'Enable Swipe Gesture';
String get title => 'Swipe Gesture';
@override
String get subtitle =>
@ -289,20 +293,20 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.';
}
class PaginationPreference extends BooleanPreference {
const PaginationPreference({bool? val})
class ManualPaginationPreference extends BooleanPreference {
const ManualPaginationPreference({bool? val})
: super(val: val ?? _paginationModeDefaultValue);
@override
PaginationPreference copyWith({required bool? val}) {
return PaginationPreference(val: val);
ManualPaginationPreference copyWith({required bool? val}) {
return ManualPaginationPreference(val: val);
}
@override
String get key => 'paginationMode';
@override
String get title => 'Enable Pagination';
String get title => 'Manual Pagination';
@override
String get subtitle => '''so you can get stuff done.''';
@ -321,7 +325,7 @@ class Material3Preference extends BooleanPreference {
String get key => 'material3Mode';
@override
String get title => 'Enable Material 3';
String get title => 'Material 3';
@override
String get subtitle =>
@ -355,6 +359,47 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid;
}
class TrueDarkModePreference extends BooleanPreference {
const TrueDarkModePreference({bool? val})
: super(val: val ?? _trueDarkModeDefaultValue);
@override
TrueDarkModePreference copyWith({required bool? val}) {
return TrueDarkModePreference(val: val);
}
@override
String get key => 'trueDarkMode';
@override
String get title => 'True Dark Mode';
@override
String get subtitle => 'real dark.';
}
class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue);
@override
HapticFeedbackPreference copyWith({required bool? val}) {
return HapticFeedbackPreference(val: val);
}
@override
String get key => 'hapticFeedbackMode';
@override
String get title => 'Haptic Feedback';
@override
String get subtitle => '';
@override
bool get isDisplayable => Platform.isIOS;
}
class FetchModePreference extends IntPreference {
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
@ -444,7 +489,7 @@ class StoryMarkingModePreference extends IntPreference {
String get key => 'storyMarkingMode';
@override
String get title => 'Mark a Story as Read on';
String get title => 'Mark as Read on';
}
class AppColorPreference extends IntPreference {

View File

@ -8,31 +8,36 @@ class SearchParams extends Equatable {
required this.filters,
required this.query,
required this.page,
this.sorted = false,
required this.sorted,
required this.exactMatch,
});
SearchParams.init()
: filters = <SearchFilter>{},
query = '',
page = 0,
sorted = false;
sorted = false,
exactMatch = false;
final Set<SearchFilter> filters;
final String query;
final int page;
final bool sorted;
final bool exactMatch;
SearchParams copyWith({
Set<SearchFilter>? filters,
String? query,
int? page,
bool? sorted,
bool? exactMatch,
}) {
return SearchParams(
filters: filters ?? this.filters,
query: query ?? this.query,
page: page ?? this.page,
sorted: sorted ?? this.sorted,
exactMatch: exactMatch ?? this.exactMatch,
);
}
@ -43,6 +48,7 @@ class SearchParams extends Equatable {
query: query,
page: page,
sorted: sorted,
exactMatch: exactMatch,
);
}
@ -54,16 +60,19 @@ class SearchParams extends Equatable {
query: query,
page: page,
sorted: sorted,
exactMatch: exactMatch,
);
}
String get filteredQuery {
final StringBuffer buffer = StringBuffer();
final String encodedQuery =
Uri.encodeComponent(exactMatch ? '"$query"' : query);
if (sorted) {
buffer.write('search_by_date?query=${Uri.encodeComponent(query)}');
buffer.write('search_by_date?query=$encodedQuery');
} else {
buffer.write('search?query=${Uri.encodeComponent(query)}');
buffer.write('search?query=$encodedQuery');
}
final Iterable<NumericFilter> numericFilters =
@ -111,5 +120,6 @@ class SearchParams extends Equatable {
query,
page,
sorted,
exactMatch,
];
}

View File

@ -3,6 +3,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/extensions/context_extension.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -74,7 +75,11 @@ class CustomFloatingActionButton extends StatelessWidget {
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot();
context.read<CommentsCubit>().scrollToNextRoot(
onError: () => context.showSnackBar(
content: '''No more root level comment below.''',
),
);
},
child: Icon(
Icons.keyboard_arrow_down,

View File

@ -1,6 +1,7 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -87,8 +88,10 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
value: widget.commentsCubit,
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState previous, CommentsState current) =>
previous.matchedComments != current.matchedComments,
previous.matchedComments != current.matchedComments ||
previous.inThreadSearchAuthor != current.inThreadSearchAuthor,
builder: (BuildContext context, CommentsState state) {
final AuthState authState = context.read<AuthBloc>().state;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
@ -118,7 +121,10 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
),
),
),
onChanged: context.read<CommentsCubit>().search,
onChanged: (String text) => widget.commentsCubit.search(
text,
author: state.inThreadSearchAuthor,
),
),
),
IconButton(
@ -136,6 +142,50 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
controller: scrollController,
shrinkWrap: true,
children: <Widget>[
Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: state.inThreadSearchAuthor == state.item.by,
label: 'by OP',
onSelected: (bool value) {
if (value) {
widget.commentsCubit.search(
state.inThreadSearchQuery,
author: state.item.by,
);
} else {
widget.commentsCubit.search(
state.inThreadSearchQuery,
);
}
},
),
const SizedBox(
width: Dimens.pt12,
),
if (authState.isLoggedIn)
CustomChip(
selected:
state.inThreadSearchAuthor == authState.username,
label: 'by me',
onSelected: (bool value) {
if (value) {
widget.commentsCubit.search(
state.inThreadSearchQuery,
author: authState.username,
);
} else {
widget.commentsCubit.search(
state.inThreadSearchQuery,
);
}
},
),
],
),
for (final int i in state.matchedComments)
CommentTile(
index: i,

View File

@ -101,6 +101,7 @@ class MainView extends StatelessWidget {
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
@ -109,6 +110,7 @@ class MainView extends StatelessWidget {
level: comment.level,
opUsername: state.item.by,
fetchMode: state.fetchMode,
isResponse: state.isResponse(comment),
onReplyTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {

View File

@ -12,14 +12,13 @@ class QrCodeViewScreen extends StatelessWidget {
static const String routeName = 'qr-code-view';
static const int qrCodeVersion = 4;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Palette.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -35,7 +34,6 @@ class QrCodeViewScreen extends StatelessWidget {
eyeShape: QrEyeShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
version: qrCodeVersion,
size: 300,
),
),

View File

@ -30,17 +30,29 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
final RefreshController refreshController = RefreshController();
final ScrollController scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
static const Duration chipsAnimationDuration = Durations.ms300;
@override
void initState() {
super.initState();
scrollController.addListener(onScroll);
}
@override
void dispose() {
refreshController.dispose();
scrollController.dispose();
focusNode.dispose();
textEditingController.dispose();
super.dispose();
}
void onScroll() => focusNode.unfocus();
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -59,217 +71,6 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: TextField(
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search Hacker News',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: (String val) {
if (val.isNotEmpty) {
debouncer.run(() {
context.read<SearchCubit>().search(val);
});
}
},
),
),
const SizedBox(
height: Dimens.pt6,
),
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: state.showDateRangeShortcutChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
],
),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.dateFilter,
initialStartDate: state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged:
context.read<SearchCubit>().onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
],
),
if (state.status == SearchStatus.loading &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: CustomCircularProgressIndicator(),
),
],
if (state.status == SearchStatus.loaded &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: Text(
'Nothing found...',
style: TextStyle(
color: Palette.grey,
),
),
),
],
Expanded(
child: SmartRefresher(
enablePullDown: false,
@ -310,6 +111,240 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
? const NeverScrollableScrollPhysics()
: null,
children: <Widget>[
Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: TextField(
controller: textEditingController,
focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search Hacker News',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: (String val) {
if (val.isNotEmpty) {
debouncer.run(() {
context.read<SearchCubit>().search(val);
});
}
},
),
),
const SizedBox(
height: Dimens.pt6,
),
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: state.showDateRangeShortcutChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
],
),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.dateFilter,
initialStartDate:
state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter:
state.params.get<PostedByFilter>(),
onChanged: context
.read<SearchCubit>()
.onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onExactMatchToggled(),
selected: state.params.exactMatch,
label: '''exact match''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange
.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
if (state.status == SearchStatus.loading &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: CustomCircularProgressIndicator(),
),
],
if (state.status == SearchStatus.loaded &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: Text(
'Nothing found...',
style: TextStyle(
color: Palette.grey,
),
),
),
],
],
),
...state.results
.map(
(Item e) => <Widget>[

View File

@ -49,7 +49,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
final DateTime? start = filter?.startTime;
final DateTime? end = filter?.endTime;
if (start == null && end == null) {
return '''from X to Y''';
return '''date range''';
} else if (start == end) {
return '''from ${_formatDateTime(start)}''';
} else {

View File

@ -24,6 +24,7 @@ class CommentTile extends StatelessWidget {
this.actionable = true,
this.collapsable = true,
this.selectable = true,
this.isResponse = false,
this.level = 0,
this.index,
this.onTap,
@ -36,6 +37,7 @@ class CommentTile extends StatelessWidget {
final bool actionable;
final bool collapsable;
final bool selectable;
final bool isResponse;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped;
@ -171,6 +173,15 @@ class CommentTile extends StatelessWidget {
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
if (isResponse)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(
Icons.reply,
size: 16,
color: Palette.grey,
),
),
const Spacer(),
Text(
comment.timeAgo,

View File

@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart' show StringCharacters, immutable;
import 'package:linkify/linkify.dart';
final RegExp _urlRegex = RegExp(
r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,~-]*)',
r'''^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,'~-]*)''',
caseSensitive: false,
dotAll: true,
);

View File

@ -50,7 +50,7 @@ class _StoriesListViewState extends State<StoriesListView>
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.paginationEnabled != current.paginationEnabled,
previous.manualPaginationEnabled != current.manualPaginationEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (StoriesState previous, StoriesState current) =>
@ -88,7 +88,7 @@ class _StoriesListViewState extends State<StoriesListView>
context.read<PinCubit>().refresh();
},
onLoadMore: () {
if (preferenceState.paginationEnabled) {
if (preferenceState.manualPaginationEnabled) {
refreshController
..refreshCompleted(resetFooterState: true)
..loadComplete();
@ -100,7 +100,7 @@ class _StoriesListViewState extends State<StoriesListView>
onPinned: context.read<PinCubit>().pinStory,
header: state.isOfflineReading ? null : header,
loadStyle: LoadStyle.HideAlways,
footer: preferenceState.paginationEnabled &&
footer: preferenceState.manualPaginationEnabled &&
state.statusByType[widget.storyType] == Status.success &&
(state.storiesByType[widget.storyType]?.length ?? 0) <
(state.storyIdsByType[widget.storyType]?.length ?? 0)

View File

@ -1,7 +1,17 @@
import 'package:flutter/services.dart';
abstract class HapticFeedbackUtil {
static void selection() => HapticFeedback.selectionClick();
static bool enabled = true;
static void light() => HapticFeedback.lightImpact();
static void selection() {
if (enabled) {
HapticFeedback.selectionClick();
}
}
static void light() {
if (enabled) {
HapticFeedback.lightImpact();
}
}
}

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 2.2.0+128
version: 2.3.0+129
publish_to: none
environment: