Compare commits

..

8 Commits

41 changed files with 454 additions and 191 deletions

View File

@ -76,6 +76,15 @@ final class SharedPrefsCore {
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 {
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
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":
if let params = call.arguments as? [String: Any] {
let val = params[valKey] as? Bool

View File

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

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
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

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

View File

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

View File

@ -95,6 +95,15 @@ class StoryRead extends StoriesEvent {
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 {
@override
List<Object?> get props => <Object?>[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,31 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.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].
///
/// You can learn more about the Hacker News API at
/// https://github.com/HackerNews/API.
class StoriesRepository {
StoriesRepository({
class HackerNewsRepository {
HackerNewsRepository({
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 SembastRepository _sembastRepository;
final Logger _logger;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
@ -34,11 +46,10 @@ class StoriesRepository {
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -57,11 +68,10 @@ class StoriesRepository {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -226,7 +236,17 @@ class StoriesRepository {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
if (!json.isFromCache) {
unawaited(_sembastRepository.cacheComment(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) {
@ -251,7 +271,17 @@ class StoriesRepository {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
if (!json.isFromCache) {
unawaited(_sembastRepository.cacheComment(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) {
@ -275,11 +305,10 @@ class StoriesRepository {
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -343,12 +372,57 @@ class StoriesRepository {
Map<String, dynamic>? json,
) async {
if (json == null) return null;
final String text = json['text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
json['text'] = parsedText;
final int? itemId = json.itemId;
String? cachedText;
if (json.isComment && itemId != null) {
cachedText =
(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;
}
}
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);
if (Platform.isIOS) {
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 {
if (Platform.isIOS) {
await _syncedPrefs.clearAll();

View File

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

View File

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

View File

@ -196,12 +196,13 @@ class _HomeScreenState extends State<HomeScreen>
}
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 =
context.read<StoriesBloc>().state.isOfflineReading;
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
final StoryMarkingMode storyMarkingMode =
context.read<PreferenceCubit>().state.storyMarkingMode;
final bool markReadStoriesEnabled = prefState.markReadStoriesEnabled;
// 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.
@ -210,7 +211,12 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} 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);
@ -230,7 +236,7 @@ class _HomeScreenState extends State<HomeScreen>
);
}
if (storyMarkingMode.shouldDetectTapping) {
if (markReadStoriesEnabled && storyMarkingMode.shouldDetectTapping) {
context.read<StoriesBloc>().add(StoryRead(story: story));
}
@ -253,7 +259,7 @@ class _HomeScreenState extends State<HomeScreen>
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
locator.get<HackerNewsRepository>().fetchItem(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
@ -272,7 +278,7 @@ class _HomeScreenState extends State<HomeScreen>
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.get<HackerNewsRepository>()
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {
@ -297,7 +303,7 @@ class _HomeScreenState extends State<HomeScreen>
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.get<HackerNewsRepository>()
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {

View File

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

View File

@ -25,6 +25,7 @@ class MainView extends StatelessWidget {
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onRightMoreTapped,
required this.shouldMarkNewComment,
super.key,
});
@ -33,6 +34,7 @@ class MainView extends StatelessWidget {
final AuthState authState;
final double topPadding;
final bool splitViewEnabled;
final bool shouldMarkNewComment;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped;
@ -111,6 +113,7 @@ class MainView extends StatelessWidget {
opUsername: state.item.by,
fetchMode: state.fetchMode,
isResponse: state.isResponse(comment),
isNew: shouldMarkNewComment && !comment.isFromCache,
onReplyTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {

View File

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -45,7 +46,12 @@ class PinIconButton extends StatelessWidget {
if (pinned) {
context.read<PinCubit>().unpinStory(story);
} 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}) {
throttle.run(() {
locator
.get<StoriesRepository>()
.get<HackerNewsRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then(((Story, List<Comment>)? res) {
if (res != null && mounted) {

View File

@ -317,6 +317,9 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
label: filter.query,
),
],
const SizedBox(
width: Dimens.pt8,
),
],
),
),

View File

@ -25,6 +25,7 @@ class CommentTile extends StatelessWidget {
this.collapsable = true,
this.selectable = true,
this.isResponse = false,
this.isNew = false,
this.level = 0,
this.index,
this.onTap,
@ -38,6 +39,7 @@ class CommentTile extends StatelessWidget {
final bool collapsable;
final bool selectable;
final bool isResponse;
final bool isNew;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped;
@ -182,6 +184,15 @@ class CommentTile extends StatelessWidget {
color: Palette.grey,
),
),
if (isNew)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(
Icons.sunny_snowing,
size: 16,
color: Palette.grey,
),
),
const Spacer(),
Text(
comment.timeAgo,
@ -289,7 +300,7 @@ class CommentTile extends StatelessWidget {
);
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
? color.withOpacity(commentBackgroundColorOpacity)

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart' show StringCharacters, immutable;
import 'package:linkify/linkify.dart';
final RegExp _urlRegex = RegExp(
r'''^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,'~-]*)''',
r'''^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+\*_.,'~-]*)''',
caseSensitive: false,
dotAll: true,
);
@ -84,7 +84,7 @@ class UrlLinkifier extends Linkifier {
if (url.endsWith(',')) {
url = url.substring(0, max(0, url.length - 1));
end = '$end,';
end = '${end ?? ''},';
}
if ((options.humanize) || (options.removeWww)) {

View File

@ -146,6 +146,7 @@ class _StoriesListViewState extends State<StoriesListView>
onMoreTapped: onMoreTapped,
itemBuilder: (Widget child, Story story) {
return Slidable(
key: ValueKey<Story>(story),
enabled: !preferenceState.swipeGestureEnabled,
startActionPane: ActionPane(
motion: const BehindMotion(),
@ -179,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(
enabled: context
.read<PreferenceCubit>()
@ -212,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() =>
context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType));
}

View File

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

View File

@ -288,14 +288,14 @@ class WebAnalyzer {
}
static Future<String?> _fetchInfoFromStory(List<int> meta) async {
final StoriesRepository storiesRepository = StoriesRepository();
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
final int storyId = meta.first;
List<int> kids = meta.sublist(1, meta.length);
// 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.
if (kids.isEmpty) {
final Story? story = await storiesRepository.fetchStory(id: storyId);
final Story? story = await hackerNewsRepository.fetchStory(id: storyId);
if (story == null) return null;
@ -305,7 +305,7 @@ class WebAnalyzer {
}
final Comment? comment =
await storiesRepository.fetchComment(id: kids.first);
await hackerNewsRepository.fetchComment(id: kids.first);
return comment != null ? '${comment.by}: ${comment.text}' : null;
}

View File

@ -34,7 +34,16 @@ abstract class HtmlUtil {
static String parseHtml(String text) {
return HtmlUnescape()
.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(
RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*',
@ -43,6 +52,8 @@ abstract class HtmlUtil {
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(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) {
final int? id = link.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
locator
.get<HackerNewsRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (item != null) {
router.push(
'/${ItemScreen.routeName}',

View File

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

View File

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

View File

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