Compare commits

...

49 Commits

Author SHA1 Message Date
1eaded5694 fix uncaught error. (#359) 2023-12-11 01:01:26 -08:00
70bb78afcb use InterceptorsWrapper for caching. (#358) 2023-12-10 15:29:40 -08:00
df2d2478d5 improve comment fetching. (#357) 2023-12-09 18:20:28 -08:00
d5ae60327d change fetch method based on network condition. (#356) 2023-12-09 10:03:22 -08:00
615a092c1e update fetching strategy. (#355) 2023-12-09 02:05:03 -08:00
5a7699d866 update hacker_news_web_repository.dart (#354) 2023-12-09 00:00:20 -08:00
56a9bab3f2 improve error handling. (#353) 2023-12-08 22:37:57 -08:00
e9bbf46b4f fix comment fetching. (#352) 2023-12-08 21:25:02 -08:00
10f503a6c0 add cache for story metadata. (#350) 2023-12-08 20:09:05 -08:00
582f3156b2 add dev option. (#349) 2023-12-08 17:21:30 -08:00
90fb45146f fix story repository. (#348) 2023-12-08 14:25:32 -08:00
c19c54e762 optimize comment fetching. (#347) 2023-12-08 13:35:52 -08:00
70e5a84b63 improve comment fetching. (#346) 2023-12-08 10:18:03 -08:00
3a51fa83f2 update story tile padding. (#344) 2023-12-08 01:12:49 -08:00
cb90751330 fix flickering image. (#343) 2023-12-07 23:24:43 -08:00
835ed7e841 use different comment fetching strategy. (#342) 2023-12-07 21:46:13 -08:00
125ccd2dd1 use isolate to fetch comments. (#341) 2023-12-05 21:04:40 -08:00
5b991c4287 update theme. (#340) 2023-12-03 17:30:34 -08:00
7dc3618afe update color. (#339) 2023-12-02 23:31:45 -08:00
eef4691814 update Info.plist (#338) 2023-12-02 20:58:39 -08:00
9f71701845 update story tile. (#336) 2023-12-02 04:46:06 -08:00
d27203b041 update Info.plist (#335) 2023-12-02 04:21:58 -08:00
4f280ec4c9 add ability to sync favorites from Hacker News. (#334) 2023-12-01 21:53:48 -08:00
72cb2737ca fix story tile. (#333) 2023-12-01 12:09:14 -08:00
215203bd16 remove error placeholder. (#332) 2023-12-01 11:27:16 -08:00
3e320faece update story title. (#331) 2023-12-01 09:56:19 -08:00
1049568246 bump Flutter version to 3.16.2 (#330) 2023-12-01 01:11:30 -08:00
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
1f4e6cf41c fix pagination button. (#298) 2023-11-02 21:50:09 -07:00
be6ed35888 update version. (#297) 2023-11-02 21:09:55 -07:00
b2ea50cea6 add pagination. (#296) 2023-11-02 20:22:51 -07:00
161 changed files with 2840 additions and 1418 deletions

View File

@ -35,22 +35,20 @@ Features:
<p align="center"> <p align="center">
<img width="200" alt="01" src="assets/screenshots/01.png"> <img width="400" alt="01" src="assets/screenshots/light-1.png">
<img width="200" alt="02" src="assets/screenshots/02.png"> <img width="400" alt="06" src="assets/screenshots/dark-1.png">
<img width="200" alt="03" src="assets/screenshots/03.png"> <img width="400" alt="02" src="assets/screenshots/light-2.png">
<img width="200" alt="04" src="assets/screenshots/04.png"> <img width="400" alt="07" src="assets/screenshots/dark-2.png">
<img width="200" alt="05" src="assets/screenshots/05.png"> <img width="400" alt="03" src="assets/screenshots/light-3.png">
<img width="200" alt="06" src="assets/screenshots/06.png"> <img width="400" alt="08" src="assets/screenshots/dark-3.png">
<img width="200" alt="07" src="assets/screenshots/07.png"> <img width="400" alt="04" src="assets/screenshots/light-4.png">
<img width="200" alt="08" src="assets/screenshots/08.png"> <img width="400" alt="09" src="assets/screenshots/dark-4.png">
<img width="200" alt="09" src="assets/screenshots/09.png"> <img width="400" alt="05" src="assets/screenshots/light-5.png">
<img width="200" alt="10" src="assets/screenshots/10.png"> <img width="400" alt="10" src="assets/screenshots/dark-5.png">
<img width="200" alt="11" src="assets/screenshots/11.png">
<img width="200" alt="12" src="assets/screenshots/12.png">
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png"> <img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png"> <img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png"> <img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png"> <img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
</p> </p>

View File

@ -23,7 +23,8 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop" android:launchMode="singleTop"

Binary file not shown.

Binary file not shown.

BIN
assets/hacki-github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/tablet-hacki.xcf Normal file

Binary file not shown.

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 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,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

@ -0,0 +1,4 @@
- RobotoSlab as default font.
- Material 3 design.
- Ability to sync favorites from your Hacker News account.
- Support for predictive back gesture.

View File

@ -0,0 +1,3 @@
- Return of true dark mode.
- Better comment fetching strategy.
- Minor UI fixes.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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),
@ -113,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.logout(); await _authRepository.logout();
await _preferenceRepository.updateUnreadCommentsIds(<int>[]); await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
await _sembastRepository.deleteAll(); await _sembastRepository.deleteCachedComments();
} }
} }

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.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';
@ -19,24 +20,32 @@ 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>(),
super(const StoriesState.init()) { super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: concurrent(),
);
on<StoriesInitialize>(onInitialize); on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh); on<StoriesRefresh>(onRefresh);
on<StoriesLoadMore>(onLoadMore); on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded); on<StoryLoaded>(
onStoryLoaded,
transformer: sequential(),
);
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 +58,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;
@ -87,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
), ),
); );
for (final StoryType type in StoryType.values) { for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit); add(LoadStories(type: type));
} }
} }
Future<void> loadStories({ Future<void> onLoadStories(
required StoryType type, LoadStories event,
required Emitter<StoriesState> emit, Emitter<StoriesState> emit,
}) async { ) async {
final StoryType type = event.type;
if (state.isOfflineReading) { if (state.isOfflineReading) {
final List<int> ids = final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type); await _offlineRepository.getCachedStoryIds(type: type);
@ -113,19 +123,19 @@ 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 await _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));
}).onDone(() { }).asFuture<void>();
add(StoriesLoaded(type: type)); add(StoriesLoaded(type: type));
});
} }
} }
@ -151,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
); );
} else { } else {
emit(state.copyWithRefreshed(type: event.type)); emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit); add(LoadStories(type: event.type));
} }
} }
@ -196,7 +206,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 +283,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 +304,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 +358,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 +388,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 +471,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 +480,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

@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
final StoryType type;
@override
List<Object?> get props => <Object?>[type];
}
class StoriesInitialize extends StoriesEvent { class StoriesInitialize extends StoriesEvent {
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
@ -95,6 +104,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

@ -73,7 +73,7 @@ abstract class RegExpConstants {
static const String number = '[0-9]+'; static const String number = '[0-9]+';
} }
abstract class Durations { abstract class AppDurations {
static const Duration ms100 = Duration(milliseconds: 100); static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200); static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300); static const Duration ms300 = Duration(milliseconds: 300);
@ -83,4 +83,7 @@ abstract class Durations {
static const Duration oneSecond = Duration(seconds: 1); static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2); static const Duration twoSeconds = Duration(seconds: 2);
static const Duration tenSeconds = Duration(seconds: 10); static const Duration tenSeconds = Duration(seconds: 10);
static const Duration sec30 = Duration(seconds: 30);
static const Duration oneMinute = Duration(minutes: 1);
static const Duration twoMinutes = Duration(minutes: 2);
} }

View File

@ -23,12 +23,13 @@ Future<void> setUpLocator() async {
output: LogUtil.logOutput(logOutputFile), output: LogUtil.logOutput(logOutputFile),
), ),
) )
..registerSingleton<StoriesRepository>(StoriesRepository()) ..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..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

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
@ -25,6 +26,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
required FilterCubit filterCubit, required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
required CollapseCache collapseCache, required CollapseCache collapseCache,
required bool isOfflineReading, required bool isOfflineReading,
required Item item, required Item item,
@ -32,18 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder, required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger, Logger? logger,
}) : _filterCubit = filterCubit, }) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_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 =
storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
@ -55,11 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
final FilterCubit _filterCubit; final FilterCubit _filterCubit;
final PreferenceCubit _preferenceCubit;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger; final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
@ -75,6 +83,30 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions = final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{}; <int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
if (isOnWifi) {
return switch (state.item) {
Story(descendants: final int descendants)
when descendants > _webFetchingCmtCountLowerLimit =>
true,
Comment(kids: final List<int> kids)
when kids.length > _webFetchingCmtCountLowerLimit =>
true,
_ => false,
};
} else {
return true;
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
@override @override
void emit(CommentsState state) { void emit(CommentsState state) {
if (!isClosed) { if (!isClosed) {
@ -86,6 +118,8 @@ class CommentsCubit extends Cubit<CommentsState> {
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false, bool useCommentCache = false,
List<Comment>? targetAncestors, List<Comment>? targetAncestors,
AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async { }) async {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit( emit(
@ -96,7 +130,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 +156,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,15 +172,54 @@ 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( switch (state.order) {
ids: kids, case CommentsOrder.natural:
getFromCache: useCommentCache ? _commentCache.getComment : null, final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
); if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache:
useCommentCache ? _commentCache.getComment : null,
);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
}
} }
} }
@ -154,7 +230,10 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone); ..onDone(_onDone);
} }
Future<void> refresh() async { Future<void> refresh({
required AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.inProgress, status: CommentsStatus.inProgress,
@ -187,18 +266,51 @@ 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) {
commentStream = _storiesRepository.fetchCommentsStream( switch (state.fetchMode) {
ids: kids, case FetchMode.lazy:
); commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
} else { case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream( switch (state.order) {
ids: kids, case CommentsOrder.natural:
); final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
} }
_streamSubscription = commentStream _streamSubscription = commentStream
@ -245,14 +357,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 +375,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 +405,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 +426,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);
@ -365,12 +481,12 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: index, index: index,
alignment: alignment, alignment: alignment,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
} }
/// 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
@ -390,7 +506,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: 1, index: 1,
alignment: 0.15, alignment: 0.15,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
return; return;
} }
@ -417,11 +533,15 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: i + 1, index: i + 1,
alignment: 0.15, alignment: 0.15,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
return; return;
} }
} }
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
} }
/// Scroll to previous root level comment. /// Scroll to previous root level comment.
@ -453,34 +573,56 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: i + 1, index: i + 1,
alignment: 0.15, alignment: 0.15,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
return; return;
} }
} }
} }
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 +649,12 @@ 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);
if (state.isOfflineReading) {
_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 +663,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,7 +12,7 @@ part 'edit_state.dart';
class EditCubit extends HydratedCubit<EditState> { class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache}) EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(), : _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: Durations.oneSecond), _debouncer = Debouncer(delay: AppDurations.oneSecond),
super(const EditState.init()); super(const EditState.init());
final DraftCache _draftCache; final DraftCache _draftCache;

View File

@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.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';
import 'package:logger/logger.dart';
part 'fav_state.dart'; part 'fav_state.dart';
@ -12,13 +17,18 @@ class FavCubit extends Cubit<FavState> {
required AuthBloc authBloc, required AuthBloc authBloc,
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _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>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -26,44 +36,43 @@ 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;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 20;
String? _username;
Future<void> init() async { Future<void> init() async {
_authBloc.stream.listen((AuthState authState) { _usernameSubscription = _authBloc.stream
if (authState.username != _username) { .map((AuthState event) => event.username)
_preferenceRepository .distinct()
.favList(of: authState.username) .listen((String username) {
.then((List<int> favIds) { _preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit( emit(
state.copyWith( state.copyWith(
favIds: favIds, status: Status.success,
favItems: <Item>[],
currentPage: 0,
), ),
); );
_storiesRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
status: Status.success,
),
);
});
}); });
});
_username = authState.username;
}
}); });
} }
Future<void> addFav(int id) async { Future<void> addFav(int id) async {
final String username = _authBloc.state.username; if (state.favIds.contains(id)) return;
await _preferenceRepository.addFav(username: username, id: id); await _preferenceRepository.addFav(username: username, id: id);
@ -73,7 +82,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;
@ -89,9 +98,9 @@ class FavCubit extends Cubit<FavState> {
} }
void removeFav(int id) { void removeFav(int id) {
final String username = _authBloc.state.username; _preferenceRepository
..removeFav(username: username, id: id)
_preferenceRepository.removeFav(username: username, id: id); ..removeFav(username: '', id: id);
emit( emit(
state.copyWith( state.copyWith(
@ -119,7 +128,7 @@ class FavCubit extends Cubit<FavState> {
upper = len; upper = len;
} }
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: state.favIds.sublist( ids: state.favIds.sublist(
lower, lower,
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
} }
void refresh() { void refresh() {
final String username = _authBloc.state.username;
emit( emit(
state.copyWith( state.copyWith(
status: Status.inProgress, status: Status.inProgress,
@ -149,7 +156,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)),
) )
@ -167,6 +174,34 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init()); emit(FavState.init());
} }
Future<void> merge({
required AppExceptionHandler onError,
required VoidCallback onSuccess,
}) async {
if (_authBloc.state.isLoggedIn) {
emit(state.copyWith(mergeStatus: Status.inProgress));
try {
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
of: _authBloc.state.username,
);
_logger.d('fetched ${ids.length} favorite items from HN.');
final List<int> combinedIds = <int>[...ids, ...state.favIds];
final LinkedHashSet<int> mergedIds =
LinkedHashSet<int>.from(combinedIds);
await _preferenceRepository.overwriteFav(
username: username,
ids: mergedIds,
);
emit(state.copyWith(mergeStatus: Status.success));
onSuccess();
refresh();
} on RateLimitedException catch (e) {
onError(e);
emit(state.copyWith(mergeStatus: Status.failure));
}
}
}
void _onItemLoaded(Item item) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(
@ -174,4 +209,14 @@ class FavCubit extends Cubit<FavState> {
), ),
); );
} }
@override
Future<void> close() {
_usernameSubscription?.cancel();
return super.close();
}
}
extension on FavCubit {
String get username => _authBloc.state.username;
} }

View File

@ -5,6 +5,7 @@ class FavState extends Equatable {
required this.favIds, required this.favIds,
required this.favItems, required this.favItems,
required this.status, required this.status,
required this.mergeStatus,
required this.currentPage, required this.currentPage,
}); });
@ -12,23 +13,27 @@ class FavState extends Equatable {
: favIds = <int>[], : favIds = <int>[],
favItems = <Item>[], favItems = <Item>[],
status = Status.idle, status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0; currentPage = 0;
final List<int> favIds; final List<int> favIds;
final List<Item> favItems; final List<Item> favItems;
final Status status; final Status status;
final Status mergeStatus;
final int currentPage; final int currentPage;
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
List<Item>? favItems, List<Item>? favItems,
Status? status, Status? status,
Status? mergeStatus,
int? currentPage, int? currentPage,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
favItems: favItems ?? this.favItems, favItems: favItems ?? this.favItems,
status: status ?? this.status, status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -36,6 +41,7 @@ class FavState extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
mergeStatus,
currentPage, currentPage,
favIds, favIds,
favItems, favItems,

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,23 +16,26 @@ 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 =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(NotificationState.init()) { super(NotificationState.init()) {
_authBloc.stream.listen((AuthState authState) { _authBloc.stream
if (authState.isLoggedIn && authState.username != _username) { .map((AuthState event) => event.username)
.distinct()
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting. // Get the user setting.
if (_preferenceCubit.state.notificationEnabled) { if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(Durations.twoSeconds, init); Future<void>.delayed(AppDurations.twoSeconds, init);
} }
// Listen for setting changes in the future. // Listen for setting changes in the future.
@ -44,9 +47,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_timer?.cancel(); _timer?.cancel();
} }
}); });
} else {
_username = authState.username;
} else if (!authState.isLoggedIn) {
emit(NotificationState.init()); emit(NotificationState.init());
} }
}); });
@ -54,10 +55,9 @@ 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;
Timer? _timer; Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5); static const Duration _refreshInterval = Duration(minutes: 5);
@ -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

@ -70,7 +70,13 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>(); bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>(); bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get devModeEnabled => _isOn<DevMode>();
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

@ -20,7 +20,7 @@ extension ContextExtension on BuildContext {
}) { }) {
ScaffoldMessenger.of(this).showSnackBar( ScaffoldMessenger.of(this).showSnackBar(
SnackBar( SnackBar(
backgroundColor: Theme.of(this).primaryColor, backgroundColor: Theme.of(this).colorScheme.primary,
content: Text( content: Text(
content, content,
style: TextStyle( style: TextStyle(
@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
); );
} }
void showErrorSnackBar() => showSnackBar( void showErrorSnackBar([String? message]) {
content: Constants.errorMessage, ScaffoldMessenger.of(this).showSnackBar(
); SnackBar(
backgroundColor: Theme.of(this).colorScheme.errorContainer,
content: Text(
message ?? Constants.errorMessage,
style: TextStyle(
color: Theme.of(this).colorScheme.onErrorContainer,
),
),
),
);
}
Rect? get rect { Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?; final RenderBox? box = findRenderObject() as RenderBox?;

View File

@ -27,7 +27,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
); );
} }
void showErrorSnackBar() => context.showErrorSnackBar(); void showErrorSnackBar([String? message]) =>
context.showErrorSnackBar(message);
Future<void>? goToItemScreen({ Future<void>? goToItemScreen({
required ItemScreenArgs args, required ItemScreenArgs args,

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';
@ -137,7 +138,7 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage; HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200; VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
runApp( runApp(
HackiApp( HackiApp(
@ -229,16 +230,21 @@ 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.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.trueDarkModeEnabled}''',
), ),
light: ThemeData( light: ThemeData(
primaryColor: state.appColor, primaryColor: state.appColor,
@ -254,7 +260,6 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor, primarySwatch: state.appColor,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
canvasColor: Palette.black,
fontFamily: state.font.name, fontFamily: state.font.name,
), ),
initial: savedThemeMode ?? AdaptiveThemeMode.system, initial: savedThemeMode ?? AdaptiveThemeMode.system,
@ -278,62 +283,72 @@ class HackiApp extends StatelessWidget {
.platformDispatcher .platformDispatcher
.platformBrightness == .platformBrightness ==
Brightness.dark); Brightness.dark);
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
);
return FeatureDiscovery( return FeatureDiscovery(
child: MediaQuery( child: MediaQuery(
data: MediaQuery.of(context).copyWith( data: state.textScaleFactor == 1
textScaleFactor: state.textScaleFactor == 1 ? MediaQuery.of(context)
? null : MediaQuery.of(context).copyWith(
: state.textScaleFactor, textScaler: TextScaler.linear(
), state.textScaleFactor,
),
),
child: MaterialApp.router( child: MaterialApp.router(
key: Key(state.appColor.hashCode.toString()),
title: 'Hacki', title: 'Hacki',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith( theme: ThemeData(
useMaterial3: state.material3Enabled, colorScheme: colorScheme,
dividerTheme: state.material3Enabled fontFamily: state.font.name,
? DividerThemeData( dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2), color: Palette.grey.withOpacity(0.2),
) ),
: null, switchTheme: SwitchThemeData(
switchTheme: state.material3Enabled trackColor: MaterialStateProperty.resolveWith(
? SwitchThemeData( (Set<MaterialState> states) {
trackColor: MaterialStateProperty.resolveWith( if (states.contains(MaterialState.selected)) {
(Set<MaterialState> states) { return colorScheme.primary.withOpacity(0.6);
if (states } else {
.contains(MaterialState.selected)) { return Palette.grey.withOpacity(0.2);
return null; }
} else { },
return Palette.grey.withOpacity(0.2); ),
} ),
}, bottomSheetTheme: const BottomSheetThemeData(
), modalElevation: 8,
) clipBehavior: Clip.hardEdge,
: null, shadowColor: Palette.black,
bottomSheetTheme: state.material3Enabled ),
? const BottomSheetThemeData( inputDecorationTheme: InputDecorationTheme(
modalElevation: 8, enabledBorder: UnderlineInputBorder(
clipBehavior: Clip.hardEdge, borderSide: BorderSide(
shadowColor: Palette.black, color: isDarkModeEnabled
) ? Palette.white
: null, : Palette.black,
inputDecorationTheme: state.material3Enabled ),
? InputDecorationTheme( ),
enabledBorder: UnderlineInputBorder( ),
borderSide: BorderSide( sliderTheme: SliderThemeData(
color: isDarkModeEnabled inactiveTrackColor:
? Palette.white colorScheme.primary.withOpacity(0.5),
: Palette.black, activeTrackColor: colorScheme.primary,
), thumbColor: colorScheme.primary,
), ),
) outlinedButtonTheme: OutlinedButtonThemeData(
: null, style: ButtonStyle(
sliderTheme: state.material3Enabled side: MaterialStateBorderSide.resolveWith(
? SliderThemeData( (_) => const BorderSide(
inactiveTrackColor: color: Palette.grey,
state.appColor.shade200.withOpacity(0.5), ),
) ),
: null, ),
),
), ),
routerConfig: router, routerConfig: router,
), ),

View File

@ -0,0 +1,32 @@
typedef AppExceptionHandler = void Function(AppException);
class AppException implements Exception {
AppException({
required this.message,
this.stackTrace,
});
final String? message;
final StackTrace? stackTrace;
}
class RateLimitedException extends AppException {
RateLimitedException() : super(message: 'Rate limited...');
}
class RateLimitedWithFallbackException extends AppException {
RateLimitedWithFallbackException()
: super(message: 'Rate limited, fetching from API instead...');
}
class PossibleParsingException extends AppException {
PossibleParsingException({
required this.itemId,
}) : super(message: 'Possible parsing failure...');
final int itemId;
}
class GenericException extends AppException {
GenericException() : super(message: 'Something went wrong...');
}

View File

@ -6,4 +6,7 @@ enum CommentsOrder {
const CommentsOrder(this.description); const CommentsOrder(this.description);
final String description; final String description;
@override
String toString() => description;
} }

View File

@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
class CachedResponse<T> extends Response<T> {
CachedResponse({
required super.requestOptions,
super.data,
super.statusCode,
}) : setDateTime = DateTime.now();
factory CachedResponse.fromResponse(Response<T> response) {
return CachedResponse<T>(
requestOptions: response.requestOptions,
data: response.data,
statusCode: response.statusCode,
);
}
final DateTime setDateTime;
}

View File

@ -2,7 +2,7 @@ enum DiscoverableFeature {
addStoryToFavList( addStoryToFavList(
featureId: 'add_story_to_fav_list', featureId: 'add_story_to_fav_list',
title: 'Fav a Story', title: 'Fav a Story',
description: '''Add it to your favorites''', description: '''Add it to your favorites.''',
), ),
openStoryInWebView( openStoryInWebView(
featureId: 'open_story_in_web_view', featureId: 'open_story_in_web_view',

View File

@ -5,4 +5,7 @@ enum FetchMode {
const FetchMode(this.description); const FetchMode(this.description);
final String description; final String description;
@override
String toString() => description;
} }

View File

@ -3,26 +3,11 @@ enum Font {
robotoSlab('Roboto Slab', isSerif: true), robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'), ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'), ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true); notoSerif('Noto Serif', isSerif: true),
exo2('Exo 2');
const Font(this.uiLabel, {this.isSerif = false}); const Font(this.uiLabel, {this.isSerif = false});
final String uiLabel; final String uiLabel;
final bool isSerif; final bool isSerif;
static Font fromString(String? val) {
switch (val) {
case 'robotoSlab':
return Font.robotoSlab;
case 'ubuntu':
return Font.ubuntu;
case 'ubuntuMono':
return Font.ubuntuMono;
case 'notoSerif':
return Font.notoSerif;
case 'roboto':
default:
return Font.roboto;
}
}
} }

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

@ -90,8 +90,13 @@ class Item extends Equatable {
final List<int> kids; final List<int> kids;
final List<int> parts; final List<int> parts;
String get timeAgo => String get timeAgo {
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString(); int time = this.time;
if (time < 9999999999) {
time = time * 1000;
}
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
}
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';

View File

@ -1,3 +1,4 @@
export 'app_exception.dart';
export 'comments_order.dart'; export 'comments_order.dart';
export 'discoverable_feature.dart'; export 'discoverable_feature.dart';
export 'export_destination.dart'; export 'export_destination.dart';

View File

@ -37,13 +37,16 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const MarkReadStoriesModePreference(), const MarkReadStoriesModePreference(),
// Divider. // Divider.
const NotificationModePreference(), const NotificationModePreference(),
const SwipeGesturePreference(),
const AutoScrollModePreference(), const AutoScrollModePreference(),
const CollapseModePreference(), const CollapseModePreference(),
const ReaderModePreference(), const ReaderModePreference(),
const CustomTabPreference(), const CustomTabPreference(),
const ManualPaginationPreference(),
const SwipeGesturePreference(),
const HapticFeedbackPreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
const Material3Preference(), const TrueDarkModePreference(),
const DevMode(),
], ],
); );
@ -67,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;
@ -74,18 +79,40 @@ const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true; const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false; const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false; const bool _customTabModeDefaultValue = false;
const bool _material3ModeDefaultValue = false; const bool _paginationModeDefaultValue = false;
const bool _devModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1; const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index; final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index; final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index; final int _fontSizeDefaultValue = FontSize.regular.index;
final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange); final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange);
final int _fontDefaultValue = Font.roboto.index; final int _fontDefaultValue = Font.robotoSlab.index;
final int _tabOrderDefaultValue = final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values); StoryType.convertToSettingsValue(StoryType.values);
final int _markStoriesAsReadWhenPreferenceDefaultValue = final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index; StoryMarkingMode.tap.index;
class DevMode extends BooleanPreference {
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
@override
DevMode copyWith({required bool? val}) {
return DevMode(val: val);
}
@override
String get key => 'devMode';
@override
String get title => 'Dev Mode';
@override
String get subtitle => '';
@override
bool get isDisplayable => false;
}
class SwipeGesturePreference extends BooleanPreference { class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val}) const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue); : super(val: val ?? _swipeGestureModeDefaultValue);
@ -99,7 +126,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 =>
@ -287,24 +314,23 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.'; String get subtitle => 'some sort of magic.';
} }
class Material3Preference extends BooleanPreference { class ManualPaginationPreference extends BooleanPreference {
const Material3Preference({bool? val}) const ManualPaginationPreference({bool? val})
: super(val: val ?? _material3ModeDefaultValue); : super(val: val ?? _paginationModeDefaultValue);
@override @override
Material3Preference copyWith({required bool? val}) { ManualPaginationPreference copyWith({required bool? val}) {
return Material3Preference(val: val); return ManualPaginationPreference(val: val);
} }
@override @override
String get key => 'material3Mode'; String get key => 'paginationMode';
@override @override
String get title => 'Enable Material 3'; String get title => 'Manual Pagination';
@override @override
String get subtitle => String get subtitle => '''so you can get stuff done.''';
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
} }
/// Whether or not to use Custom Tabs for launching URLs. /// Whether or not to use Custom Tabs for launching URLs.
@ -334,6 +360,44 @@ 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 => '';
}
class FetchModePreference extends IntPreference { class FetchModePreference extends IntPreference {
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue); FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
@ -423,7 +487,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;
} }
@ -213,6 +223,9 @@ class StoriesRepository {
/// Fetch a list of [Comment] based on ids and return results /// Fetch a list of [Comment] based on ids and return results
/// using a stream. /// using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchCommentsStream({ Stream<Comment> fetchCommentsStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
@ -226,7 +239,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) {
@ -238,6 +261,9 @@ class StoriesRepository {
/// Fetch a list of [Comment] based on ids recursively and /// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream. /// return results using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchAllCommentsRecursivelyStream({ Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
@ -251,7 +277,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 +311,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 +378,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

@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
import 'package:html/dom.dart' hide Comment;
import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
HackerNewsWebRepository({
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
_dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
CacheInterceptor(),
],
);
final Dio _dioWithCache;
final Dio _dio;
static const Map<String, String> _headers = <String, String>{
'accept': '*/*',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
};
static const String _favoritesBaseUrl =
'https://news.ycombinator.com/favorites?id=';
static const String _aThingSelector =
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing';
Future<Iterable<int>> fetchFavorites({required String of}) async {
final bool isOnWifi = await _isOnWifi;
final String username = of;
final List<int> allIds = <int>[];
int page = 1;
const int maxPage = 2;
Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
try {
final Uri url = Uri.parse(
'''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
);
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(url);
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
final Document document = parse(response.data);
final List<Element> elements =
document.querySelectorAll(_aThingSelector);
final Iterable<int> parsedIds =
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
return parsedIds;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedException();
}
throw GenericException();
}
}
Iterable<int> ids;
while (page <= maxPage) {
ids = await fetchIds(page);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
page = 1;
while (page <= maxPage) {
ids = await fetchIds(page, isComment: true);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
return allIds;
}
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
static const String _athingComtrSelector =
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing.comtr';
static const String _commentTextSelector =
'''td > table > tbody > tr > td.default > div.comment''';
static const String _commentHeadSelector =
'''td > table > tbody > tr > td.default > div > span > a''';
static const String _commentAgeSelector =
'''td > table > tbody > tr > td.default > div > span > span.age''';
static const String _commentIndentSelector =
'''td > table > tbody > tr > td.ind''';
Stream<Comment> fetchCommentsStream(Item item) async* {
final bool isOnWifi = await _isOnWifi;
final int itemId = item.id;
final int? descendants = item is Story ? item.descendants : null;
int parentTextCount = 0;
Future<Iterable<Element>> fetchElements(int page) async {
try {
final Uri url = Uri.parse('$_itemBaseUrl$itemId&p=$page');
final Options option = Options(
headers: _headers,
persistentConnection: true,
);
/// Be more conservative while user is on wifi.
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(
url,
options: option,
);
final String data = response.data ?? '';
if (page == 1) {
parentTextCount = 'parent'.allMatches(data).length;
}
final Document document = parse(data);
final List<Element> elements =
document.querySelectorAll(_athingComtrSelector);
return elements;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
}
throw GenericException();
}
}
if (descendants == 0 || item.kids.isEmpty) return;
final Set<int> fetchedCommentIds = <int>{};
int page = 1;
Iterable<Element> elements = await fetchElements(page);
final Map<int, int> indentToParentId = <int, int>{};
while (elements.isNotEmpty) {
for (final Element element in elements) {
/// Get comment id.
final String cmtIdString = element.attributes['id'] ?? '';
final int? cmtId = int.tryParse(cmtIdString);
/// Get comment text.
final Element? cmtTextElement =
element.querySelector(_commentTextSelector);
final String parsedText = await compute(
_parseCommentTextHtml,
cmtTextElement?.innerHtml ?? '',
);
/// Get comment author.
final Element? cmtHeadElement =
element.querySelector(_commentHeadSelector);
final String? cmtAuthor = cmtHeadElement?.text;
/// Get comment age.
final Element? cmtAgeElement =
element.querySelector(_commentAgeSelector);
final String? ageString = cmtAgeElement?.attributes['title'];
final int? timestamp = ageString == null
? null
: DateTime.parse(ageString)
.copyWith(isUtc: true)
.millisecondsSinceEpoch;
/// Get comment indent.
final Element? cmtIndentElement =
element.querySelector(_commentIndentSelector);
final String? indentString = cmtIndentElement?.attributes['indent'];
final int indent =
indentString == null ? 0 : (int.tryParse(indentString) ?? 0);
indentToParentId[indent] = cmtId ?? 0;
final int parentId = indentToParentId[indent - 1] ?? -1;
final Comment cmt = Comment(
id: cmtId ?? 0,
time: timestamp ?? 0,
parent: parentId,
score: 0,
by: cmtAuthor ?? '',
text: parsedText,
kids: const <int>[],
dead: false,
deleted: false,
hidden: false,
level: indent,
isFromCache: false,
);
/// Skip any comment with no valid id or timestamp.
if (cmt.id == 0 || timestamp == 0) {
continue;
}
/// Duplicate comment means we are done fetching all the comments.
if (fetchedCommentIds.contains(cmt.id)) return;
fetchedCommentIds.add(cmt.id);
yield cmt;
}
/// If we didn't successfully got any comment on first page,
/// and we are sure there are comments there based on the count of
/// 'parent' text, then this might be a parsing error and possibly is
/// caused by HN changing their HTML structure, therefore here we
/// throw an error so that we can fallback to use API instead.
if (page == 1 && parentTextCount > 0 && fetchedCommentIds.isEmpty) {
throw PossibleParsingException(itemId: itemId);
}
if (descendants != null && fetchedCommentIds.length >= descendants) {
return;
}
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
page++;
elements = await fetchElements(page);
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
static Future<String> _parseCommentTextHtml(String text) async {
return HtmlUnescape()
.convert(text)
.replaceAllMapped(
RegExp(
r'\<div class="reply"\>(.*?)\<\/div\>',
dotAll: true,
),
(Match match) => '',
)
.replaceAllMapped(
RegExp(
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
dotAll: true,
),
(Match match) => '${match[2]}',
)
.replaceAllMapped(
RegExp(
r'\<p\>(.*?)\<\/p\>',
dotAll: true,
),
(Match match) => '\n\n${match[1]}',
)
.replaceAllMapped(
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(Match match) => match[1] ?? '',
)
.replaceAllMapped(
RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*',
)
.trim();
}
}

View File

@ -185,7 +185,9 @@ class OfflineRepository {
if (json == null) { if (json == null) {
return null; return null;
} }
final Comment comment = Comment.fromJson(json.cast<String, dynamic>()); final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson);
return comment; return comment;
} catch (_) { } catch (_) {
_logger.e(_); _logger.e(_);
@ -204,8 +206,9 @@ class OfflineRepository {
final Map<dynamic, dynamic>? json = await box.get(id.toString()); final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) { if (json != null) {
final Comment comment = final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
Comment.fromJson(json.cast<String, dynamic>(), level: level); typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson, level: level);
yield comment; yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1); yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);

View File

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart'; import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart'; import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between /// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository]. /// [AuthRepository] and [PostRepository].
@ -40,7 +39,7 @@ class PostableRepository {
} }
return true; return true;
} on ServiceException { } on AppException {
return false; return false;
} }
} }
@ -65,7 +64,7 @@ class PostableRepository {
), ),
); );
} on DioException catch (e) { } on DioException catch (e) {
throw ServiceException(e.message); throw AppException(message: e.message);
} }
} }

View File

@ -157,7 +157,6 @@ class PreferenceRepository {
((prefs.getStringList(_getFavKey('')) ?? <String>[]) ((prefs.getStringList(_getFavKey('')) ?? <String>[])
..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[])) ..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[]))
.map(int.parse) .map(int.parse)
.toSet()
.toList(); .toList();
return favList; return favList;
@ -175,7 +174,7 @@ class PreferenceRepository {
await _syncedPrefs.setStringList( await _syncedPrefs.setStringList(
key: key, key: key,
val: favList.map((int e) => e.toString()).toSet().toList(), val: favList.map((int e) => e.toString()).toList(),
); );
} else { } else {
final SharedPreferences prefs = await _prefs; final SharedPreferences prefs = await _prefs;
@ -186,7 +185,30 @@ class PreferenceRepository {
await prefs.setStringList( await prefs.setStringList(
key, key,
favList.map((int e) => e.toString()).toSet().toList(), favList.map((int e) => e.toString()).toList(),
);
}
}
Future<void> overwriteFav({
required String username,
required Iterable<int> ids,
}) async {
final String key = _getFavKey(username);
final List<String> favList =
ids.map((int e) => e.toString()).toList(growable: false);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: favList,
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
favList,
); );
} }
} }
@ -384,7 +406,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 +420,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,8 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'hacker_news_repository.dart';
export 'hacker_news_web_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

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart'; import 'package:sembast/sembast.dart';
@ -12,23 +14,34 @@ import 'package:sembast/sembast_io.dart';
/// documents directory assigned by host system which you can retrieve /// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory]. /// by calling [getApplicationDocumentsDirectory].
class SembastRepository { class SembastRepository {
SembastRepository({Database? database}) { SembastRepository({
Database? database,
Database? cache,
}) {
if (database == null) { if (database == null) {
initializeDatabase(); initializeDatabase();
} else { } else {
_database = database; _database = database;
} }
if (cache == null) {
initializeCache();
} else {
_cache = cache;
}
} }
Database? _database; Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe; List<int>? _idsOfCommentsRepliedToMe;
static const String _cachedCommentsKey = 'cachedComments'; static const String _cachedCommentsKey = 'cachedComments';
static const String _commentsKey = 'comments'; static const String _commentsKey = 'comments';
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe'; static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
static const String _metadataCacheKey = 'metadata';
Future<Database> initializeDatabase() async { Future<Database> initializeDatabase() async {
final Directory dir = await getApplicationDocumentsDirectory(); final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
final DatabaseFactory dbFactory = databaseFactoryIo; final DatabaseFactory dbFactory = databaseFactoryIo;
@ -37,6 +50,16 @@ class SembastRepository {
return db; return db;
} }
Future<Database> initializeCache() async {
final Directory dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki_cache.db');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;
return db;
}
//#region Cached comments for time machine feature. //#region Cached comments for time machine feature.
Future<Map<String, Object?>> cacheComment(Comment comment) async { Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase(); final Database db = _database ?? await initializeDatabase();
@ -177,10 +200,50 @@ class SembastRepository {
//#endregion //#endregion
Future<FileSystemEntity> deleteAll() async { //#region
Future<void> cacheMetadata({
required String key,
required WebInfo info,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
return db.transaction((Transaction txn) async {
await store.record(key).put(txn, info.toJson());
});
}
Future<WebInfo?> getCachedMetadata({
required String key,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
final RecordSnapshot<String, Map<String, Object?>>? snapshot =
await store.record(key).getSnapshot(db);
if (snapshot != null) {
final WebInfo info = WebInfo.fromJson(snapshot.value);
return info;
} else {
return null;
}
}
//#endregion
Future<FileSystemEntity> deleteCachedComments() async {
final Directory dir = await getApplicationDocumentsDirectory(); final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete(); return File(dbPath).delete();
} }
Future<FileSystemEntity> deleteCachedMetadata() async {
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String cachePath = join(tempDir.path, 'hacki_cache.db');
return File(cachePath).delete();
}
} }

View File

@ -51,7 +51,7 @@ class _HomeScreenState extends State<HomeScreen>
DeviceScreenType.mobile) { DeviceScreenType.mobile) {
locator.get<Logger>().i('Resetting comments in CommentCache'); locator.get<Logger>().i('Resetting comments in CommentCache');
Future<void>.delayed( Future<void>.delayed(
Durations.ms500, AppDurations.ms500,
locator.get<CommentCache>().resetComments, locator.get<CommentCache>().resetComments,
); );
} }
@ -141,15 +141,8 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox( SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8, height: MediaQuery.of(context).padding.top - Dimens.pt8,
), ),
Theme( CustomTabBar(
data: ThemeData( tabController: tabController,
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
), ),
], ],
), ),
@ -196,12 +189,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 +204,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 +229,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 +252,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 +271,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 +296,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

@ -45,7 +45,8 @@ class PinnedStories extends StatelessWidget {
], ],
), ),
child: ColoredBox( child: ColoredBox(
color: Theme.of(context).primaryColor.withOpacity(0.2), color:
Theme.of(context).colorScheme.primary.withOpacity(0.2),
child: StoryTile( child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'), key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story, story: story,
@ -61,7 +62,7 @@ class PinnedStories extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12), padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider( child: Divider(
color: Theme.of(context).primaryColor.withOpacity(0.8), color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
), ),
), ),
], ],

View File

@ -37,7 +37,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
width: homeScreenWidth, width: homeScreenWidth,
duration: Durations.ms300, duration: AppDurations.ms300,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: homeScreen, child: homeScreen,
), ),
@ -53,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth, left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: Durations.ms300, duration: AppDurations.ms300,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: const _TabletStoryView(), child: const _TabletStoryView(),
), ),

Some files were not shown because too many files have changed in this diff Show More