mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
0310507c96 | |||
58c646e232 | |||
08328e2ca1 | |||
86b7228ffd | |||
e103c88ca6 | |||
94323a04e0 | |||
4776c375a1 |
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal 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.
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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>[
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
Reference in New Issue
Block a user