Compare commits

...

19 Commits

Author SHA1 Message Date
71aa42118d fix web analyzer (#327) 2023-11-26 09:43:23 +09:00
4f21d3e6bd update pubspec.yaml (#325) 2023-11-15 10:50:00 -08:00
96d0fe9e5e fix new comment indicator. (#324) 2023-11-15 01:15:10 -08:00
69eee3e278 fix url rendering. (#323) 2023-11-14 23:52:05 -08:00
36bcd996c0 bump Flutter version to 3.13.9 (#322) 2023-11-14 23:22:09 -08:00
5fc39d8b8b fix code block formatting. (#321) 2023-11-14 20:25:42 -08:00
5dce7787e1 improve text rendering performance. (#320) 2023-11-14 17:14:06 -08:00
8888dde792 allow marking stories as read from homepage. (#319) 2023-11-14 14:35:27 -08:00
6c8fc4cf87 fix response indicator when lazy fetching is enabled. (#317) 2023-11-13 21:10:47 -08:00
ae9cc109db revert "improve caching strategy. (#312)" (#316) 2023-11-13 19:42:20 -08:00
c8976ed17b improve caching strategy. (#312) 2023-11-11 00:31:09 -08:00
ff7e115418 fix manual pagination button. (#310) 2023-11-06 22:46:44 -08:00
0310507c96 revert html util change. (#309) 2023-11-06 19:40:53 -08:00
58c646e232 update html_util.dart (#308) 2023-11-06 17:10:10 -08:00
08328e2ca1 update url_linkifier.dart (#307) 2023-11-06 14:19:25 -08:00
86b7228ffd improve response indicator. (#306) 2023-11-06 12:45:46 -08:00
e103c88ca6 fix favorites export. (#305) 2023-11-05 22:47:45 -08:00
94323a04e0 fix response indicator. (#304) 2023-11-05 21:22:02 -08:00
4776c375a1 UX improvements on HN and in-thread search. (#303) 2023-11-05 19:48:01 -08:00
55 changed files with 1040 additions and 471 deletions

View File

@ -76,6 +76,15 @@ final class SharedPrefsCore {
return true return true
} }
fileprivate func remove(key: String?) -> Bool{
if let key = key {
let keyStore = NSUbiquitousKeyValueStore()
keyStore.removeObject(forKey: key)
}
return true
}
} }
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin { public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "remove":
if let params = call.arguments as? [String: Any] {
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.remove(key: key)
result(res)
}
case "setBool": case "setBool":
if let params = call.arguments as? [String: Any] { if let params = call.arguments as? [String: Any] {
let val = params[valKey] as? Bool let val = params[valKey] as? Bool

View File

@ -15,6 +15,14 @@ class SyncedSharedPreferences {
const MethodChannel(channel), const MethodChannel(channel),
); );
Future<bool?> remove({
required String key,
}) async {
return _channel.invokeMethod('remove', <String, dynamic>{
'key': key,
});
}
Future<bool?> setBool({ Future<bool?> setBool({
required String key, required String key,
required bool val, required bool val,

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

@ -0,0 +1,4 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

@ -0,0 +1,4 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

@ -159,7 +159,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7 synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -11,13 +11,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({ AuthBloc({
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(), }) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(const AuthState.init()) { super(const AuthState.init()) {
@ -31,7 +31,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository; final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
Future<void> onInitialize( Future<void> onInitialize(
@ -41,7 +41,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; final String? username = await _authRepository.username;
User? user = await _storiesRepository.fetchUser(id: username!); User? user = await _hackerNewsRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation, /// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story), /// if user has no public activity (posting a comment or story),
@ -89,7 +89,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User? user = await _storiesRepository.fetchUser(id: event.username); final User? user =
await _hackerNewsRepository.fetchUser(id: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user ?? User.emptyWithId(event.username), user: user ?? User.emptyWithId(event.username),

View File

@ -19,15 +19,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit, required FilterCubit filterCubit,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger, Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit, _filterCubit = filterCubit,
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
@ -37,6 +37,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoriesLoadMore>(onLoadMore); on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded); on<StoryLoaded>(onStoryLoaded);
on<StoryRead>(onStoryRead); on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded); on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload); on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload); on<StoriesCancelDownload>(onCancelDownload);
@ -49,7 +50,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit; final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger; final Logger _logger;
DeviceScreenType? deviceScreenType; DeviceScreenType? deviceScreenType;
@ -113,13 +114,14 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: type)); add(StoriesLoaded(type: type));
}); });
} else { } else {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type); final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
emit( emit(
state state
.copyWithStoryIdsUpdated(type: type, to: ids) .copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0), .copyWithCurrentPageUpdated(type: type, to: 0),
); );
_storiesRepository _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize)) .fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) { .listen((Story story) {
add(StoryLoaded(story: story, type: type)); add(StoryLoaded(story: story, type: type));
@ -196,7 +198,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: event.type)); add(StoriesLoaded(type: event.type));
}); });
} else { } else {
_storiesRepository _hackerNewsRepository
.fetchStoriesStream( .fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist( ids: state.storyIdsByType[event.type]!.sublist(
lower, lower,
@ -273,7 +275,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
..remove(StoryType.latest); ..remove(StoryType.latest);
for (final StoryType type in prioritizedTypes) { for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type); final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
await _offlineRepository.cacheStoryIds(type: type, ids: ids); await _offlineRepository.cacheStoryIds(type: type, ids: ids);
prioritizedIds.addAll(ids); prioritizedIds.addAll(ids);
} }
@ -293,7 +296,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
); );
final Set<int> latestIds = <int>{}; final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds( final List<int> ids = await _hackerNewsRepository.fetchStoryIds(
type: StoryType.latest, type: StoryType.latest,
); );
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids); await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
@ -347,7 +350,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
_logger.d('fetching story $id'); _logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStory(id: id); final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) { if (story == null) {
if (isPrioritized) { if (isPrioritized) {
@ -377,7 +380,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
/// In other words, we are prioritizing the story itself instead of /// In other words, we are prioritizing the story itself instead of
/// the comments in the story. /// the comments in the story.
late final StreamSubscription<Comment>? downloadStream; late final StreamSubscription<Comment>? downloadStream;
downloadStream = _storiesRepository downloadStream = _hackerNewsRepository
.fetchAllChildrenComments(ids: story.kids) .fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>() .whereType<Comment>()
.listen( .listen(
@ -460,7 +463,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoryRead event, StoryRead event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
unawaited(_preferenceRepository.updateHasRead(event.story.id)); unawaited(_preferenceRepository.addHasRead(event.story.id));
emit( emit(
state.copyWith( state.copyWith(
@ -469,6 +472,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
); );
} }
Future<void> onStoryUnread(
StoryUnread event,
Emitter<StoriesState> emit,
) async {
unawaited(_preferenceRepository.removeHasRead(event.story.id));
emit(
state.copyWith(
readStoriesIds: <int>{...state.readStoriesIds}..remove(event.story.id),
),
);
}
Future<void> onClearAllReadStories( Future<void> onClearAllReadStories(
ClearAllReadStories event, ClearAllReadStories event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,

View File

@ -95,6 +95,15 @@ class StoryRead extends StoriesEvent {
List<Object?> get props => <Object?>[story]; List<Object?> get props => <Object?>[story];
} }
class StoryUnread extends StoriesEvent {
StoryUnread({required this.story});
final Story story;
@override
List<Object?> get props => <Object?>[story];
}
class ClearAllReadStories extends StoriesEvent { class ClearAllReadStories extends StoriesEvent {
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];

View File

@ -23,12 +23,12 @@ Future<void> setUpLocator() async {
output: LogUtil.logOutput(logOutputFile), output: LogUtil.logOutput(logOutputFile),
), ),
) )
..registerSingleton<StoriesRepository>(StoriesRepository()) ..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository()) ..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository()) ..registerSingleton<SearchRepository>(SearchRepository())
..registerSingleton<AuthRepository>(AuthRepository()) ..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository()) ..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<OfflineRepository>(OfflineRepository()) ..registerSingleton<OfflineRepository>(OfflineRepository())
..registerSingleton<DraftCache>(DraftCache()) ..registerSingleton<DraftCache>(DraftCache())
..registerSingleton<CommentCache>(CommentCache()) ..registerSingleton<CommentCache>(CommentCache())

View File

@ -32,18 +32,15 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder, required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
SembastRepository? sembastRepository,
Logger? logger, Logger? logger,
}) : _filterCubit = filterCubit, }) : _filterCubit = filterCubit,
_collapseCache = collapseCache, _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
@ -58,8 +55,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final SembastRepository _sembastRepository;
final Logger _logger; final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
@ -96,7 +92,7 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
_streamSubscription = _storiesRepository _streamSubscription = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream( .fetchAllCommentsRecursivelyStream(
ids: targetAncestors!.last.kids, ids: targetAncestors!.last.kids,
level: targetAncestors.last.level + 1, level: targetAncestors.last.level + 1,
@ -122,7 +118,10 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.isOfflineReading final Item updatedItem = state.isOfflineReading
? item ? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ?? : await _hackerNewsRepository
.fetchItem(id: item.id)
.then(_toBuildable)
.onError((_, __) => item) ??
item; item;
final List<int> kids = _sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
@ -135,12 +134,13 @@ class CommentsCubit extends Cubit<CommentsState> {
} else { } else {
switch (state.fetchMode) { switch (state.fetchMode) {
case FetchMode.lazy: case FetchMode.lazy:
commentStream = _storiesRepository.fetchCommentsStream( commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids, ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null, getFromCache: useCommentCache ? _commentCache.getComment : null,
); );
case FetchMode.eager: case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream( commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids, ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null, getFromCache: useCommentCache ? _commentCache.getComment : null,
); );
@ -187,16 +187,16 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItem(id: item.id) ?? item; await _hackerNewsRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = _sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream; late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) { if (state.fetchMode == FetchMode.lazy) {
commentStream = _storiesRepository.fetchCommentsStream( commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids, ids: kids,
); );
} else { } else {
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream( commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids, ids: kids,
); );
} }
@ -245,14 +245,17 @@ class CommentsCubit extends Cubit<CommentsState> {
/// Ignoring because the subscription will be cancelled in close() /// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions // ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription = final StreamSubscription<Comment> streamSubscription =
_storiesRepository _hackerNewsRepository
.fetchCommentsStream(ids: comment.kids) .fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment) .asyncMap(_toBuildableComment)
.whereNotNull() .whereNotNull()
.listen((Comment cmt) { .listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent); _collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt); _commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit( emit(
state.copyWith( state.copyWith(
@ -260,6 +263,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.comments.indexOf(comment) + offset + 1, state.comments.indexOf(comment) + offset + 1,
cmt.copyWith(level: level), cmt.copyWith(level: level),
), ),
idToCommentMap: updatedIdToCommentMap,
), ),
); );
offset++; offset++;
@ -289,7 +293,7 @@ class CommentsCubit extends Cubit<CommentsState> {
HapticFeedbackUtil.light(); HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress)); emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
final Item? parent = final Item? parent =
await _storiesRepository.fetchItem(id: state.item.parent); await _hackerNewsRepository.fetchItem(id: state.item.parent);
if (parent == null) { if (parent == null) {
return; return;
@ -310,7 +314,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadRootThread() async { Future<void> loadRootThread() async {
HapticFeedbackUtil.light(); HapticFeedbackUtil.light();
emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress)); emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
final Story? parent = await _storiesRepository final Story? parent = await _hackerNewsRepository
.fetchParentStory(id: state.item.id) .fetchParentStory(id: state.item.id)
.then(_toBuildableStory); .then(_toBuildableStory);
@ -370,7 +374,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 +426,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 +468,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) {
@ -507,8 +537,8 @@ class CommentsCubit extends Cubit<CommentsState> {
if (comment != null) { if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent); _collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment); _commentCache.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 +547,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

@ -12,13 +12,13 @@ class FavCubit extends Cubit<FavState> {
required AuthBloc authBloc, required AuthBloc authBloc,
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -26,7 +26,7 @@ class FavCubit extends Cubit<FavState> {
final AuthBloc _authBloc; final AuthBloc _authBloc;
final AuthRepository _authRepository; final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
static const int _pageSize = 20; static const int _pageSize = 20;
String? _username; String? _username;
@ -43,7 +43,7 @@ class FavCubit extends Cubit<FavState> {
currentPage: 0, currentPage: 0,
), ),
); );
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
) )
@ -73,7 +73,7 @@ class FavCubit extends Cubit<FavState> {
), ),
); );
final Item? item = await _storiesRepository.fetchItem(id: id); final Item? item = await _hackerNewsRepository.fetchItem(id: id);
if (item == null) return; if (item == null) return;
@ -119,7 +119,7 @@ class FavCubit extends Cubit<FavState> {
upper = len; upper = len;
} }
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: state.favIds.sublist( ids: state.favIds.sublist(
lower, lower,
@ -149,7 +149,7 @@ class FavCubit extends Cubit<FavState> {
_preferenceRepository.favList(of: username).then((List<int> favIds) { _preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(state.copyWith(favIds: favIds)); emit(state.copyWith(favIds: favIds));
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
) )

View File

@ -10,16 +10,16 @@ part 'history_state.dart';
class HistoryCubit extends Cubit<HistoryState> { class HistoryCubit extends Cubit<HistoryState> {
HistoryCubit({ HistoryCubit({
required AuthBloc authBloc, required AuthBloc authBloc,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(HistoryState.init()) { super(HistoryState.init()) {
init(); init();
} }
final AuthBloc _authBloc; final AuthBloc _authBloc;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
static const int _pageSize = 20; static const int _pageSize = 20;
void init() { void init() {
@ -27,7 +27,7 @@ class HistoryCubit extends Cubit<HistoryState> {
if (authState.isLoggedIn) { if (authState.isLoggedIn) {
final String username = authState.username; final String username = authState.username;
_storiesRepository _hackerNewsRepository
.fetchSubmitted(userId: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedIds) { .then((List<int>? submittedIds) {
emit( emit(
@ -38,7 +38,7 @@ class HistoryCubit extends Cubit<HistoryState> {
), ),
); );
if (submittedIds != null) { if (submittedIds != null) {
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: submittedIds.sublist( ids: submittedIds.sublist(
0, 0,
@ -66,7 +66,7 @@ class HistoryCubit extends Cubit<HistoryState> {
upper = len; upper = len;
} }
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: state.submittedIds.sublist( ids: state.submittedIds.sublist(
lower, lower,
@ -93,12 +93,12 @@ class HistoryCubit extends Cubit<HistoryState> {
), ),
); );
_storiesRepository _hackerNewsRepository
.fetchSubmitted(userId: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedIds) { .then((List<int>? submittedIds) {
emit(state.copyWith(submittedIds: submittedIds)); emit(state.copyWith(submittedIds: submittedIds));
if (submittedIds != null) { if (submittedIds != null) {
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: submittedIds.sublist( ids: submittedIds.sublist(
0, 0,

View File

@ -16,13 +16,13 @@ class NotificationCubit extends Cubit<NotificationState> {
NotificationCubit({ NotificationCubit({
required AuthBloc authBloc, required AuthBloc authBloc,
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit, _preferenceCubit = preferenceCubit,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository = _sembastRepository =
@ -54,7 +54,7 @@ class NotificationCubit extends Cubit<NotificationState> {
final AuthBloc _authBloc; final AuthBloc _authBloc;
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
String? _username; String? _username;
@ -82,7 +82,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) { for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id); Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id); comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) { if (comment != null) {
emit( emit(
state.copyWith( state.copyWith(
@ -160,7 +160,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) { for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id); Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id); comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) { if (comment != null) {
emit(state.copyWith(comments: <Comment>[...state.comments, comment])); emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
} }
@ -184,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
} }
Future<void> _fetchReplies() { Future<void> _fetchReplies() {
return _storiesRepository return _hackerNewsRepository
.fetchSubmitted(userId: _authBloc.state.username) .fetchSubmitted(userId: _authBloc.state.username)
.then((List<int>? submittedItems) async { .then((List<int>? submittedItems) async {
if (submittedItems != null) { if (submittedItems != null) {
@ -194,7 +194,9 @@ class NotificationCubit extends Cubit<NotificationState> {
); );
for (final int id in subscribedItems) { for (final int id in subscribedItems) {
await _storiesRepository.fetchItem(id: id).then((Item? item) async { await _hackerNewsRepository
.fetchItem(id: id)
.then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[]; final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids = final List<int> previousKids =
(await _sembastRepository.kids(of: id)) ?? <int>[]; (await _sembastRepository.kids(of: id)) ?? <int>[];
@ -216,7 +218,7 @@ class NotificationCubit extends Cubit<NotificationState> {
...state.unreadCommentsIds, ...state.unreadCommentsIds,
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)), ]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
); );
await _storiesRepository await _hackerNewsRepository
.fetchComment(id: newCommentId) .fetchComment(id: newCommentId)
.then((Comment? comment) { .then((Comment? comment) {
if (comment != null && !comment.dead && !comment.deleted) { if (comment != null && !comment.dead && !comment.deleted) {

View File

@ -1,5 +1,6 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
@ -9,28 +10,33 @@ part 'pin_state.dart';
class PinCubit extends Cubit<PinState> { class PinCubit extends Cubit<PinState> {
PinCubit({ PinCubit({
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _preferenceRepository = }) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PinState.init()) { super(PinState.init()) {
init(); init();
} }
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
void init() { void init() {
emit(PinState.init()); emit(PinState.init());
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) { _preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
emit(state.copyWith(pinnedStoriesIds: ids)); emit(state.copyWith(pinnedStoriesIds: ids));
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched); _hackerNewsRepository
.fetchStoriesStream(ids: ids)
.listen(_onStoryFetched);
}).whenComplete(() => emit(state.copyWith(status: Status.success))); }).whenComplete(() => emit(state.copyWith(status: Status.success)));
} }
void pinStory(Story story) { void pinStory(
Story story, {
VoidCallback? onDone,
}) {
if (!state.pinnedStoriesIds.contains(story.id)) { if (!state.pinnedStoriesIds.contains(story.id)) {
emit( emit(
state.copyWith( state.copyWith(
@ -39,10 +45,14 @@ class PinCubit extends Cubit<PinState> {
), ),
); );
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds); _preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
} }
} }
void unpinStory(Story story) { void unpinStory(
Story story, {
VoidCallback? onDone,
}) {
emit( emit(
state.copyWith( state.copyWith(
pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id), pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id),
@ -50,6 +60,7 @@ class PinCubit extends Cubit<PinState> {
), ),
); );
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds); _preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
} }
void refresh() { void refresh() {

View File

@ -11,13 +11,13 @@ part 'poll_state.dart';
class PollCubit extends Cubit<PollState> { class PollCubit extends Cubit<PollState> {
PollCubit({ PollCubit({
required Story story, required Story story,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _story = story, }) : _story = story,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PollState.init()); super(PollState.init());
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final Story _story; final Story _story;
Future<void> init({ Future<void> init({
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
if (pollOptionsIds.isEmpty || refresh) { if (pollOptionsIds.isEmpty || refresh) {
final Story? updatedStory = final Story? updatedStory =
await _storiesRepository.fetchStory(id: _story.id); await _hackerNewsRepository.fetchStory(id: _story.id);
if (updatedStory != null) { if (updatedStory != null) {
pollOptionsIds = updatedStory.parts; pollOptionsIds = updatedStory.parts;
@ -47,7 +47,7 @@ class PollCubit extends Cubit<PollState> {
} }
if (pollOptionsIds.isNotEmpty) { if (pollOptionsIds.isNotEmpty) {
final List<PollOption> pollOptions = (await _storiesRepository final List<PollOption> pollOptions = (await _hackerNewsRepository
.fetchPollOptionsStream(ids: pollOptionsIds) .fetchPollOptionsStream(ids: pollOptionsIds)
.toSet()) .toSet())
.toList(); .toList();

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

@ -7,16 +7,16 @@ import 'package:hacki/repositories/repositories.dart';
part 'user_state.dart'; part 'user_state.dart';
class UserCubit extends Cubit<UserState> { class UserCubit extends Cubit<UserState> {
UserCubit({StoriesRepository? storiesRepository}) UserCubit({HackerNewsRepository? hackerNewsRepository})
: _storiesRepository = : _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(const UserState.init()); super(const UserState.init());
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
void init({required String userId}) { void init({required String userId}) {
emit(state.copyWith(status: Status.inProgress)); emit(state.copyWith(status: Status.inProgress));
_storiesRepository.fetchUser(id: userId).then((User? user) { _hackerNewsRepository.fetchUser(id: userId).then((User? user) {
emit( emit(
state.copyWith( state.copyWith(
user: user ?? User.emptyWithId(userId), user: user ?? User.emptyWithId(userId),

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

@ -17,6 +17,7 @@ class BuildableComment extends Comment with Buildable {
required super.deleted, required super.deleted,
required super.hidden, required super.hidden,
required super.level, required super.level,
required super.isFromCache,
required this.elements, required this.elements,
}); });
@ -33,6 +34,7 @@ class BuildableComment extends Comment with Buildable {
deleted: comment.deleted, deleted: comment.deleted,
level: comment.level, level: comment.level,
hidden: comment.hidden, hidden: comment.hidden,
isFromCache: comment.isFromCache,
); );
@override @override
@ -53,6 +55,7 @@ class BuildableComment extends Comment with Buildable {
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,
level: level ?? this.level, level: level ?? this.level,
elements: elements, elements: elements,
isFromCache: isFromCache,
); );
} }

View File

@ -13,6 +13,7 @@ class Comment extends Item {
required super.deleted, required super.deleted,
required super.hidden, required super.hidden,
required this.level, required this.level,
required this.isFromCache,
}) : super( }) : super(
descendants: 0, descendants: 0,
parts: <int>[], parts: <int>[],
@ -21,9 +22,12 @@ class Comment extends Item {
type: '', type: '',
); );
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson(); Comment.fromJson(super.json, {this.level = 0})
: isFromCache = json['fromCache'] == true,
super.fromJson();
final int level; final int level;
final bool isFromCache;
String get metadata => '''by $by $timeAgo'''; String get metadata => '''by $by $timeAgo''';
@ -45,6 +49,7 @@ class Comment extends Item {
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,
level: level ?? this.level, level: level ?? this.level,
isFromCache: isFromCache,
); );
} }

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

@ -6,7 +6,8 @@ enum StoryMarkingMode {
tap('tapping'), tap('tapping'),
// Mark a story as read after user scrolls past or taps on it, whichever // Mark a story as read after user scrolls past or taps on it, whichever
// happens the first. // happens the first.
scrollPastOrTap('scrolling past or tapping'); scrollPastOrTap('scrolling past or tapping'),
swipeGestureOnly('swipe gesture only');
const StoryMarkingMode(this.label); const StoryMarkingMode(this.label);

View File

@ -1,19 +1,31 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
/// [StoriesRepository] is for fetching /// [HackerNewsRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User]. /// [Item] such as [Story], [PollOption], [Comment] or [User].
/// ///
/// You can learn more about the Hacker News API at /// You can learn more about the Hacker News API at
/// https://github.com/HackerNews/API. /// https://github.com/HackerNews/API.
class StoriesRepository { class HackerNewsRepository {
StoriesRepository({ HackerNewsRepository({
FirebaseClient? firebaseClient, FirebaseClient? firebaseClient,
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous(); SembastRepository? sembastRepository,
Logger? logger,
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>();
final FirebaseClient _firebaseClient; final FirebaseClient _firebaseClient;
final SembastRepository _sembastRepository;
final Logger _logger;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/'; static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<Map<String, dynamic>?> _fetchItemJson(int id) async { Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
@ -34,11 +46,10 @@ class StoriesRepository {
await _fetchItemJson(id).then((Map<String, dynamic>? json) { await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null; if (json == null) return null;
final String type = json['type'] as String; if (json.isStory) {
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (type == 'comment') { } else if (json.isComment) {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -57,11 +68,10 @@ class StoriesRepository {
final Map<String, dynamic> json = val as Map<String, dynamic>; final Map<String, dynamic> json = val as Map<String, dynamic>;
final String type = json['type'] as String; if (json.isStory) {
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (type == 'comment') { } else if (json.isComment) {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -226,7 +236,17 @@ class StoriesRepository {
if (json == null) return null; if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level); final Comment comment = Comment.fromJson(json, level: level);
if (!json.isFromCache) {
unawaited(_sembastRepository.cacheComment(comment));
}
return comment; return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
}); });
if (comment != null) { if (comment != null) {
@ -251,7 +271,17 @@ class StoriesRepository {
if (json == null) return null; if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level); final Comment comment = Comment.fromJson(json, level: level);
if (!json.isFromCache) {
unawaited(_sembastRepository.cacheComment(comment));
}
return comment; return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
}); });
if (comment != null) { if (comment != null) {
@ -275,11 +305,10 @@ class StoriesRepository {
await _fetchItemJson(id).then((Map<String, dynamic>? json) async { await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null; if (json == null) return null;
final String type = json['type'] as String; if (json.isStory) {
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (type == 'comment') { } else if (json.isComment) {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -343,12 +372,57 @@ class StoriesRepository {
Map<String, dynamic>? json, Map<String, dynamic>? json,
) async { ) async {
if (json == null) return null; if (json == null) return null;
final String text = json['text'] as String? ?? ''; final int? itemId = json.itemId;
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml, String? cachedText;
text, if (json.isComment && itemId != null) {
); cachedText =
json['text'] = parsedText; (await locator.get<SembastRepository>().getCachedComment(id: itemId))
?.text;
}
bool isValid(String? text) {
return cachedText != null &&
cachedText != '[delayed]' &&
cachedText != '[flagged]';
}
if (isValid(cachedText)) {
json
..text = cachedText
..isFromCache = true;
} else {
final String? text = json.text;
if (text == null || text.isEmpty) return json;
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
json.text = parsedText;
}
return json; return json;
} }
} }
extension on Map<String, dynamic> {
bool get isFromCache => this['fromCache'] == true;
set isFromCache(bool value) {
this['fromCache'] = value;
}
String? get text => this['text'] as String?;
set text(String? value) {
this['text'] = value;
}
int? get itemId => this['id'] as int?;
String? get type => this['type'] as String?;
bool get isStory => type == 'story' || type == 'job' || type == 'poll';
bool get isComment => type == 'comment';
}

View File

@ -384,7 +384,7 @@ class PreferenceRepository {
); );
} }
Future<void> updateHasRead(int storyId) async { Future<void> addHasRead(int storyId) async {
final String key = _getHasReadKey(storyId); final String key = _getHasReadKey(storyId);
if (Platform.isIOS) { if (Platform.isIOS) {
await _syncedPrefs.setBool(key: key, val: true); await _syncedPrefs.setBool(key: key, val: true);
@ -398,6 +398,17 @@ class PreferenceRepository {
} }
} }
Future<void> removeHasRead(int storyId) async {
final String key = _getHasReadKey(storyId);
if (Platform.isIOS) {
await _syncedPrefs.remove(key: key);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.remove(_getHasReadKey(storyId));
}
}
Future<void> clearAllReadStories() async { Future<void> clearAllReadStories() async {
if (Platform.isIOS) { if (Platform.isIOS) {
await _syncedPrefs.clearAll(); await _syncedPrefs.clearAll();

View File

@ -1,7 +1,7 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'hacker_news_repository.dart';
export 'offline_repository.dart'; export 'offline_repository.dart';
export 'post_repository.dart'; export 'post_repository.dart';
export 'preference_repository.dart'; export 'preference_repository.dart';
export 'search_repository.dart'; export 'search_repository.dart';
export 'sembast_repository.dart'; export 'sembast_repository.dart';
export 'stories_repository.dart';

View File

@ -60,6 +60,7 @@ class SearchRepository {
deleted: false, deleted: false,
hidden: false, hidden: false,
level: 0, level: 0,
isFromCache: false,
); );
yield comment; yield comment;
} else { } else {

View File

@ -196,12 +196,13 @@ class _HomeScreenState extends State<HomeScreen>
} }
void onStoryTapped(Story story) { void onStoryTapped(Story story) {
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled; final PreferenceState prefState = context.read<PreferenceCubit>().state;
final bool useReader = prefState.readerEnabled;
final StoryMarkingMode storyMarkingMode = prefState.storyMarkingMode;
final bool offlineReading = final bool offlineReading =
context.read<StoriesBloc>().state.isOfflineReading; context.read<StoriesBloc>().state.isOfflineReading;
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled; final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
final StoryMarkingMode storyMarkingMode = final bool markReadStoriesEnabled = prefState.markReadStoriesEnabled;
context.read<PreferenceCubit>().state.storyMarkingMode;
// If a story is a job story and it has a link to the job posting, // If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page. // it would be better to just navigate to the web page.
@ -210,7 +211,12 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) { if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId(); context.read<ReminderCubit>().removeLastReadStoryId();
} else { } else {
final ItemScreenArgs args = ItemScreenArgs(item: story); final bool shouldMarkNewComment = markReadStoriesEnabled &&
context.read<StoriesBloc>().state.readStoriesIds.contains(story.id);
final ItemScreenArgs args = ItemScreenArgs(
item: story,
shouldMarkNewComment: shouldMarkNewComment,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id); context.read<ReminderCubit>().updateLastReadStoryId(story.id);
@ -230,7 +236,7 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
if (storyMarkingMode.shouldDetectTapping) { if (markReadStoriesEnabled && storyMarkingMode.shouldDetectTapping) {
context.read<StoriesBloc>().add(StoryRead(story: story)); context.read<StoriesBloc>().add(StoryRead(story: story));
} }
@ -253,7 +259,7 @@ class _HomeScreenState extends State<HomeScreen>
final int? id = event.itemId; final int? id = event.itemId;
if (id != null) { if (id != null) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) { locator.get<HackerNewsRepository>().fetchItem(id: id).then((Item? item) {
if (mounted) { if (mounted) {
if (item != null) { if (item != null) {
goToItemScreen( goToItemScreen(
@ -272,7 +278,7 @@ class _HomeScreenState extends State<HomeScreen>
if (storyId == null) return; if (storyId == null) return;
await locator await locator
.get<StoriesRepository>() .get<HackerNewsRepository>()
.fetchStory(id: storyId) .fetchStory(id: storyId)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {
@ -297,7 +303,7 @@ class _HomeScreenState extends State<HomeScreen>
context.read<NotificationCubit>().markAsRead(commentId); context.read<NotificationCubit>().markAsRead(commentId);
await locator await locator
.get<StoriesRepository>() .get<HackerNewsRepository>()
.fetchStory(id: storyId) .fetchStory(id: storyId)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {

View File

@ -24,23 +24,26 @@ class ItemScreenArgs extends Equatable {
const ItemScreenArgs({ const ItemScreenArgs({
required this.item, required this.item,
this.onlyShowTargetComment = false, this.onlyShowTargetComment = false,
this.shouldMarkNewComment = false,
this.useCommentCache = false, this.useCommentCache = false,
this.targetComments, this.targetComments,
}); });
final Item item; final Item item;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool shouldMarkNewComment;
final List<Comment>? targetComments; final List<Comment>? targetComments;
/// when the user is trying to view a sub-thread from a main thread, we don't /// when the user is trying to view a sub-thread from a main thread, we don't
/// need to fetch comments from [StoriesRepository] since we have some, if not /// need to fetch comments from [HackerNewsRepository] since we have some,
/// all, comments cached in [CommentCache]. /// if not all, comments cached in [CommentCache].
final bool useCommentCache; final bool useCommentCache;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,
onlyShowTargetComment, onlyShowTargetComment,
shouldMarkNewComment,
targetComments, targetComments,
useCommentCache, useCommentCache,
]; ];
@ -52,6 +55,7 @@ class ItemScreen extends StatefulWidget {
required this.parentComments, required this.parentComments,
super.key, super.key,
this.splitViewEnabled = false, this.splitViewEnabled = false,
this.shouldMarkNewComment = false,
}); });
static const String routeName = 'item'; static const String routeName = 'item';
@ -81,6 +85,7 @@ class ItemScreen extends StatefulWidget {
child: ItemScreen( child: ItemScreen(
item: args.item, item: args.item,
parentComments: args.targetComments ?? <Comment>[], parentComments: args.targetComments ?? <Comment>[],
shouldMarkNewComment: args.shouldMarkNewComment,
), ),
), ),
); );
@ -123,6 +128,7 @@ class ItemScreen extends StatefulWidget {
item: args.item, item: args.item,
parentComments: args.targetComments ?? <Comment>[], parentComments: args.targetComments ?? <Comment>[],
splitViewEnabled: true, splitViewEnabled: true,
shouldMarkNewComment: args.shouldMarkNewComment,
), ),
), ),
), ),
@ -130,6 +136,7 @@ class ItemScreen extends StatefulWidget {
} }
final bool splitViewEnabled; final bool splitViewEnabled;
final bool shouldMarkNewComment;
final Item item; final Item item;
final List<Comment> parentComments; final List<Comment> parentComments;
@ -275,6 +282,7 @@ class _ItemScreenState extends State<ItemScreen>
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
shouldMarkNewComment: widget.shouldMarkNewComment,
), ),
), ),
BlocBuilder<SplitViewCubit, SplitViewState>( BlocBuilder<SplitViewCubit, SplitViewState>(
@ -349,6 +357,7 @@ class _ItemScreenState extends State<ItemScreen>
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
shouldMarkNewComment: widget.shouldMarkNewComment,
), ),
floatingActionButton: const CustomFloatingActionButton(), floatingActionButton: const CustomFloatingActionButton(),
bottomSheet: ReplyBox( bottomSheet: ReplyBox(

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

@ -25,6 +25,7 @@ class MainView extends StatelessWidget {
required this.splitViewEnabled, required this.splitViewEnabled,
required this.onMoreTapped, required this.onMoreTapped,
required this.onRightMoreTapped, required this.onRightMoreTapped,
required this.shouldMarkNewComment,
super.key, super.key,
}); });
@ -33,6 +34,7 @@ class MainView extends StatelessWidget {
final AuthState authState; final AuthState authState;
final double topPadding; final double topPadding;
final bool splitViewEnabled; final bool splitViewEnabled;
final bool shouldMarkNewComment;
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
@ -101,6 +103,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 +112,8 @@ 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),
isNew: shouldMarkNewComment && !comment.isFromCache,
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

@ -3,6 +3,7 @@ import 'dart:math';
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/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.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';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -45,7 +46,12 @@ class PinIconButton extends StatelessWidget {
if (pinned) { if (pinned) {
context.read<PinCubit>().unpinStory(story); context.read<PinCubit>().unpinStory(story);
} else { } else {
context.read<PinCubit>().pinStory(story); context.read<PinCubit>().pinStory(
story,
onDone: () => context.showSnackBar(
content: 'Pinned to home page.',
),
);
} }
}, },
), ),

View File

@ -378,7 +378,7 @@ class _ProfileScreenState extends State<ProfileScreen>
void onCommentTapped(Comment comment, {VoidCallback? then}) { void onCommentTapped(Comment comment, {VoidCallback? then}) {
throttle.run(() { throttle.run(() {
locator locator
.get<StoriesRepository>() .get<HackerNewsRepository>()
.fetchParentStoryWithComments(id: comment.parent) .fetchParentStoryWithComments(id: comment.parent)
.then(((Story, List<Comment>)? res) { .then(((Story, List<Comment>)? res) {
if (res != null && mounted) { if (res != null && mounted) {

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,243 @@ 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,
),
],
const SizedBox(
width: Dimens.pt8,
),
],
),
),
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,8 @@ 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.isNew = false,
this.level = 0, this.level = 0,
this.index, this.index,
this.onTap, this.onTap,
@ -36,6 +38,8 @@ 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 bool isNew;
final FetchMode fetchMode; final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped; final void Function(Comment)? onReplyTapped;
@ -171,6 +175,24 @@ 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,
),
),
if (!comment.dead && isNew)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(
Icons.sunny_snowing,
size: 16,
color: Palette.grey,
),
),
const Spacer(), const Spacer(),
Text( Text(
comment.timeAgo, comment.timeAgo,
@ -278,7 +300,7 @@ class CommentTile extends StatelessWidget {
); );
final double commentBackgroundColorOpacity = final double commentBackgroundColorOpacity =
Theme.of(context).brightness == Brightness.dark ? 0.03 : 0.15; Theme.of(context).canvasColor != Palette.white ? 0.03 : 0.15;
final Color commentColor = prefState.eyeCandyEnabled final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity) ? color.withOpacity(commentBackgroundColorOpacity)

View File

@ -107,7 +107,7 @@ class _CountDownReminderState extends State<CountdownReminder>
onTap: () { onTap: () {
if (state.storyId != null) { if (state.storyId != null) {
locator locator
.get<StoriesRepository>() .get<HackerNewsRepository>()
.fetchStory(id: state.storyId!) .fetchStory(id: state.storyId!)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {

View File

@ -27,25 +27,34 @@ class CodeLinkifier extends Linkifier {
list.add(element); list.add(element);
} else { } else {
final String matchedText = match.group(0)!; final String matchedText = match.group(0)!;
final num pos = element.text.indexOf(matchedText);
final List<String> splitTexts = element.text.split(matchedText); final List<String> splitTexts = element.text.split(matchedText);
int curPos = 0; final String preceding = splitTexts[0];
bool added = false;
for (final String text in splitTexts) { list.addAll(
list.addAll(parse(<LinkifyElement>[TextElement(text)], options)); parse(
<LinkifyElement>[
TextElement(preceding == '\n\n' ? '' : preceding),
],
options,
),
);
curPos += text.length; String trimmedText = matchedText
.replaceFirst(_openTag, '')
.replaceFirst(_closeTag, '');
trimmedText = '$trimmedText\n';
if (!added && curPos >= pos) { list
added = true; ..add(CodeElement(trimmedText))
final String trimmedText = matchedText ..addAll(
.replaceFirst(_openTag, '') parse(
.replaceFirst(_closeTag, ''); <LinkifyElement>[
list.add(CodeElement(trimmedText)); TextElement(splitTexts[1]),
} ],
} options,
),
);
} }
} else { } else {
list.add(element); list.add(element);

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,
); );
@ -84,7 +84,7 @@ class UrlLinkifier extends Linkifier {
if (url.endsWith(',')) { if (url.endsWith(',')) {
url = url.substring(0, max(0, url.length - 1)); url = url.substring(0, max(0, url.length - 1));
end = '$end,'; end = '${end ?? ''},';
} }
if ((options.humanize) || (options.removeWww)) { if ((options.humanize) || (options.removeWww)) {

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,33 +111,42 @@ 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(
key: ValueKey<Story>(story),
enabled: !preferenceState.swipeGestureEnabled, enabled: !preferenceState.swipeGestureEnabled,
startActionPane: ActionPane( startActionPane: ActionPane(
motion: const BehindMotion(), motion: const BehindMotion(),
@ -160,6 +180,30 @@ class _StoriesListViewState extends State<StoriesListView>
), ),
], ],
), ),
endActionPane: ActionPane(
motion: const BehindMotion(),
dismissible: DismissiblePane(
closeOnCancel: true,
confirmDismiss: () async {
mark(story);
return false;
},
onDismissed: () {},
),
children: <Widget>[
SlidableAction(
onPressed: (_) => mark(story),
backgroundColor: preferenceState.markReadStoriesEnabled
? Theme.of(context).primaryColor
: Palette.grey,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
icon: state.readStoriesIds.contains(story.id)
? Icons.visibility_off
: Icons.visibility,
),
],
),
child: OptionalWrapper( child: OptionalWrapper(
enabled: context enabled: context
.read<PreferenceCubit>() .read<PreferenceCubit>()
@ -193,6 +237,22 @@ class _StoriesListViewState extends State<StoriesListView>
); );
} }
void mark(Story story) {
HapticFeedbackUtil.light();
final StoriesBloc storiesBloc = context.read<StoriesBloc>();
final bool markReadStoriesEnabled =
context.read<PreferenceCubit>().state.markReadStoriesEnabled;
if (markReadStoriesEnabled) {
if (storiesBloc.state.readStoriesIds.contains(story.id)) {
storiesBloc.add(StoryUnread(story: story));
} else {
storiesBloc.add(StoryRead(story: story));
}
} else {
context.showSnackBar(content: 'Read story marking is disabled.');
}
}
void loadMoreStories() => void loadMoreStories() =>
context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType)); context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType));
} }

View File

@ -44,7 +44,7 @@ abstract class Fetcher {
logger: logger, logger: logger,
); );
final StoriesRepository storiesRepository = StoriesRepository(); final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
final SembastRepository sembastRepository = SembastRepository(); final SembastRepository sembastRepository = SembastRepository();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
@ -57,7 +57,7 @@ abstract class Fetcher {
Comment? newReply; Comment? newReply;
await storiesRepository await hackerNewsRepository
.fetchSubmitted(userId: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedItems) async { .then((List<int>? submittedItems) async {
if (submittedItems != null) { if (submittedItems != null) {
@ -67,7 +67,9 @@ abstract class Fetcher {
); );
for (final int id in subscribedItems) { for (final int id in subscribedItems) {
await storiesRepository.fetchRawItem(id: id).then((Item? item) async { await hackerNewsRepository
.fetchRawItem(id: id)
.then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[]; final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids = final List<int> previousKids =
(await sembastRepository.kids(of: id)) ?? <int>[]; (await sembastRepository.kids(of: id)) ?? <int>[];
@ -81,7 +83,7 @@ abstract class Fetcher {
for (final int newCommentId in diff) { for (final int newCommentId in diff) {
if (unreadIds.contains(newCommentId)) continue; if (unreadIds.contains(newCommentId)) continue;
await storiesRepository await hackerNewsRepository
.fetchRawComment(id: newCommentId) .fetchRawComment(id: newCommentId)
.then((Comment? comment) async { .then((Comment? comment) async {
final bool hasPushedBefore = final bool hasPushedBefore =
@ -113,7 +115,7 @@ abstract class Fetcher {
// pushed before. // pushed before.
if (newReply != null) { if (newReply != null) {
final Story? story = final Story? story =
await storiesRepository.fetchRawParentStory(id: newReply!.id); await hackerNewsRepository.fetchRawParentStory(id: newReply!.id);
final String text = HtmlUtil.parseHtml(newReply!.text); final String text = HtmlUtil.parseHtml(newReply!.text);
if (story != null) { if (story != null) {

View File

@ -219,10 +219,8 @@ class WebAnalyzer {
String? fallbackDescription; String? fallbackDescription;
if (res == null || isEmpty(res[2] as String?)) { if (res == null || isEmpty(res[2] as String?)) {
final String? commentText = await compute( final List<int> ids = <int>[story.id, ...story.kids];
_fetchInfoFromStory, final String? commentText = await _fetchInfoFromStory(ids);
<int>[story.id, ...story.kids],
);
shouldRetry = commentText == null; shouldRetry = commentText == null;
fallbackDescription = commentText ?? 'no comment yet'; fallbackDescription = commentText ?? 'no comment yet';
@ -288,14 +286,14 @@ class WebAnalyzer {
} }
static Future<String?> _fetchInfoFromStory(List<int> meta) async { static Future<String?> _fetchInfoFromStory(List<int> meta) async {
final StoriesRepository storiesRepository = StoriesRepository(); final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
final int storyId = meta.first; final int storyId = meta.first;
List<int> kids = meta.sublist(1, meta.length); List<int> kids = meta.sublist(1, meta.length);
// Kids of stories from search results are always empty, so here we try // Kids of stories from search results are always empty, so here we try
// to fetch the story itself first and see if the kids are still empty. // to fetch the story itself first and see if the kids are still empty.
if (kids.isEmpty) { if (kids.isEmpty) {
final Story? story = await storiesRepository.fetchStory(id: storyId); final Story? story = await hackerNewsRepository.fetchStory(id: storyId);
if (story == null) return null; if (story == null) return null;
@ -305,7 +303,7 @@ class WebAnalyzer {
} }
final Comment? comment = final Comment? comment =
await storiesRepository.fetchComment(id: kids.first); await hackerNewsRepository.fetchComment(id: kids.first);
return comment != null ? '${comment.by}: ${comment.text}' : null; return comment != null ? '${comment.by}: ${comment.text}' : null;
} }

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

@ -34,7 +34,16 @@ abstract class HtmlUtil {
static String parseHtml(String text) { static String parseHtml(String text) {
return HtmlUnescape() return HtmlUnescape()
.convert(text) .convert(text)
.replaceAll('<p>', '\n') .replaceAllMapped(
RegExp(r'\<pre\>\<code\>(.*?)\<\/code\>\<\/pre\>', dotAll: true),
(Match match) =>
'<pre><code>${match[1]?.replaceAll('\n', '[break]')}</code></pre>',
)
.replaceAll('\n', '')
.replaceAllMapped(
RegExp(r'\<p\>(.*?)\<p\>', dotAll: true),
(Match match) => '\n${match[1]?.replaceAll('\n', ' ')}\n',
)
.replaceAllMapped( .replaceAllMapped(
RegExp(r'\<i\>(.*?)\<\/i\>'), RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*', (Match match) => '*${match[1]}*',
@ -43,6 +52,8 @@ abstract class HtmlUtil {
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'), RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(Match match) => match[1] ?? '', (Match match) => match[1] ?? '',
) )
.replaceAll('\n', '\n\n'); .replaceAll('\n', '\n\n')
.replaceAll('<p>', '\n\n')
.replaceAll('[break]', '\n');
} }
} }

View File

@ -52,7 +52,10 @@ abstract class LinkUtil {
if (useHackiForHnLink && link.isStoryLink) { if (useHackiForHnLink && link.isStoryLink) {
final int? id = link.itemId; final int? id = link.itemId;
if (id != null) { if (id != null) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) { locator
.get<HackerNewsRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (item != null) { if (item != null) {
router.push( router.push(
'/${ItemScreen.routeName}', '/${ItemScreen.routeName}',

View File

@ -467,14 +467,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
gbk_codec:
dependency: "direct main"
description:
name: gbk_codec
sha256: "3af5311fc9393115e3650ae6023862adf998051a804a08fb804f042724999f61"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
get_it: get_it:
dependency: "direct main" dependency: "direct main"
description: description:
@ -495,10 +487,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: a206cc4621a644531a2e05e7774616ab4d9d85eab1f3b0e255f3102937fccab1 sha256: c247a4f76071c3b97bb5ae8912968870d5565644801c5e09f3bc961b4d874895
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.0" version: "12.1.1"
hive: hive:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1176,66 +1168,66 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.14" version: "6.2.1"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.2.0"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" sha256: "4ac97281cf60e2e8c5cc703b2b28528f9b50c8f7cebc71df6bdf0845f647268a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5" version: "6.2.0"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.1.0"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.1.0"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_platform_interface name: url_launcher_platform_interface
sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
url_launcher_web: url_launcher_web:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.20" version: "2.2.0"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.8" version: "3.1.0"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@ -1438,4 +1430,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.1.0 <4.0.0" dart: ">=3.1.0 <4.0.0"
flutter: ">=3.13.8" flutter: ">=3.13.9"

View File

@ -1,11 +1,11 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.2.0+128 version: 2.4.2+133
publish_to: none publish_to: none
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
flutter: "3.13.8" flutter: "3.13.9"
dependencies: dependencies:
adaptive_theme: ^3.2.0 adaptive_theme: ^3.2.0
@ -38,9 +38,8 @@ dependencies:
flutter_siri_suggestions: ^2.1.0 flutter_siri_suggestions: ^2.1.0
flutter_slidable: ^3.0.0 flutter_slidable: ^3.0.0
font_awesome_flutter: ^10.3.0 font_awesome_flutter: ^10.3.0
gbk_codec: ^0.4.0
get_it: ^7.2.0 get_it: ^7.2.0
go_router: ^12.0.0 go_router: ^12.1.1
hive: ^2.2.3 hive: ^2.2.3
html: ^0.15.1 html: ^0.15.1
html_unescape: ^2.0.0 html_unescape: ^2.0.0
@ -67,7 +66,7 @@ dependencies:
responsive_builder: ^0.7.0 responsive_builder: ^0.7.0
rxdart: ^0.27.7 rxdart: ^0.27.7
scrollable_positioned_list: ^0.3.5 scrollable_positioned_list: ^0.3.5
sembast: ^3.4.0+6 sembast: ^3.5.0+1
share_plus: ^7.2.1 share_plus: ^7.2.1
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
shared_preferences_android: ^2.2.1 shared_preferences_android: ^2.2.1
@ -76,7 +75,7 @@ dependencies:
synced_shared_preferences: synced_shared_preferences:
path: components/synced_shared_preferences path: components/synced_shared_preferences
universal_platform: ^1.0.0+1 universal_platform: ^1.0.0+1
url_launcher: ^6.1.9 url_launcher: ^6.2.1
visibility_detector: ^0.4.0+2 visibility_detector: ^0.4.0+2
wakelock: ^0.6.2 wakelock: ^0.6.2
webview_flutter: ^4.4.1 webview_flutter: ^4.4.1

View File

@ -9,7 +9,7 @@ class MockAuthRepository extends Mock implements AuthRepository {}
class MockPreferenceRepository extends Mock implements PreferenceRepository {} class MockPreferenceRepository extends Mock implements PreferenceRepository {}
class MockStoriesRepository extends Mock implements StoriesRepository {} class MockHackerNewsRepository extends Mock implements HackerNewsRepository {}
class MockSembastRepository extends Mock implements SembastRepository {} class MockSembastRepository extends Mock implements SembastRepository {}
@ -17,7 +17,8 @@ void main() {
final MockAuthRepository mockAuthRepository = MockAuthRepository(); final MockAuthRepository mockAuthRepository = MockAuthRepository();
final MockPreferenceRepository mockPreferenceRepository = final MockPreferenceRepository mockPreferenceRepository =
MockPreferenceRepository(); MockPreferenceRepository();
final MockStoriesRepository mockStoriesRepository = MockStoriesRepository(); final MockHackerNewsRepository mockHackerNewsRepository =
MockHackerNewsRepository();
final MockSembastRepository mockSembastRepository = MockSembastRepository(); final MockSembastRepository mockSembastRepository = MockSembastRepository();
const int created = 0; const int created = 0;
@ -49,7 +50,7 @@ void main() {
AuthBloc( AuthBloc(
authRepository: mockAuthRepository, authRepository: mockAuthRepository,
preferenceRepository: mockPreferenceRepository, preferenceRepository: mockPreferenceRepository,
storiesRepository: mockStoriesRepository, hackerNewsRepository: mockHackerNewsRepository,
sembastRepository: mockSembastRepository, sembastRepository: mockSembastRepository,
).state, ).state,
equals(const AuthState.init()), equals(const AuthState.init()),
@ -67,7 +68,7 @@ void main() {
.thenAnswer((_) => Future<String?>.value(username)); .thenAnswer((_) => Future<String?>.value(username));
when(() => mockAuthRepository.password) when(() => mockAuthRepository.password)
.thenAnswer((_) => Future<String>.value(password)); .thenAnswer((_) => Future<String>.value(password));
when(() => mockStoriesRepository.fetchUser(id: username)) when(() => mockHackerNewsRepository.fetchUser(id: username))
.thenAnswer((_) => Future<User>.value(tUser)); .thenAnswer((_) => Future<User>.value(tUser));
when(() => mockAuthRepository.loggedIn) when(() => mockAuthRepository.loggedIn)
.thenAnswer((_) => Future<bool>.value(false)); .thenAnswer((_) => Future<bool>.value(false));
@ -79,7 +80,7 @@ void main() {
return AuthBloc( return AuthBloc(
authRepository: mockAuthRepository, authRepository: mockAuthRepository,
preferenceRepository: mockPreferenceRepository, preferenceRepository: mockPreferenceRepository,
storiesRepository: mockStoriesRepository, hackerNewsRepository: mockHackerNewsRepository,
sembastRepository: mockSembastRepository, sembastRepository: mockSembastRepository,
); );
}, },
@ -91,7 +92,7 @@ void main() {
verify: (_) { verify: (_) {
verify(() => mockAuthRepository.loggedIn).called(2); verify(() => mockAuthRepository.loggedIn).called(2);
verifyNever(() => mockAuthRepository.username); verifyNever(() => mockAuthRepository.username);
verifyNever(() => mockStoriesRepository.fetchUser(id: username)); verifyNever(() => mockHackerNewsRepository.fetchUser(id: username));
}, },
); );
@ -107,7 +108,7 @@ void main() {
return AuthBloc( return AuthBloc(
authRepository: mockAuthRepository, authRepository: mockAuthRepository,
preferenceRepository: mockPreferenceRepository, preferenceRepository: mockPreferenceRepository,
storiesRepository: mockStoriesRepository, hackerNewsRepository: mockHackerNewsRepository,
sembastRepository: mockSembastRepository, sembastRepository: mockSembastRepository,
); );
}, },
@ -154,7 +155,8 @@ void main() {
password: password, password: password,
), ),
).called(1); ).called(1);
verify(() => mockStoriesRepository.fetchUser(id: username)).called(1); verify(() => mockHackerNewsRepository.fetchUser(id: username))
.called(1);
}, },
); );
}); });