Compare commits

...

8 Commits

20 changed files with 582 additions and 278 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. /// Scroll to next root level comment.
void scrollToNextRoot() { void scrollToNextRoot({VoidCallback? onError}) {
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
@ -422,6 +422,10 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
} }
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
} }
/// Scroll to previous root level comment. /// 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(); resetSearch();
if (query.isEmpty) return; late final bool Function(Comment cmt) conditionSatisfied;
final String lowercaseQuery = query.toLowerCase(); 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)) { for (final int i in 0.to(state.comments.length, inclusive: false)) {
final Comment cmt = state.comments.elementAt(i); final Comment cmt = state.comments.elementAt(i);
if (cmt.text.toLowerCase().contains(lowercaseQuery)) { if (conditionSatisfied(cmt)) {
emit( emit(
state.copyWith( state.copyWith(
matchedComments: <int>[...state.matchedComments, i], matchedComments: <int>[...state.matchedComments, i],
inThreadSearchQuery: query,
), ),
); );
} }
} }
} }
void resetSearch() => void resetSearch() => emit(
emit(state.copyWith(matchedComments: <int>[], inThreadSearchQuery: '')); state.copyWith(
matchedComments: <int>[],
inThreadSearchQuery: '',
inThreadSearchAuthor: '',
),
);
List<int> _sortKids(List<int> kids) { List<int> _sortKids(List<int> kids) {
switch (state.order) { switch (state.order) {
@ -509,6 +535,7 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment); _commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any( final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword), (String keyword) => comment.text.toLowerCase().contains(keyword),
); );
@ -517,7 +544,16 @@ class CommentsCubit extends Cubit<CommentsState> {
comment.copyWith(hidden: hidden), 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.item,
required this.comments, required this.comments,
required this.matchedComments, required this.matchedComments,
required this.idToCommentMap,
required this.status, required this.status,
required this.fetchParentStatus, required this.fetchParentStatus,
required this.fetchRootStatus, required this.fetchRootStatus,
@ -22,6 +23,7 @@ class CommentsState extends Equatable {
required this.isOfflineReading, required this.isOfflineReading,
required this.currentPage, required this.currentPage,
required this.inThreadSearchQuery, required this.inThreadSearchQuery,
required this.inThreadSearchAuthor,
}); });
CommentsState.init({ CommentsState.init({
@ -31,15 +33,18 @@ class CommentsState extends Equatable {
required this.order, required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
matchedComments = <int>[], matchedComments = <int>[],
idToCommentMap = <int, Comment>{},
status = CommentsStatus.idle, status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle, fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle, fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0, currentPage = 0,
inThreadSearchQuery = ''; inThreadSearchQuery = '',
inThreadSearchAuthor = '';
final Item item; final Item item;
final List<Comment> comments; final List<Comment> comments;
final Map<int, Comment> idToCommentMap;
final CommentsStatus status; final CommentsStatus status;
final CommentsStatus fetchParentStatus; final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus; final CommentsStatus fetchRootStatus;
@ -49,6 +54,7 @@ class CommentsState extends Equatable {
final bool isOfflineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
final String inThreadSearchQuery; final String inThreadSearchQuery;
final String inThreadSearchAuthor;
/// Indexes of comments that matches the query for in-thread search. /// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments; final List<int> matchedComments;
@ -57,6 +63,7 @@ class CommentsState extends Equatable {
Item? item, Item? item,
List<Comment>? comments, List<Comment>? comments,
List<int>? matchedComments, List<int>? matchedComments,
Map<int, Comment>? idToCommentMap,
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus, CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus, CommentsStatus? fetchRootStatus,
@ -66,6 +73,7 @@ class CommentsState extends Equatable {
bool? isOfflineReading, bool? isOfflineReading,
int? currentPage, int? currentPage,
String? inThreadSearchQuery, String? inThreadSearchQuery,
String? inThreadSearchAuthor,
}) { }) {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
@ -81,11 +89,40 @@ class CommentsState extends Equatable {
isOfflineReading: isOfflineReading ?? this.isOfflineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery, inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
); );
} }
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet(); 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 @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,
@ -100,5 +137,7 @@ class CommentsState extends Equatable {
comments, comments,
matchedComments, matchedComments,
inThreadSearchQuery, inThreadSearchQuery,
inThreadSearchAuthor,
idToCommentMap,
]; ];
} }

View File

@ -72,7 +72,11 @@ class PreferenceState extends Equatable {
bool get material3Enabled => _isOn<Material3Preference>(); 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 => double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val; preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); 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) { void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith( final DateTime updatedStart = start.copyWith(
second: 0, second: 0,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart'; import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -87,8 +88,10 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
value: widget.commentsCubit, value: widget.commentsCubit,
child: BlocBuilder<CommentsCubit, CommentsState>( child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState previous, CommentsState current) => buildWhen: (CommentsState previous, CommentsState current) =>
previous.matchedComments != current.matchedComments, previous.matchedComments != current.matchedComments ||
previous.inThreadSearchAuthor != current.inThreadSearchAuthor,
builder: (BuildContext context, CommentsState state) { builder: (BuildContext context, CommentsState state) {
final AuthState authState = context.read<AuthBloc>().state;
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
appBar: AppBar( 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( IconButton(
@ -136,6 +142,50 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
controller: scrollController, controller: scrollController,
shrinkWrap: true, shrinkWrap: true,
children: <Widget>[ 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) for (final int i in state.matchedComments)
CommentTile( CommentTile(
index: i, index: i,

View File

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

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.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/extensions/extensions.dart';
@ -77,8 +78,23 @@ class MorePopupMenu extends StatelessWidget {
return Semantics( return Semantics(
excludeSemantics: state.status == Status.inProgress, excludeSemantics: state.status == Status.inProgress,
child: ListTile( child: ListTile(
leading: const Icon( leading: Column(
Icons.account_circle, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
AnimatedCrossFade(
alignment: Alignment.center,
duration: Durations.ms300,
crossFadeState: state.status.isLoading
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: const Icon(
Icons.account_circle_outlined,
),
secondChild: const Icon(
Icons.account_circle,
),
),
],
), ),
title: Text(item.by), title: Text(item.by),
subtitle: Text( subtitle: Text(

View File

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

View File

@ -30,17 +30,29 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> with ItemActionMixin { class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
final RefreshController refreshController = RefreshController(); final RefreshController refreshController = RefreshController();
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond); final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
static const Duration chipsAnimationDuration = Durations.ms300; static const Duration chipsAnimationDuration = Durations.ms300;
@override
void initState() {
super.initState();
scrollController.addListener(onScroll);
}
@override @override
void dispose() { void dispose() {
refreshController.dispose(); refreshController.dispose();
scrollController.dispose(); scrollController.dispose();
focusNode.dispose();
textEditingController.dispose();
super.dispose(); super.dispose();
} }
void onScroll() => focusNode.unfocus();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -59,217 +71,6 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ 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( Expanded(
child: SmartRefresher( child: SmartRefresher(
enablePullDown: false, enablePullDown: false,
@ -310,6 +111,240 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: null, : null,
children: <Widget>[ 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 ...state.results
.map( .map(
(Item e) => <Widget>[ (Item e) => <Widget>[

View File

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

View File

@ -24,6 +24,7 @@ class CommentTile extends StatelessWidget {
this.actionable = true, this.actionable = true,
this.collapsable = true, this.collapsable = true,
this.selectable = true, this.selectable = true,
this.isResponse = false,
this.level = 0, this.level = 0,
this.index, this.index,
this.onTap, this.onTap,
@ -36,6 +37,7 @@ class CommentTile extends StatelessWidget {
final bool actionable; final bool actionable;
final bool collapsable; final bool collapsable;
final bool selectable; final bool selectable;
final bool isResponse;
final FetchMode fetchMode; final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped; final void Function(Comment)? onReplyTapped;
@ -171,6 +173,15 @@ class CommentTile extends StatelessWidget {
textScaleFactor: textScaleFactor:
MediaQuery.of(context).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(), const Spacer(),
Text( Text(
comment.timeAgo, comment.timeAgo,

View File

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

View File

@ -3,6 +3,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.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';
@ -50,7 +51,7 @@ class _StoriesListViewState extends State<StoriesListView>
buildWhen: (PreferenceState previous, PreferenceState current) => buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled || previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled || previous.metadataEnabled != current.metadataEnabled ||
previous.paginationEnabled != current.paginationEnabled, previous.manualPaginationEnabled != current.manualPaginationEnabled,
builder: (BuildContext context, PreferenceState preferenceState) { builder: (BuildContext context, PreferenceState preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>( return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (StoriesState previous, StoriesState current) => listenWhen: (StoriesState previous, StoriesState current) =>
@ -68,8 +69,18 @@ class _StoriesListViewState extends State<StoriesListView>
previous.currentPageByType[storyType] == 0) || previous.currentPageByType[storyType] == 0) ||
(previous.storiesByType[storyType]!.length != (previous.storiesByType[storyType]!.length !=
current.storiesByType[storyType]!.length) || current.storiesByType[storyType]!.length) ||
(previous.readStoriesIds.length != current.readStoriesIds.length), (previous.readStoriesIds.length !=
current.readStoriesIds.length) ||
(previous.statusByType[widget.storyType] !=
current.statusByType[widget.storyType]),
builder: (BuildContext context, StoriesState state) { builder: (BuildContext context, StoriesState state) {
bool shouldShowLoadButton() {
return preferenceState.manualPaginationEnabled &&
state.statusByType[widget.storyType] == Status.success &&
(state.storiesByType[widget.storyType]?.length ?? 0) <
(state.storyIdsByType[widget.storyType]?.length ?? 0);
}
return ItemsListView<Story>( return ItemsListView<Story>(
showOfflineBanner: true, showOfflineBanner: true,
markReadStories: preferenceState.markReadStoriesEnabled, markReadStories: preferenceState.markReadStoriesEnabled,
@ -88,7 +99,7 @@ class _StoriesListViewState extends State<StoriesListView>
context.read<PinCubit>().refresh(); context.read<PinCubit>().refresh();
}, },
onLoadMore: () { onLoadMore: () {
if (preferenceState.paginationEnabled) { if (preferenceState.manualPaginationEnabled) {
refreshController refreshController
..refreshCompleted(resetFooterState: true) ..refreshCompleted(resetFooterState: true)
..loadComplete(); ..loadComplete();
@ -100,30 +111,38 @@ class _StoriesListViewState extends State<StoriesListView>
onPinned: context.read<PinCubit>().pinStory, onPinned: context.read<PinCubit>().pinStory,
header: state.isOfflineReading ? null : header, header: state.isOfflineReading ? null : header,
loadStyle: LoadStyle.HideAlways, loadStyle: LoadStyle.HideAlways,
footer: preferenceState.paginationEnabled && footer: Center(
state.statusByType[widget.storyType] == Status.success && child: AnimatedCrossFade(
(state.storiesByType[widget.storyType]?.length ?? 0) < alignment: Alignment.center,
(state.storyIdsByType[widget.storyType]?.length ?? 0) crossFadeState: shouldShowLoadButton()
? Padding( ? CrossFadeState.showFirst
padding: const EdgeInsets.only( : CrossFadeState.showSecond,
left: Dimens.pt48, duration: Durations.ms300,
right: Dimens.pt48, firstChild: Padding(
top: Dimens.pt36, padding: const EdgeInsets.only(
bottom: Dimens.pt12, left: Dimens.pt48,
), right: Dimens.pt48,
child: OutlinedButton( top: Dimens.pt36,
onPressed: loadMoreStories, bottom: Dimens.pt12,
style: ButtonStyle( ),
foregroundColor: MaterialStateColor.resolveWith( child: OutlinedButton(
(_) => Theme.of(context).colorScheme.onSurface, onPressed: loadMoreStories,
), style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(double.infinity, Dimens.pt48),
), ),
child: Text( foregroundColor: MaterialStateColor.resolveWith(
'''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 2}''', (_) => Theme.of(context).colorScheme.onSurface,
), ),
), ),
) child: Text(
: null, '''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 2}''',
),
),
),
secondChild: const SizedBox.shrink(),
),
),
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
itemBuilder: (Widget child, Story story) { itemBuilder: (Widget child, Story story) {
return Slidable( return Slidable(

View File

@ -1,7 +1,17 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class HapticFeedbackUtil { 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 name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.2.0+128 version: 2.3.1+130
publish_to: none publish_to: none
environment: environment: