Compare commits

..

7 Commits

20 changed files with 436 additions and 230 deletions

View File

@ -1,7 +1,9 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.6) CFPropertyList (3.0.7)
base64
nkf
rexml rexml
activesupport (6.1.7) activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@ -9,30 +11,31 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.6) addressable (2.8.7)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5) algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3) httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1) json (>= 1.5.1)
artifactory (3.0.15) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.889.0) aws-partitions (1.994.0)
aws-sdk-core (3.191.1) aws-sdk-core (3.211.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0) aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.143.0) aws-sdk-s3 (1.169.0)
aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0) aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0) claide (1.1.0)
cocoapods (1.11.3) cocoapods (1.11.3)
addressable (~> 2.8) addressable (~> 2.8)
@ -87,7 +90,7 @@ GEM
ethon (0.15.0) ethon (0.15.0)
ffi (>= 1.15.0) ffi (>= 1.15.0)
excon (0.109.0) excon (0.109.0)
faraday (1.10.3) faraday (1.10.4)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -108,22 +111,22 @@ GEM
faraday-httpclient (1.0.1) faraday-httpclient (1.0.1)
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (1.0.1) faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.3) faraday-retry (1.0.3)
faraday_middleware (1.2.0) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.3.0) fastimage (2.3.1)
fastlane (2.219.0) fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0) aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0) babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0) bundler (>= 1.12.0, < 3.0.0)
colored colored (~> 1.2)
commander (~> 4.6) commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0) dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0) emoji_regex (>= 0.1, < 4.0)
@ -132,6 +135,7 @@ GEM
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
@ -144,10 +148,10 @@ GEM
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2) naturally (~> 2.2)
optparse (>= 0.1.1) optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3) security (= 0.1.5)
simctl (~> 1.6.3) simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0) terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3) terminal-table (~> 3)
@ -156,7 +160,9 @@ GEM
word_wrap (~> 1.0.0) word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0) xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.15.5) ffi (1.15.5)
fourflusher (2.3.1) fourflusher (2.3.1)
fuzzy_match (2.0.4) fuzzy_match (2.0.4)
@ -198,40 +204,42 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
highline (2.0.3) highline (2.0.3)
http-cookie (1.0.5) http-cookie (1.0.7)
domain_name (~> 0.5) domain_name (~> 0.5)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.1) json (2.7.2)
jwt (2.7.1) jwt (2.9.3)
mini_magick (4.12.0) base64
mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.16.3) minitest (5.16.3)
molinillo (0.8.0) molinillo (0.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.0) multipart-post (2.4.1)
nanaimo (0.3.0) nanaimo (0.3.0)
nap (1.1.0) nap (1.1.0)
naturally (2.2.1) naturally (2.2.1)
netrc (0.11.0) netrc (0.11.0)
optparse (0.4.0) nkf (0.2.0)
optparse (0.5.0)
os (1.1.4) os (1.1.4)
plist (3.7.1) plist (3.7.1)
public_suffix (4.0.7) public_suffix (4.0.7)
rake (13.1.0) rake (13.2.1)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.6) rexml (3.3.8)
rouge (2.0.7) rouge (2.0.7)
ruby-macho (2.5.1) ruby-macho (2.5.1)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
security (0.1.3) security (0.1.5)
signet (0.18.0) signet (0.18.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@ -240,6 +248,7 @@ GEM
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
@ -253,18 +262,16 @@ GEM
tzinfo (2.0.5) tzinfo (2.0.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
uber (0.1.0) uber (0.1.0)
unf (0.1.4) unf (0.2.0)
unf_ext unicode-display_width (2.6.0)
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.24.0) xcodeproj (1.25.1)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3) atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0) claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1) colored2 (~> 3.1)
nanaimo (~> 0.3.0) nanaimo (~> 0.3.0)
rexml (~> 3.2.4) rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0) xcpretty (0.3.0)
rouge (~> 2.0.7) rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1) xcpretty-travis-formatter (1.0.1)

View File

@ -10,10 +10,10 @@ PODS:
- flutter_inappwebview_ios (0.0.1): - flutter_inappwebview_ios (0.0.1):
- Flutter - Flutter
- flutter_inappwebview_ios/Core (= 0.0.1) - flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0) - OrderedSet (~> 6.0.3)
- flutter_inappwebview_ios/Core (0.0.1): - flutter_inappwebview_ios/Core (0.0.1):
- Flutter - Flutter
- OrderedSet (~> 5.0) - OrderedSet (~> 6.0.3)
- flutter_local_notifications (0.0.1): - flutter_local_notifications (0.0.1):
- Flutter - Flutter
- flutter_native_splash (0.0.1): - flutter_native_splash (0.0.1):
@ -25,7 +25,7 @@ PODS:
- integration_test (0.0.1): - integration_test (0.0.1):
- Flutter - Flutter
- MTBBarcodeScanner (5.0.11) - MTBBarcodeScanner (5.0.11)
- OrderedSet (5.0.0) - OrderedSet (6.0.3)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
@ -135,14 +135,14 @@ SPEC CHECKSUMS:
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40 flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
@ -158,4 +158,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: f03c7c11cf2b623592c89c68c628682778bb78b4 PODFILE CHECKSUM: f03c7c11cf2b623592c89c68c628682778bb78b4
COCOAPODS: 1.15.2 COCOAPODS: 1.16.2

View File

@ -19,6 +19,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository, HackerNewsWebRepository? hackerNewsWebRepository,
SembastRepository? sembastRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
@ -27,6 +28,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository = _hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(), hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -36,8 +39,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository; final HackerNewsWebRepository _hackerNewsWebRepository;
final SembastRepository _sembastRepository;
late final StreamSubscription<String>? _usernameSubscription; late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 100;
Future<void> init() async { Future<void> init() async {
_usernameSubscription = _authBloc.stream _usernameSubscription = _authBloc.stream
@ -55,6 +59,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
_hackerNewsRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
getFromCache: (int id) =>
_sembastRepository.getCachedItem(id: id),
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
@ -97,7 +103,10 @@ class FavCubit extends Cubit<FavState> with Loggable {
void removeFav(int id) { void removeFav(int id) {
_preferenceRepository _preferenceRepository
..removeFav(username: username, id: id) ..removeFav(username: username, id: id)
..removeFav(username: '', id: id); ..removeFav(
username: '',
id: id,
);
emit( emit(
state.copyWith( state.copyWith(
@ -200,6 +209,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
} }
void _onItemLoaded(Item item) { void _onItemLoaded(Item item) {
_sembastRepository.cacheItem(item);
emit( emit(
state.copyWith( state.copyWith(
favItems: List<Item>.from(state.favItems)..add(item), favItems: List<Item>.from(state.favItems)..add(item),
@ -207,6 +217,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
); );
} }
void switchTab() =>
emit(state.copyWith(isDisplayingStories: !state.isDisplayingStories));
@override @override
Future<void> close() { Future<void> close() {
_usernameSubscription?.cancel(); _usernameSubscription?.cancel();

View File

@ -7,6 +7,7 @@ class FavState extends Equatable {
required this.status, required this.status,
required this.mergeStatus, required this.mergeStatus,
required this.currentPage, required this.currentPage,
required this.isDisplayingStories,
}); });
FavState.init() FavState.init()
@ -14,13 +15,21 @@ class FavState extends Equatable {
favItems = <Item>[], favItems = <Item>[],
status = Status.idle, status = Status.idle,
mergeStatus = Status.idle, mergeStatus = Status.idle,
currentPage = 0; currentPage = 0,
isDisplayingStories = true;
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 Status mergeStatus;
final int currentPage; final int currentPage;
final bool isDisplayingStories;
List<Comment> get favComments =>
favItems.whereType<Comment>().toList(growable: false);
List<Story> get favStories =>
favItems.whereType<Story>().toList(growable: false);
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
@ -28,6 +37,7 @@ class FavState extends Equatable {
Status? status, Status? status,
Status? mergeStatus, Status? mergeStatus,
int? currentPage, int? currentPage,
bool? isDisplayingStories,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
@ -35,6 +45,7 @@ class FavState extends Equatable {
status: status ?? this.status, status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus, mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
isDisplayingStories: isDisplayingStories ?? this.isDisplayingStories,
); );
} }
@ -45,5 +56,6 @@ class FavState extends Equatable {
currentPage, currentPage,
favIds, favIds,
favItems, favItems,
isDisplayingStories,
]; ];
} }

View File

@ -41,6 +41,7 @@ class BuildableComment extends Comment with Buildable {
BuildableComment copyWith({ BuildableComment copyWith({
int? level, int? level,
bool? hidden, bool? hidden,
int? kid,
}) { }) {
return BuildableComment( return BuildableComment(
id: id, id: id,
@ -49,7 +50,7 @@ class BuildableComment extends Comment with Buildable {
score: score, score: score,
by: by, by: by,
text: text, text: text,
kids: kids, kids: kid == null ? kids : <int>[...kids, kid],
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,

View File

@ -36,6 +36,7 @@ class Comment extends Item {
Comment copyWith({ Comment copyWith({
int? level, int? level,
bool? hidden, bool? hidden,
int? kid,
}) { }) {
return Comment( return Comment(
id: id, id: id,
@ -44,7 +45,7 @@ class Comment extends Item {
score: score, score: score,
by: by, by: by,
text: text, text: text,
kids: kids, kids: kid == null ? kids : <int>[...kids, kid],
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,

View File

@ -110,11 +110,11 @@ final class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode'; String get key => 'swipeGestureMode';
@override @override
String get title => 'Swipe Gesture'; String get title => 'Swipe Gesture for Switching Tabs';
@override @override
String get subtitle => String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.'''; '''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu and double tap to open the url (if complex tile is disabled).''';
} }
final class NotificationModePreference extends BooleanPreference { final class NotificationModePreference extends BooleanPreference {

View File

@ -302,8 +302,15 @@ class HackerNewsRepository with Loggable {
/// Fetch a list of [Item] based on ids and return results /// Fetch a list of [Item] based on ids and return results
/// using a stream. /// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* { Stream<Item> fetchItemsStream({
required List<int> ids,
Future<Item?> Function(int)? getFromCache,
}) async* {
for (final int id in ids) { for (final int id in ids) {
final Item? cachedItem = await getFromCache?.call(id);
if (cachedItem != null) {
yield cachedItem;
} else {
final Item? item = final Item? item =
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;
@ -323,6 +330,7 @@ class HackerNewsRepository with Loggable {
} }
} }
} }
}
/// Fetch a list of [Story] based on ids and return results /// Fetch a list of [Story] based on ids and return results
/// using a stream. /// using a stream.

View File

@ -175,7 +175,8 @@ class HackerNewsWebRepository with Loggable {
subtextElement.querySelector(_ageSelector) ?? subtextElement.querySelector(_ageSelector) ??
subtextElement.querySelector('.age'); subtextElement.querySelector('.age');
final String? dateStr = postDateElement?.attributes['title']; final String? dateStr =
postDateElement?.attributes['title']?.split(' ').firstOrNull;
final int? timestamp = dateStr == null final int? timestamp = dateStr == null
? null ? null
: DateTime.parse(dateStr) : DateTime.parse(dateStr)
@ -401,7 +402,8 @@ class HackerNewsWebRepository with Loggable {
/// Get comment age. /// Get comment age.
final Element? cmtAgeElement = final Element? cmtAgeElement =
element.querySelector(_commentAgeSelector); element.querySelector(_commentAgeSelector);
final String? ageString = cmtAgeElement?.attributes['title']; final String? ageString =
cmtAgeElement?.attributes['title']?.split(' ').firstOrNull;
final int? timestamp = ageString == null final int? timestamp = ageString == null
? null ? null

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
@ -10,6 +11,8 @@ import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart'; import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading. /// [SembastRepository] is for storing stories and comments for faster loading.
/// This is currently used by [TimeMachineCubit], [NotificationCubit] and
/// [FavCubit].
/// ///
/// Sembast [Database] is used as its database and is being stored in the /// Sembast [Database] is used as its database and is being stored in the
/// documents directory assigned by host system which you can retrieve /// documents directory assigned by host system which you can retrieve
@ -67,7 +70,7 @@ class SembastRepository with Loggable {
return db; return db;
} }
//#region Cached comments for time machine feature. //#region Cached comments for time machine feature and favorites screen.
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();
final StoreRef<int, Map<String, Object?>> store = final StoreRef<int, Map<String, Object?>> store =
@ -89,7 +92,34 @@ class SembastRepository with Loggable {
} }
} }
Future<int> deleteAllCachedComments() async { Future<Map<String, Object?>> cacheItem(Item item) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
return store.record(item.id).put(db, item.toJson());
}
Future<Item?> getCachedItem({required int id}) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await store.record(id).getSnapshot(db);
if (snapshot != null) {
final bool isStory = snapshot['type'] == 'story';
if (isStory) {
final Story story = Story.fromJson(snapshot.value);
return story;
} else {
final Comment comment = Comment.fromJson(snapshot.value);
return comment;
}
} else {
return null;
}
}
Future<int> deleteAllCachedItems() async {
final Database db = _database ?? await initializeDatabase(); final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store = final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey); intMapStoreFactory.store(_cachedCommentsKey);

View File

@ -1,7 +1,5 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
@ -130,124 +128,12 @@ class _ProfileScreenState extends State<ProfileScreen>
top: Dimens.pt50, top: Dimens.pt50,
child: Visibility( child: Visibility(
visible: pageType == PageType.fav, visible: pageType == PageType.fav,
child: BlocConsumer<FavCubit, FavState>( child: FavoritesScreen(
listener: (BuildContext context, FavState favState) {
if (favState.status == Status.success) {
refreshControllerFav
..refreshCompleted()
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length,
builder: (BuildContext context, FavState favState) {
Widget? header() => authState.isLoggedIn
? BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () =>
context.read<FavCubit>().merge(
onError: (AppException e) =>
showErrorSnackBar(e.message),
onSuccess: () => showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child:
CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text('Sync from Hacker News'),
);
},
)
: null;
if (favState.favItems.isEmpty &&
favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled !=
current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile:
prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile:
prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshControllerFav, refreshController: refreshControllerFav,
items: favState.favItems, authState: authState,
onRefresh: () { onItemTap: (Item item) => goToItemScreen(
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item), args: ItemScreenArgs(item: item),
), ),
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context
.read<FavCubit>()
.removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
), ),
), ),
), ),

View File

@ -0,0 +1,187 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/profile/widgets/centered_message_view.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({
required this.refreshController,
required this.authState,
required this.onItemTap,
super.key,
});
final RefreshController refreshController;
final AuthState authState;
final void Function(Item) onItemTap;
@override
Widget build(BuildContext context) {
return BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == Status.success) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length ||
previous.isDisplayingStories != current.isDisplayingStories,
builder: (BuildContext context, FavState favState) {
Widget? header() => Column(
children: <Widget>[
if (authState.isLoggedIn)
BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () => context.read<FavCubit>().merge(
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
onSuccess: () => context.showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'Sync from Hacker News',
),
);
},
),
Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: favState.isDisplayingStories,
label: 'Story',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: !favState.isDisplayingStories,
label: 'Comment',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
],
),
],
);
if (favState.favItems.isEmpty && favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
} else {
if (favState.isDisplayingStories && favState.favStories.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite story.',
),
],
);
} else if (!favState.isDisplayingStories &&
favState.favComments.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite comment.',
),
],
);
}
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile: prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile: prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshController,
items: favState.isDisplayingStories
? favState.favStories
: favState.favComments,
onRefresh: () {
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: onItemTap,
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context.read<FavCubit>().removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
);
}
}

View File

@ -606,7 +606,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
context.pop(); context.pop();
locator locator
.get<SembastRepository>() .get<SembastRepository>()
.deleteAllCachedComments() .deleteAllCachedItems()
.whenComplete( .whenComplete(
locator.get<OfflineRepository>().deleteAll, locator.get<OfflineRepository>().deleteAll,
) )

View File

@ -1,5 +1,6 @@
export 'centered_message_view.dart'; export 'centered_message_view.dart';
export 'enter_offline_mode_list_tile.dart'; export 'enter_offline_mode_list_tile.dart';
export 'favorites_screen.dart';
export 'inbox_view.dart'; export 'inbox_view.dart';
export 'offline_list_tile.dart'; export 'offline_list_tile.dart';
export 'settings.dart'; export 'settings.dart';

View File

@ -82,6 +82,13 @@ class ItemsListView<T extends Item> extends StatelessWidget {
FadeIn( FadeIn(
child: InkWell( child: InkWell(
onTap: () => onTap(e), onTap: () => onTap(e),
/// If swipe gesture is enabled on home screen, use
/// long press instead of slide action to trigger
/// the action menu.
onLongPress: swipeGestureEnabled
? () => onMoreTapped?.call(e, context.rect)
: null,
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: Dimens.pt8, top: Dimens.pt8,

View File

@ -123,9 +123,12 @@ class LinkView extends StatelessWidget {
), ),
), ),
) )
: CachedNetworkImage( : () {
imageUrl: imageUri ?? Constants.favicon(url), if (imageUri?.isNotEmpty ?? false) {
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth, return CachedNetworkImage(
imageUrl: imageUri!,
fit:
isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri, cacheKey: imageUri,
errorWidget: (_, __, ___) { errorWidget: (_, __, ___) {
if (url.isEmpty) { if (url.isEmpty) {
@ -153,7 +156,33 @@ class LinkView extends StatelessWidget {
), ),
); );
}, },
);
} else if (url.isNotEmpty) {
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
), ),
);
},
),
);
} else {
return FadeIn(
child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
}(),
), ),
), ),
) )

View File

@ -128,7 +128,7 @@ class StoryTile extends StatelessWidget {
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: () { onDoubleTap: () {
if (story.url.isNotEmpty) { if (story.url.isNotEmpty) {
LinkUtil.launch( LinkUtil.launch(
story.url, story.url,

View File

@ -3,7 +3,18 @@ import 'package:hacki/models/models.dart' show Comment;
class CommentCache { class CommentCache {
static final Map<int, Comment> _comments = <int, Comment>{}; static final Map<int, Comment> _comments = <int, Comment>{};
void cacheComment(Comment comment) => _comments[comment.id] = comment; void cacheComment(Comment comment) {
_comments[comment.id] = comment;
/// Comments fetched from `HackerNewsWebRepository` doesn't have populated
/// `kids` field, this is why we need to update that of the parent
/// comment here.
final int parentId = comment.parent;
final Comment? parent = _comments[parentId];
if (parent == null || parent.kids.contains(comment.id)) return;
final Comment updatedParent = parent.copyWith(kid: comment.id);
_comments[parentId] = updatedParent;
}
Comment? getComment(int id) => _comments[id]; Comment? getComment(int id) => _comments[id];

View File

@ -250,10 +250,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0" sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.6.0" version: "5.7.0"
dio_smart_retry: dio_smart_retry:
dependency: "direct main" dependency: "direct main"
description: description:
@ -381,18 +381,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_inappwebview name: flutter_inappwebview
sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.1.5"
flutter_inappwebview_android: flutter_inappwebview_android:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_android name: flutter_inappwebview_android
sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.13" version: "1.1.3"
flutter_inappwebview_internal_annotations: flutter_inappwebview_internal_annotations:
dependency: transitive dependency: transitive
description: description:
@ -405,34 +405,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_ios name: flutter_inappwebview_ios
sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.13" version: "1.1.2"
flutter_inappwebview_macos: flutter_inappwebview_macos:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_macos name: flutter_inappwebview_macos
sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.11" version: "1.1.2"
flutter_inappwebview_platform_interface: flutter_inappwebview_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_platform_interface name: flutter_inappwebview_platform_interface
sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.10" version: "1.3.0+1"
flutter_inappwebview_web: flutter_inappwebview_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_web name: flutter_inappwebview_web
sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_local_notifications: flutter_local_notifications:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1458,13 +1466,13 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
web: web:
dependency: transitive dependency: "direct overridden"
description: description:
name: web name: web
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.1" version: "1.1.0"
web_socket: web_socket:
dependency: transitive dependency: transitive
description: description:
@ -1578,5 +1586,5 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.4.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.24.3" flutter: ">=3.24.3"

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.9.2+150 version: 2.9.7+155
publish_to: none publish_to: none
environment: environment:
@ -17,7 +17,7 @@ dependencies:
collection: ^1.17.1 collection: ^1.17.1
connectivity_plus: ^6.0.3 connectivity_plus: ^6.0.3
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
dio: ^5.4.3+1 dio: ^5.7.0
dio_smart_retry: ^6.0.0 dio_smart_retry: ^6.0.0
equatable: ^2.0.5 equatable: ^2.0.5
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
@ -32,7 +32,7 @@ dependencies:
flutter_email_sender: ^6.0.3 flutter_email_sender: ^6.0.3
flutter_fadein: ^2.0.0 flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1 flutter_feather_icons: 2.0.0+1
flutter_inappwebview: ^6.0.0 flutter_inappwebview: ^6.1.5
flutter_local_notifications: ^17.1.2 flutter_local_notifications: ^17.1.2
flutter_material_color_picker: ^1.2.0 flutter_material_color_picker: ^1.2.0
flutter_native_splash: ^2.4.1 flutter_native_splash: ^2.4.1
@ -83,6 +83,9 @@ dependencies:
webview_flutter: ^4.8.0 webview_flutter: ^4.8.0
workmanager: ^0.5.1 workmanager: ^0.5.1
dependency_overrides:
web: ^1.0.0
dev_dependencies: dev_dependencies:
bloc_test: ^9.1.0 bloc_test: ^9.1.0
flutter_driver: flutter_driver: