Compare commits

..

7 Commits

Author SHA1 Message Date
19f2107d95 v0.2.28 (#65)
* bumped version.

* fixed comments cubit and story tile.

* cancel subscription on error.
2022-07-02 01:14:40 -07:00
c9b2d82dfc v0.2.27 (#64)
* bumped version.

* fixed comment cubit.

* fixed share button.

* fixed share dialog.

* added lazy loading.

* bumped version.

* fixed lazy loading.

* bumped version.

* updated screenshots.

* added customization of fetch mode and comments order.

* updated screenshots.

* added haptic feedback.
2022-06-30 18:32:11 -07:00
56e442b09f v0.2.26 (#63)
* added integration test.

* replace listview with listview.builder

* added cache.

* bumped version.

* bumped version.

* updated github action.

* bumped version.

* fixed time machine cubit.

* fixed time machine cubit.

* reverted changes.

* removed keepAliveMixin
2022-06-28 17:32:50 -07:00
9069efcced improved logging. 2022-06-28 12:08:02 -07:00
bf6a5667dc fixed naming. 2022-06-28 12:01:36 -07:00
cff73a010b v0.2.25 (#62)
* bumped version.

* improved cache.

* improved comment cache.

* updated default val for navigationMode.
2022-06-28 00:08:07 -07:00
f0d6cac3fd v0.2.24 (#61)
* bumped version.

* improved collapse.

* improved download speed.

* improved err handling.

* improved error handling.

* improved error handling.

* improved logging.

* improved logging.

* bumped version.
2022-06-27 00:47:22 -07:00
87 changed files with 1585 additions and 865 deletions

View File

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
JAVA_VERSION: "11.0" JAVA_VERSION: "11.0"
FLUTTER_VERSION: "3.0.0" FLUTTER_VERSION: "3.0.3"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
@ -20,7 +20,7 @@ jobs:
java-version: '17' java-version: '17'
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.0.0' flutter-version: '3.0.3'
channel: 'stable' channel: 'stable'
- run: flutter pub get - run: flutter pub get
- run: flutter analyze - run: flutter analyze

Binary file not shown.

Before

Width:  |  Height:  |  Size: 935 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View File

@ -1 +0,0 @@
- Bugfixes.

View File

@ -1 +0,0 @@
- Updates to UI.

View File

@ -1 +0,0 @@
- Updates to UI.

View File

@ -1 +0,0 @@
- Updates to UI.

View File

@ -1 +0,0 @@
- Tapping on comments in notification and history screen will lead you directly to the comment.

View File

@ -1,3 +0,0 @@
- Tapping on comment in notification or history screen will now lead you directly to the comment.
- Fixed the bug where reply box cannot be expanded in editing mode.
- Fixed inconsistent font size in history screen.

View File

@ -1 +0,0 @@
- Added offline mode.

View File

@ -1,2 +0,0 @@
- Added offline mode.
- Bugfixes.

View File

@ -1,2 +0,0 @@
- Added offline mode.
- Bugfixes.

View File

@ -1,2 +0,0 @@
- Added offline mode.
- Bugfixes.

View File

@ -1,3 +0,0 @@
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,3 +0,0 @@
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,3 +0,0 @@
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,4 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.

View File

@ -1,5 +0,0 @@
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -1,6 +0,0 @@
- You can now add filters for searching.
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -1,6 +0,0 @@
- You can now add filters for searching.
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -1,6 +0,0 @@
- You can now add filters for searching.
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -1,6 +0,0 @@
- You can now add filters for searching.
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -1,7 +0,0 @@
- You can share links.
- You can now add filters for searching.
- You can now participate in polls.
- Pick up where you left off.
- Swipe left on comment tile to view its parents without scrolling all the way up.
- Huge performance boost.
- Bugfixes.

View File

@ -0,0 +1,2 @@
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,2 @@
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,2 @@
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 935 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View File

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hacki/main.dart' as app;
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('performance test', () {
testWidgets('scrolling performance on ItemScreen',
(WidgetTester tester) async {
await app.main(testing: true);
await tester.pump();
final Finder bestStoryTabFinder = find.text('BEST');
await tester.tap(bestStoryTabFinder);
await tester.pumpAndSettle(const Duration(seconds: 3));
final Finder storyTileFinder = find.byType(StoryTile);
await tester.tap(storyTileFinder.first);
await tester.pumpAndSettle(const Duration(seconds: 3));
TestGesture gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, 900));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -900));
await tester.pump();
});
});
}

View File

@ -19,6 +19,8 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- integration_test (0.0.1):
- Flutter
- OrderedSet (5.0.0) - OrderedSet (5.0.0)
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
- Flutter - Flutter
@ -50,6 +52,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`) - flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@ -80,6 +83,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions: flutter_siri_suggestions:
:path: ".symlinks/plugins/flutter_siri_suggestions/ios" :path: ".symlinks/plugins/flutter_siri_suggestions/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
path_provider_ios: path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios" :path: ".symlinks/plugins/path_provider_ios/ios"
receive_sharing_intent: receive_sharing_intent:
@ -109,6 +114,7 @@ SPEC CHECKSUMS:
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825

View File

@ -577,7 +577,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.23; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -714,7 +714,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.23; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -745,7 +745,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.23; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -7,7 +7,9 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.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';
import 'package:responsive_builder/responsive_builder.dart'; import 'package:responsive_builder/responsive_builder.dart';
import 'package:rxdart/rxdart.dart';
part 'stories_event.dart'; part 'stories_event.dart';
part 'stories_state.dart'; part 'stories_state.dart';
@ -15,15 +17,18 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> { class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({ StoriesBloc({
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
CacheRepository? cacheRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(), _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) { super(const StoriesState.init()) {
on<StoriesInitialize>(onInitialize); on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh); on<StoriesRefresh>(onRefresh);
@ -39,9 +44,10 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final CacheRepository _cacheRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger;
DeviceScreenType? deviceScreenType; DeviceScreenType? deviceScreenType;
StreamSubscription<PreferenceState>? _streamSubscription; StreamSubscription<PreferenceState>? _streamSubscription;
static const int _smallPageSize = 10; static const int _smallPageSize = 10;
@ -71,7 +77,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesPageSizeChanged(pageSize: pageSize)); add(StoriesPageSizeChanged(pageSize: pageSize));
} }
}); });
final bool hasCachedStories = await _cacheRepository.hasCachedStories; final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile; final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
final int pageSize = _getPageSize(isComplexTile: isComplexTile); final int pageSize = _getPageSize(isComplexTile: isComplexTile);
emit( emit(
@ -90,13 +96,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required Emitter<StoriesState> emit, required Emitter<StoriesState> emit,
}) async { }) async {
if (state.offlineReading) { if (state.offlineReading) {
final List<int> ids = await _cacheRepository.getCachedStoryIds(of: of); final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of);
emit( emit(
state state
.copyWithStoryIdsUpdated(of: of, to: ids) .copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0), .copyWithCurrentPageUpdated(of: of, to: 0),
); );
_cacheRepository _offlineRepository
.getCachedStoriesStream( .getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, state.currentPageSize)), ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
) )
@ -167,7 +173,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
if (state.offlineReading) { if (state.offlineReading) {
_cacheRepository _offlineRepository
.getCachedStoriesStream( .getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist( ids: state.storyIdsByType[event.type]!.sublist(
lower, lower,
@ -241,9 +247,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
), ),
); );
await _cacheRepository.deleteAllStoryIds(); await _offlineRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories(); await _offlineRepository.deleteAllStories();
await _cacheRepository.deleteAllComments(); await _offlineRepository.deleteAllComments();
final Set<int> prioritizedIds = <int>{}; final Set<int> prioritizedIds = <int>{};
final List<StoryType> prioritizedTypes = <StoryType>[...types] final List<StoryType> prioritizedTypes = <StoryType>[...types]
@ -251,7 +257,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
for (final StoryType type in prioritizedTypes) { for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(of: type); final List<int> ids = await _storiesRepository.fetchStoryIds(of: type);
await _cacheRepository.cacheStoryIds(of: type, ids: ids); await _offlineRepository.cacheStoryIds(of: type, ids: ids);
prioritizedIds.addAll(ids); prioritizedIds.addAll(ids);
} }
@ -273,7 +279,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final List<int> ids = await _storiesRepository.fetchStoryIds( final List<int> ids = await _storiesRepository.fetchStoryIds(
of: StoryType.latest, of: StoryType.latest,
); );
await _cacheRepository.cacheStoryIds(of: StoryType.latest, ids: ids); await _offlineRepository.cacheStoryIds(of: StoryType.latest, ids: ids);
latestIds.addAll(ids); latestIds.addAll(ids);
await fetchAndCacheStories( await fetchAndCacheStories(
@ -281,12 +287,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
includingWebPage: event.includingWebPage, includingWebPage: event.includingWebPage,
isPrioritized: false, isPrioritized: false,
); );
emit(
state.copyWith(
downloadStatus: StoriesDownloadStatus.finished,
),
);
} catch (_) { } catch (_) {
emit( emit(
state.copyWith( state.copyWith(
@ -318,44 +318,53 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
continue; continue;
} }
await _cacheRepository.cacheStory(story: story); await _offlineRepository.cacheStory(story: story);
if (story.url.isNotEmpty && includingWebPage) { if (story.url.isNotEmpty && includingWebPage) {
await _cacheRepository.cacheUrl(url: story.url); _logger.i('downloading ${story.url}');
await _offlineRepository.cacheUrl(url: story.url);
} }
final Completer<void> completer = Completer<void>();
_storiesRepository _storiesRepository
.fetchAllChildrenComments(ids: story.kids) .fetchAllChildrenComments(ids: story.kids)
.listen((Comment? comment) async { .whereType<Comment>()
if (comment != null) { .listen(
await _cacheRepository.cacheComment(comment: comment); (Comment comment) => unawaited(
} _offlineRepository.cacheComment(comment: comment),
}).onDone(() { ),
completer.complete(); )
add(StoryDownloaded(skipped: false)); .onDone(() => add(StoryDownloaded(skipped: false)));
});
await completer.future;
} }
} }
void onStoryDownloaded(StoryDownloaded event, Emitter<StoriesState> emit) { void onStoryDownloaded(StoryDownloaded event, Emitter<StoriesState> emit) {
if (event.skipped) { if (event.skipped) {
final int updatedStoriesToBeDownloaded = state.storiesToBeDownloaded - 1;
emit( emit(
state.copyWith( state.copyWith(
storiesToBeDownloaded: state.storiesToBeDownloaded - 1, storiesToBeDownloaded: updatedStoriesToBeDownloaded,
downloadStatus:
state.storiesDownloaded == updatedStoriesToBeDownloaded
? StoriesDownloadStatus.finished
: null,
), ),
); );
} else { } else {
final int updatedStoriesDownloaded = state.storiesDownloaded + 1; final int updatedStoriesDownloaded = state.storiesDownloaded + 1;
final int updatedStoriesToBeDownloaded =
updatedStoriesDownloaded > state.storiesToBeDownloaded
? state.storiesToBeDownloaded + 1
: state.storiesToBeDownloaded;
emit( emit(
state.copyWith( state.copyWith(
storiesDownloaded: updatedStoriesDownloaded, storiesDownloaded: updatedStoriesDownloaded,
storiesToBeDownloaded: storiesToBeDownloaded: updatedStoriesToBeDownloaded,
updatedStoriesDownloaded > state.storiesToBeDownloaded downloadStatus:
? state.storiesToBeDownloaded + 1 updatedStoriesDownloaded == updatedStoriesToBeDownloaded
: state.storiesToBeDownloaded, ? StoriesDownloadStatus.finished
: null,
), ),
); );
} }
@ -373,10 +382,10 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesExitOffline event, StoriesExitOffline event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
await _cacheRepository.deleteAllStoryIds(); await _offlineRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories(); await _offlineRepository.deleteAllStories();
await _cacheRepository.deleteAllComments(); await _offlineRepository.deleteAllComments();
await _cacheRepository.deleteAllWebPages(); await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false)); emit(state.copyWith(offlineReading: false));
add(StoriesInitialize()); add(StoriesInitialize());
} }

View File

@ -0,0 +1,31 @@
import 'package:logger/logger.dart';
class CustomLogFilter extends LogFilter {
@override
// ignore: overridden_fields
Level? level = Level.verbose;
/// The minimal level allowed in production.
static const Level _minimalLevel = Level.info;
@override
bool shouldLog(LogEvent event) {
bool shouldLog = false;
if (event.level.index >= _minimalLevel.index) {
return true;
}
assert(
() {
if (event.level.index >= level!.index) {
shouldLog = true;
}
return true;
}(),
'',
);
return shouldLog;
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:hacki/config/custom_log_filter.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
@ -10,16 +11,17 @@ final GetIt locator = GetIt.instance;
/// Set up [GetIt] locator. /// Set up [GetIt] locator.
Future<void> setUpLocator() async { Future<void> setUpLocator() async {
locator locator
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
..registerSingleton<StoriesRepository>(StoriesRepository()) ..registerSingleton<StoriesRepository>(StoriesRepository())
..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<SembastRepository>(SembastRepository())
..registerSingleton<CacheRepository>(CacheRepository()) ..registerSingleton<OfflineRepository>(OfflineRepository())
..registerSingleton<CacheService>(CacheService()) ..registerSingleton<DraftCache>(DraftCache())
..registerSingleton<CommentCache>(CommentCache())
..registerSingleton<LocalNotification>(LocalNotification()) ..registerSingleton<LocalNotification>(LocalNotification())
..registerSingleton<Logger>(Logger())
..registerSingleton<RouteObserver<ModalRoute<dynamic>>>( ..registerSingleton<RouteObserver<ModalRoute<dynamic>>>(
RouteObserver<ModalRoute<dynamic>>(), RouteObserver<ModalRoute<dynamic>>(),
); );

View File

@ -10,31 +10,31 @@ part 'collapse_state.dart';
class CollapseCubit extends Cubit<CollapseState> { class CollapseCubit extends Cubit<CollapseState> {
CollapseCubit({ CollapseCubit({
required int commentId, required int commentId,
CacheService? cacheService, CollapseCache? collapseCache,
}) : _commentId = commentId, }) : _commentId = commentId,
_cacheService = cacheService ?? locator.get<CacheService>(), _collapseCache = collapseCache ?? locator.get<CollapseCache>(),
super(const CollapseState.init()); super(const CollapseState.init());
final int _commentId; final int _commentId;
final CacheService _cacheService; final CollapseCache _collapseCache;
late final StreamSubscription<Map<int, Set<int>>> _streamSubscription; late final StreamSubscription<Map<int, Set<int>>> _streamSubscription;
void init() { void init() {
_streamSubscription = _streamSubscription =
_cacheService.hiddenComments.listen(hiddenCommentsStreamListener); _collapseCache.hiddenComments.listen(hiddenCommentsStreamListener);
emit( emit(
state.copyWith( state.copyWith(
collapsedCount: _cacheService.totalHidden(_commentId), collapsedCount: _collapseCache.totalHidden(_commentId),
collapsed: _cacheService.isCollapsed(_commentId), collapsed: _collapseCache.isCollapsed(_commentId),
hidden: _cacheService.isHidden(_commentId), hidden: _collapseCache.isHidden(_commentId),
), ),
); );
} }
void collapse() { void collapse() {
if (state.collapsed) { if (state.collapsed) {
_cacheService.uncollapse(_commentId); _collapseCache.uncollapse(_commentId);
emit( emit(
state.copyWith( state.copyWith(
@ -43,7 +43,7 @@ class CollapseCubit extends Cubit<CollapseState> {
), ),
); );
} else { } else {
final int count = _cacheService.collapse(_commentId); final int count = _collapseCache.collapse(_commentId);
emit( emit(
state.copyWith( state.copyWith(

View File

@ -11,31 +11,56 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
part 'comments_state.dart'; part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
CacheService? cacheService, required CollapseCache collapseCache,
CacheRepository? cacheRepository, CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading, required bool offlineReading,
required Item item, required Item item,
}) : _cacheService = cacheService ?? locator.get<CacheService>(), required FetchMode defaultFetchMode,
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(), required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(CommentsState.init(offlineReading: offlineReading, item: item)); _logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
offlineReading: offlineReading,
item: item,
fetchMode: defaultFetchMode,
order: defaultCommentsOrder,
),
);
final CacheService _cacheService; final CollapseCache _collapseCache;
final CacheRepository _cacheRepository; final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final Logger _logger;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
/// The map of [StreamSubscription] for streams
/// fetching comments lazily. [int] is the id of parent comment.
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _pageSize = 20; static const int _pageSize = 20;
@override @override
@ -47,6 +72,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({ Future<void> init({
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetParents, List<Comment>? targetParents,
}) async { }) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
@ -59,7 +85,7 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchCommentsStream( .fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids, ids: targetParents!.last.kids,
level: targetParents.last.level + 1, level: targetParents.last.level + 1,
) )
@ -69,7 +95,13 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
emit(state.copyWith(status: CommentsStatus.loading)); emit(
state.copyWith(
status: CommentsStatus.loading,
comments: <Comment>[],
currentPage: 0,
),
);
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.offlineReading final Item updatedItem = state.offlineReading
@ -80,15 +112,31 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(state.copyWith(item: updatedItem)); emit(state.copyWith(item: updatedItem));
if (state.offlineReading) { if (state.offlineReading) {
_streamSubscription = _cacheRepository _streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: kids) .getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} else { } else {
_streamSubscription = _storiesRepository switch (state.fetchMode) {
.fetchCommentsStream(ids: kids) case FetchMode.lazy:
.listen(_onCommentFetched) _streamSubscription = _storiesRepository
..onDone(_onDone); .fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
}
} }
} }
@ -102,28 +150,47 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
_cacheService
..resetComments()
..resetCollapsedComments();
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.loading,
comments: <Comment>[],
), ),
); );
_collapseCache.resetCollapsedComments();
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
for (final int id in _streamSubscriptions.keys) {
await _streamSubscriptions[id]?.cancel();
}
_streamSubscriptions.clear();
emit(
state.copyWith(
comments: <Comment>[],
currentPage: 0,
),
);
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItemBy(id: item.id) ?? item; await _storiesRepository.fetchItemBy(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids); final List<int> kids = sortKids(updatedItem.kids);
_streamSubscription = _storiesRepository if (state.fetchMode == FetchMode.lazy) {
.fetchCommentsStream(ids: kids) _streamSubscription = _storiesRepository
.listen(_onCommentFetched) .fetchCommentsStream(
..onDone(_onDone); ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
}
emit( emit(
state.copyWith( state.copyWith(
@ -138,17 +205,67 @@ class CommentsCubit extends Cubit<CommentsState> {
emit( emit(
state.copyWith( state.copyWith(
onlyShowTargetComment: false, onlyShowTargetComment: false,
comments: <Comment>[],
item: story, item: story,
), ),
); );
init(); init();
} }
void loadMore() { /// [comment] is only used for lazy fetching.
if (_streamSubscription != null) { void loadMore({Comment? comment}) {
emit(state.copyWith(status: CommentsStatus.loading)); switch (state.fetchMode) {
_streamSubscription?.resume(); case FetchMode.lazy:
if (comment == null) return;
if (_streamSubscriptions.containsKey(comment.id)) return;
final int level = comment.level + 1;
int offset = 0;
/// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final List<LinkifyElement> elements = _linkify(
cmt.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(cmt, elements: elements);
emit(
state.copyWith(
comments: <Comment>[...state.comments]..insert(
state.comments.indexOf(comment) + offset + 1,
buildableComment.copyWith(level: level),
),
),
);
offset++;
})
..onDone(() {
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
})
..onError((dynamic error) {
_logger.e(error);
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
}
break;
} }
} }
@ -175,11 +292,30 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
void onOrderChanged(CommentsOrder? order) { void onOrderChanged(CommentsOrder? order) {
HapticFeedback.selectionClick();
if (order == null) return; if (order == null) return;
if (state.order == order) return;
HapticFeedback.selectionClick();
_streamSubscription?.cancel(); _streamSubscription?.cancel();
emit(state.copyWith(order: order, comments: <Comment>[])); for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
init(); s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(order: order));
init(useCommentCache: true);
}
void onFetchModeChanged(FetchMode? fetchMode) {
if (fetchMode == null) return;
if (state.fetchMode == fetchMode) return;
_collapseCache.resetCollapsedComments();
HapticFeedback.selectionClick();
_streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(fetchMode: fetchMode));
init(useCommentCache: true);
} }
List<int> sortKids(List<int> kids) { List<int> sortKids(List<int> kids) {
@ -205,9 +341,8 @@ class CommentsCubit extends Cubit<CommentsState> {
void _onCommentFetched(Comment? comment) { void _onCommentFetched(Comment? comment) {
if (comment != null) { if (comment != null) {
_cacheService _collapseCache.addKid(comment.id, to: comment.parent);
..addKid(comment.id, to: comment.parent) _commentCache.cacheComment(comment);
..cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = _linkify( final List<LinkifyElement> elements = _linkify(
@ -224,21 +359,24 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage && if (state.fetchMode == FetchMode.eager) {
updatedComments.length <= if (updatedComments.length >=
_pageSize * 2 + _pageSize * state.currentPage) { _pageSize + _pageSize * state.currentPage &&
final bool isHidden = _cacheService.isHidden(comment.id); updatedComments.length <=
_pageSize * 2 + _pageSize * state.currentPage) {
final bool isHidden = _collapseCache.isHidden(comment.id);
if (!isHidden) { if (!isHidden) {
_streamSubscription?.pause(); _streamSubscription?.pause();
}
emit(
state.copyWith(
currentPage: state.currentPage + 1,
status: CommentsStatus.loaded,
),
);
} }
emit(
state.copyWith(
currentPage: state.currentPage + 1,
status: CommentsStatus.loaded,
),
);
} }
} }
} }
@ -271,6 +409,9 @@ class CommentsCubit extends Cubit<CommentsState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
await s.cancel();
}
await super.close(); await super.close();
} }
} }

View File

@ -14,6 +14,11 @@ enum CommentsOrder {
oldestFirst, oldestFirst,
} }
enum FetchMode {
lazy,
eager,
}
class CommentsState extends Equatable { class CommentsState extends Equatable {
const CommentsState({ const CommentsState({
required this.item, required this.item,
@ -21,6 +26,7 @@ class CommentsState extends Equatable {
required this.status, required this.status,
required this.fetchParentStatus, required this.fetchParentStatus,
required this.order, required this.order,
required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.offlineReading,
required this.currentPage, required this.currentPage,
@ -29,10 +35,11 @@ class CommentsState extends Equatable {
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.offlineReading,
required this.item, required this.item,
required this.fetchMode,
required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
status = CommentsStatus.init, status = CommentsStatus.init,
fetchParentStatus = CommentsStatus.init, fetchParentStatus = CommentsStatus.init,
order = CommentsOrder.natural,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0; currentPage = 0;
@ -41,6 +48,7 @@ class CommentsState extends Equatable {
final CommentsStatus status; final CommentsStatus status;
final CommentsStatus fetchParentStatus; final CommentsStatus fetchParentStatus;
final CommentsOrder order; final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool offlineReading;
final int currentPage; final int currentPage;
@ -51,6 +59,7 @@ class CommentsState extends Equatable {
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus, CommentsStatus? fetchParentStatus,
CommentsOrder? order, CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? offlineReading,
int? currentPage, int? currentPage,
@ -61,6 +70,7 @@ class CommentsState extends Equatable {
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus, fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
status: status ?? this.status, status: status ?? this.status,
order: order ?? this.order, order: order ?? this.order,
fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, offlineReading: offlineReading ?? this.offlineReading,
@ -68,6 +78,8 @@ class CommentsState extends Equatable {
); );
} }
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,
@ -75,6 +87,7 @@ class CommentsState extends Equatable {
status, status,
fetchParentStatus, fetchParentStatus,
order, order,
fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, offlineReading,
currentPage, currentPage,

View File

@ -8,19 +8,19 @@ import 'package:hacki/utils/debouncer.dart';
part 'edit_state.dart'; part 'edit_state.dart';
class EditCubit extends Cubit<EditState> { class EditCubit extends Cubit<EditState> {
EditCubit({CacheService? cacheService}) EditCubit({DraftCache? draftCache})
: _cacheService = cacheService ?? locator.get<CacheService>(), : _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: const Duration(seconds: 1)), _debouncer = Debouncer(delay: const Duration(seconds: 1)),
super(const EditState.init()); super(const EditState.init());
final CacheService _cacheService; final DraftCache _draftCache;
final Debouncer _debouncer; final Debouncer _debouncer;
void onReplyTapped(Item item) { void onReplyTapped(Item item) {
emit( emit(
EditState( EditState(
replyingTo: item, replyingTo: item,
text: _cacheService.getDraft(replyingTo: item.id), text: _draftCache.getDraft(replyingTo: item.id),
), ),
); );
} }
@ -44,7 +44,7 @@ class EditCubit extends Cubit<EditState> {
void onReplySubmittedSuccessfully() { void onReplySubmittedSuccessfully() {
if (state.replyingTo != null) { if (state.replyingTo != null) {
_cacheService.removeDraft(replyingTo: state.replyingTo!.id); _draftCache.removeDraft(replyingTo: state.replyingTo!.id);
} }
emit(const EditState.init()); emit(const EditState.init());
} }
@ -54,7 +54,7 @@ class EditCubit extends Cubit<EditState> {
if (state.replyingTo != null) { if (state.replyingTo != null) {
final int? id = state.replyingTo?.id; final int? id = state.replyingTo?.id;
_debouncer.run(() { _debouncer.run(() {
_cacheService.cacheDraft( _draftCache.cacheDraft(
text: text, text: text,
replyingTo: id!, replyingTo: id!,
); );

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.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/comments/comments_cubit.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
part 'preference_state.dart'; part 'preference_state.dart';
@ -33,6 +34,10 @@ class PreferenceCubit extends Cubit<PreferenceState> {
.then((bool value) => emit(state.copyWith(markReadStories: value))); .then((bool value) => emit(state.copyWith(markReadStories: value)));
_preferenceRepository.shouldShowMetadata _preferenceRepository.shouldShowMetadata
.then((bool value) => emit(state.copyWith(showMetadata: value))); .then((bool value) => emit(state.copyWith(showMetadata: value)));
_preferenceRepository.fetchMode
.then((FetchMode value) => emit(state.copyWith(fetchMode: value)));
_preferenceRepository.commentsOrder
.then((CommentsOrder value) => emit(state.copyWith(order: value)));
} }
void toggleNotificationMode() { void toggleNotificationMode() {
@ -74,4 +79,16 @@ class PreferenceCubit extends Cubit<PreferenceState> {
emit(state.copyWith(showMetadata: !state.showMetadata)); emit(state.copyWith(showMetadata: !state.showMetadata));
_preferenceRepository.toggleMetadataMode(); _preferenceRepository.toggleMetadataMode();
} }
void selectFetchMode(FetchMode? fetchMode) {
if (fetchMode == null || state.fetchMode == fetchMode) return;
emit(state.copyWith(fetchMode: fetchMode));
_preferenceRepository.selectFetchMode(fetchMode);
}
void selectCommentsOrder(CommentsOrder? order) {
if (order == null || state.order == order) return;
emit(state.copyWith(order: order));
_preferenceRepository.selectCommentsOrder(order);
}
} }

View File

@ -10,6 +10,8 @@ class PreferenceState extends Equatable {
required this.useReader, required this.useReader,
required this.markReadStories, required this.markReadStories,
required this.showMetadata, required this.showMetadata,
required this.fetchMode,
required this.order,
}); });
const PreferenceState.init() const PreferenceState.init()
@ -20,7 +22,9 @@ class PreferenceState extends Equatable {
useTrueDark = false, useTrueDark = false,
useReader = false, useReader = false,
markReadStories = false, markReadStories = false,
showMetadata = false; showMetadata = false,
fetchMode = FetchMode.eager,
order = CommentsOrder.natural;
final bool showNotification; final bool showNotification;
final bool showComplexStoryTile; final bool showComplexStoryTile;
@ -30,6 +34,8 @@ class PreferenceState extends Equatable {
final bool useReader; final bool useReader;
final bool markReadStories; final bool markReadStories;
final bool showMetadata; final bool showMetadata;
final FetchMode fetchMode;
final CommentsOrder order;
PreferenceState copyWith({ PreferenceState copyWith({
bool? showNotification, bool? showNotification,
@ -40,6 +46,8 @@ class PreferenceState extends Equatable {
bool? useReader, bool? useReader,
bool? markReadStories, bool? markReadStories,
bool? showMetadata, bool? showMetadata,
FetchMode? fetchMode,
CommentsOrder? order,
}) { }) {
return PreferenceState( return PreferenceState(
showNotification: showNotification ?? this.showNotification, showNotification: showNotification ?? this.showNotification,
@ -50,6 +58,8 @@ class PreferenceState extends Equatable {
useReader: useReader ?? this.useReader, useReader: useReader ?? this.useReader,
markReadStories: markReadStories ?? this.markReadStories, markReadStories: markReadStories ?? this.markReadStories,
showMetadata: showMetadata ?? this.showMetadata, showMetadata: showMetadata ?? this.showMetadata,
fetchMode: fetchMode ?? this.fetchMode,
order: order ?? this.order,
); );
} }
@ -63,5 +73,7 @@ class PreferenceState extends Equatable {
useReader, useReader,
markReadStories, markReadStories,
showMetadata, showMetadata,
fetchMode,
order,
]; ];
} }

View File

@ -3,18 +3,24 @@ import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
part 'split_view_state.dart'; part 'split_view_state.dart';
class SplitViewCubit extends Cubit<SplitViewState> { class SplitViewCubit extends Cubit<SplitViewState> {
SplitViewCubit({CacheService? cacheService}) SplitViewCubit({
: _cacheService = cacheService ?? locator.get<CacheService>(), CommentCache? commentCache,
Logger? logger,
}) : _commentCache = commentCache ?? locator.get<CommentCache>(),
_logger = logger ?? locator.get<Logger>(),
super(const SplitViewState.init()); super(const SplitViewState.init());
final CacheService _cacheService; final Logger _logger;
final CommentCache _commentCache;
void updateItemScreenArgs(ItemScreenArgs args) { void updateItemScreenArgs(ItemScreenArgs args) {
_cacheService.resetCollapsedComments(); _logger.i('resetting comments in CommentCache');
_commentCache.resetComments();
emit(state.copyWith(itemScreenArgs: args)); emit(state.copyWith(itemScreenArgs: args));
} }

View File

@ -3,34 +3,34 @@ import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart' show Comment; import 'package:hacki/models/models.dart' show Comment;
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/cache_service.dart'; import 'package:hacki/services/services.dart';
part 'time_machine_state.dart'; part 'time_machine_state.dart';
class TimeMachineCubit extends Cubit<TimeMachineState> { class TimeMachineCubit extends Cubit<TimeMachineState> {
TimeMachineCubit({ TimeMachineCubit({
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
CacheService? cacheService, CommentCache? commentCache,
}) : _sembastRepository = }) : _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_cacheService = cacheService ?? locator.get<CacheService>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
super(TimeMachineState.init()); super(TimeMachineState.init());
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final CacheService _cacheService; final CommentCache _commentCache;
Future<void> activateTimeMachine(Comment comment) async { Future<void> activateTimeMachine(Comment comment) async {
emit(state.copyWith(parents: <Comment>[])); emit(state.copyWith(parents: <Comment>[]));
final List<Comment> parents = <Comment>[]; final List<Comment> parents = <Comment>[];
Comment? parent = _cacheService.getComment(comment.parent); Comment? parent = _commentCache.getComment(comment.parent);
parent ??= await _sembastRepository.getCachedComment(id: comment.parent); parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
while (parent != null) { while (parent != null) {
parents.insert(0, parent); parents.insert(0, parent);
final int parentId = parent.parent; final int parentId = parent.parent;
parent = _cacheService.getComment(parentId); parent = _commentCache.getComment(parentId);
parent ??= await _sembastRepository.getCachedComment(id: parentId); parent ??= await _sembastRepository.getCachedComment(id: parentId);
} }

View File

@ -0,0 +1,58 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
extension TryReadContext on BuildContext {
T? tryRead<T>() {
try {
return read<T>();
} catch (_) {
return null;
}
}
Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?;
final Rect? rect =
box == null ? null : box.localToGlobal(Offset.zero) & box.size;
return rect;
}
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
static const double _screenWidthLowerBound = 428,
_screenWidthUpperBound = 850,
_picHeightLowerBound = 110,
_picHeightUpperBound = 128,
_smallPicHeight = 100,
_picHeightFactor = 0.3;
double get storyTileHeight {
final double screenWidth =
min(MediaQuery.of(this).size.height, MediaQuery.of(this).size.width);
if (screenWidth == _screenWidth) {
return _storyTileHeight;
} else {
_screenWidth = screenWidth;
}
final bool showSmallerPreviewPic = screenWidth > _screenWidthLowerBound &&
screenWidth < _screenWidthUpperBound;
final double height = showSmallerPreviewPic
? _smallPicHeight
: (screenWidth * _picHeightFactor)
.clamp(_picHeightLowerBound, _picHeightUpperBound);
final int maxLines = height == _smallPicHeight ? 3 : 4;
_storyTileMaxLines = maxLines;
_storyTileHeight = height;
return height;
}
int get storyTileMaxLines {
return _storyTileMaxLines;
}
}

View File

@ -1,3 +1,4 @@
export 'context_extension.dart';
export 'date_time_extension.dart'; export 'date_time_extension.dart';
export 'int_extension.dart'; export 'int_extension.dart';
export 'list_extension.dart'; export 'list_extension.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension WidgetModifier on Widget { extension WidgetModifier on Widget {
Widget padding([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) { Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
return Padding( return Padding(
padding: value, padding: value,
child: this, child: this,

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -14,6 +15,7 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/repositories/repositories.dart' show PreferenceRepository; import 'package:hacki/repositories/repositories.dart' show PreferenceRepository;
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/custom_bloc_observer.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:hive/hive.dart'; import 'package:hive/hive.dart';
@ -30,9 +32,13 @@ final BehaviorSubject<String?> selectNotificationSubject =
final BehaviorSubject<String?> siriSuggestionSubject = final BehaviorSubject<String?> siriSuggestionSubject =
BehaviorSubject<String?>(); BehaviorSubject<String?>();
Future<void> main() async { late final bool isTesting;
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
isTesting = testing;
if (Platform.isIOS) { if (Platform.isIOS) {
unawaited( unawaited(
Workmanager().initialize( Workmanager().initialize(
@ -86,25 +92,26 @@ Future<void> main() async {
final bool trueDarkMode = final bool trueDarkMode =
prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false; prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false;
// Uncomment code below for running with logging. if (kReleaseMode) {
// BlocOverrides.runZoned( runApp(
// () { HackiApp(
// runApp( savedThemeMode: savedThemeMode,
// HackiApp( trueDarkMode: trueDarkMode,
// savedThemeMode: savedThemeMode, ),
// trueDarkMode: trueDarkMode, );
// ), } else {
// ); BlocOverrides.runZoned(
// }, () {
// blocObserver: CustomBlocObserver(), runApp(
// ); HackiApp(
savedThemeMode: savedThemeMode,
runApp( trueDarkMode: trueDarkMode,
HackiApp( ),
savedThemeMode: savedThemeMode, );
trueDarkMode: trueDarkMode, },
), blocObserver: CustomBlocObserver(),
); );
}
} }
class HackiApp extends StatelessWidget { class HackiApp extends StatelessWidget {

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
enum StoryType { enum StoryType {
@ -113,9 +111,10 @@ class Story extends Item {
@override @override
String toString() { String toString() {
final String prettyString = // final String prettyString =
const JsonEncoder.withIndent(' ').convert(this); // const JsonEncoder.withIndent(' ').convert(this);
return 'Story $prettyString'; // return 'Story $prettyString';
return 'Story $id';
} }
@override @override

View File

@ -5,16 +5,20 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/postable_repository.dart'; import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
class AuthRepository extends PostableRepository { class AuthRepository extends PostableRepository {
AuthRepository({ AuthRepository({
Dio? dio, Dio? dio,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository = }) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(dio: dio); super(dio: dio);
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger;
static const String _authority = 'news.ycombinator.com'; static const String _authority = 'news.ycombinator.com';
@ -44,6 +48,7 @@ class AuthRepository extends PostableRepository {
password: password, password: password,
); );
} catch (_) { } catch (_) {
_logger.e(_);
return false; return false;
} }
} }

View File

@ -1,167 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
/// [CacheRepository] is for storing stories and comments for offline reading.
/// It's using [Hive] as its database which is being stored in temp directory.
class CacheRepository {
CacheRepository({
Future<Box<List<int>>>? storyIdBox,
Future<Box<Map<dynamic, dynamic>>>? storyBox,
Future<Box<String>>? webPageBox,
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
}) : _storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_webPageBox = webPageBox ?? Hive.openBox<String>(_webPageBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
static const String _storyIdBoxName = 'storyIdBox';
static const String _storyBoxName = 'storyBox';
static const String _commentBoxName = 'commentBox';
static const String _webPageBoxName = 'webPageBox';
final Future<Box<List<int>>> _storyIdBox;
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
final Future<Box<String>> _webPageBox;
Future<bool> get hasCachedStories =>
_storyBox.then((Box<Map<dynamic, dynamic>> box) => box.isNotEmpty);
Future<void> cacheStoryIds({
required StoryType of,
required List<int> ids,
}) async {
final Box<List<int>> box = await _storyIdBox;
return box.put(of.name, ids);
}
Future<void> cacheStory({required Story story}) async {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
return box.put(story.id.toString(), story.toJson());
}
Future<void> cacheUrl({required String url}) async {
final Box<String> box = await _webPageBox;
final String html = await compute(downloadWebPage, url);
return box.put(url, html);
}
Future<String?> getHtml({required String url}) async {
final Box<String> box = await _webPageBox;
return box.get(url);
}
Future<bool> hasCachedWebPage({required String url}) async {
final Box<String> box = await _webPageBox;
return box.containsKey(url);
}
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
final Box<List<int>> box = await _storyIdBox;
final List<int>? ids = box.get(of.name);
return ids ?? <int>[];
}
Stream<Story> getCachedStoriesStream({required List<int> ids}) async* {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
for (final int id in ids) {
final Map<dynamic, dynamic>? json = box.get(id.toString());
if (json == null) {
continue;
}
final Story story = Story.fromJson(json.cast<String, dynamic>());
yield story;
}
return;
}
Future<Story?> getCachedStory({required int id}) async {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
final Map<dynamic, dynamic>? json = box.get(id.toString());
if (json == null) {
return null;
}
final Story story = Story.fromJson(json.cast<String, dynamic>());
return story;
}
Future<void> cacheComment({required Comment comment}) async {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
return box.put(comment.id.toString(), comment.toJson());
}
Future<Comment?> getCachedComment({required int id}) async {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json == null) {
return null;
}
final Comment comment = Comment.fromJson(json.cast<String, dynamic>());
return comment;
}
Stream<Comment> getCachedCommentsStream({
required List<int> ids,
int level = 0,
}) async* {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
for (final int id in ids) {
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) {
final Comment comment =
Comment.fromJson(json.cast<String, dynamic>(), level: level);
yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);
}
}
}
Future<int> deleteAllStoryIds() async {
final Box<List<int>> box = await _storyIdBox;
return box.clear();
}
Future<int> deleteAllStories() async {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
return box.clear();
}
Future<int> deleteAllComments() async {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
return box.clear();
}
Future<int> deleteAllWebPages() async {
final Box<String> box = await _webPageBox;
return box.clear();
}
Future<int> deleteAll() async {
return deleteAllStoryIds()
.whenComplete(deleteAllStories)
.whenComplete(deleteAllComments)
.whenComplete(deleteAllWebPages);
}
static Future<String> downloadWebPage(String link) async {
try {
final Client client = Client();
final Uri url = Uri.parse(link);
final Response response = await client.get(url);
final String body = response.body;
return body;
} catch (_) {
return '''Web page not available.''';
}
}
}

View File

@ -0,0 +1,274 @@
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
import 'package:logger/logger.dart';
/// [OfflineRepository] is for storing stories and comments for offline reading.
/// It's using [Hive] as its database which is being stored in temp directory.
class OfflineRepository {
OfflineRepository({
Future<Box<List<int>>>? storyIdBox,
Future<Box<Map<dynamic, dynamic>>>? storyBox,
Future<LazyBox<String>>? webPageBox,
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
Logger? logger,
}) : _storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_webPageBox = webPageBox ?? Hive.openLazyBox<String>(_webPageBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName),
_logger = logger ?? locator.get<Logger>();
static const String _storyIdBoxName = 'storyIdBox';
static const String _storyBoxName = 'storyBox';
static const String _commentBoxName = 'commentBox';
static const String _webPageBoxName = 'webPageBox';
final Future<Box<List<int>>> _storyIdBox;
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
final Future<LazyBox<String>> _webPageBox;
final Logger _logger;
Future<bool> get hasCachedStories =>
_storyBox.then((Box<Map<dynamic, dynamic>> box) => box.isNotEmpty);
Future<void> cacheStoryIds({
required StoryType of,
required List<int> ids,
}) async {
late final Box<List<int>> box;
try {
box = await _storyIdBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
box = await _storyIdBox;
}
return box.put(of.name, ids);
}
Future<void> cacheStory({required Story story}) async {
late final Box<Map<dynamic, dynamic>> box;
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyBoxName);
box = await _storyBox;
}
return box.put(story.id.toString(), story.toJson());
}
Future<void> cacheUrl({required String url}) async {
late final LazyBox<String> box;
try {
box = await _webPageBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_webPageBoxName);
box = await _webPageBox;
}
final String html = await compute(_downloadWebPage, url);
return box.put(url, html);
}
Future<String?> getHtml({required String url}) async {
try {
final LazyBox<String> box = await _webPageBox;
return box.get(url);
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return null;
}
}
Future<bool> hasCachedWebPage({required String url}) async {
try {
final LazyBox<String> box = await _webPageBox;
return box.containsKey(url);
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return false;
}
}
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
try {
final Box<List<int>> box = await _storyIdBox;
final List<int>? ids = box.get(of.name);
return ids ?? <int>[];
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
return <int>[];
}
}
Stream<Story> getCachedStoriesStream({required List<int> ids}) async* {
late final Box<Map<dynamic, dynamic>> box;
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyBoxName);
return;
}
for (final int id in ids) {
final Map<dynamic, dynamic>? json = box.get(id.toString());
if (json == null) {
continue;
}
final Story story = Story.fromJson(json.cast<String, dynamic>());
yield story;
}
return;
}
Future<Story?> getCachedStory({required int id}) async {
late final Box<Map<dynamic, dynamic>> box;
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyBoxName);
return null;
}
final Map<dynamic, dynamic>? json = box.get(id.toString());
if (json == null) {
return null;
}
final Story story = Story.fromJson(json.cast<String, dynamic>());
return story;
}
Future<void> cacheComment({required Comment comment}) async {
late final LazyBox<Map<dynamic, dynamic>> box;
try {
box = await _commentBox;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_commentBoxName);
box = await _commentBox;
}
return box.put(comment.id.toString(), comment.toJson());
}
Future<Comment?> getCachedComment({required int id}) async {
try {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json == null) {
return null;
}
final Comment comment = Comment.fromJson(json.cast<String, dynamic>());
return comment;
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_commentBoxName);
return null;
}
}
Stream<Comment> getCachedCommentsStream({
required List<int> ids,
int level = 0,
}) async* {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
for (final int id in ids) {
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) {
final Comment comment =
Comment.fromJson(json.cast<String, dynamic>(), level: level);
yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);
}
}
}
Future<int> deleteAllStoryIds() async {
try {
final Box<List<int>> box = await _storyIdBox;
return box.clear();
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
return 0;
}
}
Future<int> deleteAllStories() async {
try {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
return box.clear();
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_storyBoxName);
return 0;
}
}
Future<int> deleteAllComments() async {
try {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
return box.clear();
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_commentBoxName);
return 0;
}
}
Future<int> deleteAllWebPages() async {
try {
final LazyBox<String> box = await _webPageBox;
return box.clear();
} catch (_) {
_logger.e(_);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return 0;
}
}
Future<int> deleteAll() async {
return deleteAllStoryIds()
.whenComplete(deleteAllStories)
.whenComplete(deleteAllComments)
.whenComplete(deleteAllWebPages);
}
static Future<String> _downloadWebPage(String link) async {
try {
final Client client = Client();
final Uri url = Uri.parse(link);
final Response response = await client.get(url);
final String body = response.body;
client.close();
return body;
} catch (_) {
return '''Web page not available.''';
}
}
}

View File

@ -2,6 +2,9 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart'; import 'package:synced_shared_preferences/synced_shared_preferences.dart';
@ -10,9 +13,11 @@ class PreferenceRepository {
SyncedSharedPreferences? syncedPrefs, SyncedSharedPreferences? syncedPrefs,
Future<SharedPreferences>? prefs, Future<SharedPreferences>? prefs,
FlutterSecureStorage? secureStorage, FlutterSecureStorage? secureStorage,
Logger? logger,
}) : _syncedPrefs = syncedPrefs ?? SyncedSharedPreferences.instance, }) : _syncedPrefs = syncedPrefs ?? SyncedSharedPreferences.instance,
_prefs = prefs ?? SharedPreferences.getInstance(), _prefs = prefs ?? SharedPreferences.getInstance(),
_secureStorage = secureStorage ?? const FlutterSecureStorage(); _secureStorage = secureStorage ?? const FlutterSecureStorage(),
_logger = logger ?? locator.get<Logger>();
static const String _usernameKey = 'username'; static const String _usernameKey = 'username';
static const String _passwordKey = 'password'; static const String _passwordKey = 'password';
@ -38,20 +43,26 @@ class PreferenceRepository {
static const String _navigationModeKey = 'navigationMode'; static const String _navigationModeKey = 'navigationMode';
static const String _eyeCandyModeKey = 'eyeCandyMode'; static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _markReadStoriesModeKey = 'markReadStoriesMode'; static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const String _fetchModeKey = 'fetchMode';
static const String _commentsOrderKey = 'commentsOrder';
static const bool _notificationModeDefaultValue = true; static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true; static const bool _displayModeDefaultValue = true;
static const bool _navigationModeDefaultValue = true; static const bool _navigationModeDefaultValueIOS = true;
static const bool _navigationModeDefaultValueAndroid = false;
static const bool _eyeCandyModeDefaultValue = false; static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = false; static const bool _trueDarkModeDefaultValue = false;
static const bool _readerModeDefaultValue = true; static const bool _readerModeDefaultValue = true;
static const bool _markReadStoriesModeDefaultValue = true; static const bool _markReadStoriesModeDefaultValue = true;
static const bool _isFirstLaunchKeyDefaultValue = true; static const bool _isFirstLaunchKeyDefaultValue = true;
static const bool _metadataModeDefaultValue = true; static const bool _metadataModeDefaultValue = true;
static final int _fetchModeDefaultValue = FetchMode.eager.index;
static final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final SyncedSharedPreferences _syncedPrefs; final SyncedSharedPreferences _syncedPrefs;
final Future<SharedPreferences> _prefs; final Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage; final FlutterSecureStorage _secureStorage;
final Logger _logger;
Future<bool> get loggedIn async => await username != null; Future<bool> get loggedIn async => await username != null;
@ -82,7 +93,10 @@ class PreferenceRepository {
Future<bool> get shouldShowWebFirst async => _prefs.then( Future<bool> get shouldShowWebFirst async => _prefs.then(
(SharedPreferences prefs) => (SharedPreferences prefs) =>
prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue, prefs.getBool(_navigationModeKey) ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
); );
Future<bool> get shouldShowEyeCandy async => _prefs.then( Future<bool> get shouldShowEyeCandy async => _prefs.then(
@ -111,6 +125,17 @@ class PreferenceRepository {
_markReadStoriesModeDefaultValue, _markReadStoriesModeDefaultValue,
); );
Future<FetchMode> get fetchMode async => _prefs.then(
(SharedPreferences prefs) => FetchMode.values
.elementAt(prefs.getInt(_fetchModeKey) ?? _fetchModeDefaultValue),
);
Future<CommentsOrder> get commentsOrder async => _prefs.then(
(SharedPreferences prefs) => CommentsOrder.values.elementAt(
prefs.getInt(_commentsOrderKey) ?? _commentsOrderDefaultValue,
),
);
Future<bool> hasPushed(int commentId) async => Future<bool> hasPushed(int commentId) async =>
_prefs.then((SharedPreferences prefs) { _prefs.then((SharedPreferences prefs) {
final bool? val = prefs.getBool(_getPushNotificationKey(commentId)); final bool? val = prefs.getBool(_getPushNotificationKey(commentId));
@ -153,9 +178,13 @@ class PreferenceRepository {
aOptions: androidOptions, aOptions: androidOptions,
); );
} catch (_) { } catch (_) {
await _secureStorage.deleteAll( try {
aOptions: androidOptions, await _secureStorage.deleteAll(
); aOptions: androidOptions,
);
} catch (_) {
_logger.e(_);
}
rethrow; rethrow;
} }
@ -182,8 +211,10 @@ class PreferenceRepository {
Future<void> toggleNavigationMode() async { Future<void> toggleNavigationMode() async {
final SharedPreferences prefs = await _prefs; final SharedPreferences prefs = await _prefs;
final bool currentMode = final bool currentMode = prefs.getBool(_navigationModeKey) ??
prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue; (Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS);
await prefs.setBool(_navigationModeKey, !currentMode); await prefs.setBool(_navigationModeKey, !currentMode);
} }
@ -222,6 +253,18 @@ class PreferenceRepository {
await prefs.setBool(_metadataModeKey, !currentMode); await prefs.setBool(_metadataModeKey, !currentMode);
} }
Future<void> selectFetchMode(FetchMode fetchMode) async {
final SharedPreferences prefs = await _prefs;
final int index = fetchMode.index;
await prefs.setInt(_fetchModeKey, index);
}
Future<void> selectCommentsOrder(CommentsOrder order) async {
final SharedPreferences prefs = await _prefs;
final int index = order.index;
await prefs.setInt(_commentsOrderKey, index);
}
//#region fav //#region fav
Future<List<int>> favList({required String of}) async { Future<List<int>> favList({required String of}) async {

View File

@ -1,5 +1,5 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'cache_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';

View File

@ -51,9 +51,37 @@ class StoriesRepository {
Stream<Comment> fetchCommentsStream({ Stream<Comment> fetchCommentsStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
Comment? Function(int)? getFromCache,
}) async* { }) async* {
for (final int id in ids) { for (final int id in ids) {
final Comment? comment = await _firebaseClient Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
}
}
return;
}
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json') .get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?)) .then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async { .then((Map<String, dynamic>? json) async {
@ -66,9 +94,10 @@ class StoriesRepository {
if (comment != null) { if (comment != null) {
yield comment; yield comment;
yield* fetchCommentsStream( yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids, ids: comment.kids,
level: level + 1, level: level + 1,
getFromCache: getFromCache,
); );
} }
} }

View File

@ -26,6 +26,7 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart'; import 'package:responsive_builder/responsive_builder.dart';
@ -47,7 +48,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware { with SingleTickerProviderStateMixin, RouteAware {
final CacheService cacheService = locator.get<CacheService>();
final Throttle featureDiscoveryDismissThrottle = Throttle( final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay, delay: _throttleDelay,
); );
@ -66,9 +66,10 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext(); super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType == if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) { DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed( Future<void>.delayed(
const Duration(milliseconds: 500), const Duration(milliseconds: 500),
cacheService.resetCollapsedComments, locator.get<CommentCache>().resetComments,
); );
} }
} }
@ -102,12 +103,14 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures( if (!isTesting) {
context, FeatureDiscovery.discoverFeatures(
const <String>{ context,
Constants.featureLogIn, const <String>{
}, Constants.featureLogIn,
); },
);
}
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -449,7 +452,10 @@ class _HomeScreenState extends State<HomeScreen>
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) { locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) { if (mounted) {
if (item != null) { if (item != null) {
goToItemScreen(args: ItemScreenArgs(item: item)); goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
} }
} }
}); });

View File

@ -1,3 +1,5 @@
// ignore_for_file: comment_references
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -38,6 +40,7 @@ class ItemScreenArgs extends Equatable {
const ItemScreenArgs({ const ItemScreenArgs({
required this.item, required this.item,
this.onlyShowTargetComment = false, this.onlyShowTargetComment = false,
this.useCommentCache = false,
this.targetComments, this.targetComments,
}); });
@ -45,11 +48,17 @@ class ItemScreenArgs extends Equatable {
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final List<Comment>? targetComments; final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't
/// need to fetch comments from [StoryRepository] since we have some, if not
/// all, comments cached in [CommentCache].
final bool useCommentCache;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,
onlyShowTargetComment, onlyShowTargetComment,
targetComments, targetComments,
useCommentCache,
]; ];
} }
@ -66,25 +75,36 @@ class ItemScreen extends StatefulWidget {
static Route<dynamic> route(ItemScreenArgs args) { static Route<dynamic> route(ItemScreenArgs args) {
return MaterialPageRoute<ItemScreen>( return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName), settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => MultiBlocProvider( builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
providers: <BlocProvider<dynamic>>[ create: (BuildContext context) => CollapseCache(),
BlocProvider<CommentsCubit>( lazy: false,
create: (_) => CommentsCubit( child: MultiBlocProvider(
offlineReading: context.read<StoriesBloc>().state.offlineReading, providers: <BlocProvider<dynamic>>[
item: args.item, BlocProvider<CommentsCubit>(
)..init( create: (BuildContext context) => CommentsCubit(
onlyShowTargetComment: args.onlyShowTargetComment, offlineReading:
targetParents: args.targetComments, context.read<StoriesBloc>().state.offlineReading,
), item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
), ),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
), ),
), ),
); );
@ -100,27 +120,37 @@ class ItemScreen extends StatefulWidget {
return true; return true;
} }
}, },
child: MultiBlocProvider( child: RepositoryProvider<CollapseCache>(
key: ValueKey<ItemScreenArgs>(args), create: (BuildContext context) => CollapseCache(),
providers: <BlocProvider<dynamic>>[ lazy: false,
BlocProvider<CommentsCubit>( child: MultiBlocProvider(
create: (BuildContext context) => CommentsCubit( key: ValueKey<ItemScreenArgs>(args),
offlineReading: context.read<StoriesBloc>().state.offlineReading, providers: <BlocProvider<dynamic>>[
item: args.item, BlocProvider<CommentsCubit>(
)..init( create: (BuildContext context) => CommentsCubit(
onlyShowTargetComment: args.onlyShowTargetComment, offlineReading:
targetParents: args.targetComments, context.read<StoriesBloc>().state.offlineReading,
), item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
splitViewEnabled: true,
), ),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
splitViewEnabled: true,
), ),
), ),
); );
@ -160,14 +190,16 @@ class _ItemScreenState extends State<ItemScreen> {
super.initState(); super.initState();
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures( if (!isTesting) {
context, FeatureDiscovery.discoverFeatures(
const <String>{ context,
Constants.featurePinToTop, const <String>{
Constants.featureAddStoryToFavList, Constants.featurePinToTop,
Constants.featureOpenStoryInWebView, Constants.featureAddStoryToFavList,
}, Constants.featureOpenStoryInWebView,
); },
);
}
}); });
scrollController.addListener(() { scrollController.addListener(() {
@ -180,7 +212,6 @@ class _ItemScreenState extends State<ItemScreen> {
@override @override
void dispose() { void dispose() {
locator.get<CacheService>().resetComments();
refreshController.dispose(); refreshController.dispose();
commentEditingController.dispose(); commentEditingController.dispose();
scrollController.dispose(); scrollController.dispose();
@ -284,309 +315,370 @@ class _ItemScreenState extends State<ItemScreen> {
} }
} }
}, },
onLoading: context.read<CommentsCubit>().loadMore, onLoading: () {
child: ListView( if (state.fetchMode == FetchMode.eager) {
context.read<CommentsCubit>().loadMore();
} else {
refreshController.loadComplete();
}
},
child: ListView.builder(
primary: false, primary: false,
children: <Widget>[ itemCount: state.comments.length + 2,
SizedBox( itemBuilder: (BuildContext context, int index) {
height: topPadding, if (index == 0) {
), return Column(
if (!widget.splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[ children: <Widget>[
SlidableAction( SizedBox(
onPressed: (_) { height: topPadding,
HapticFeedback.lightImpact();
if (state.item.id !=
context
.read<EditCubit>()
.state
.replyingTo
?.id) {
commentEditingController.clear();
}
context
.read<EditCubit>()
.onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
), ),
SlidableAction( if (!widget.splitViewEnabled)
onPressed: (_) => onMoreTapped(state.item), const Padding(
backgroundColor: Palette.orange, padding: EdgeInsets.only(bottom: Dimens.pt6),
foregroundColor: Palette.white, child: OfflineBanner(),
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
), ),
child: Row( Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[ children: <Widget>[
Text( SlidableAction(
state.item.by, onPressed: (_) {
style: const TextStyle( HapticFeedback.lightImpact();
color: Palette.orange,
), if (state.item.id !=
context
.read<EditCubit>()
.state
.replyingTo
?.id) {
commentEditingController.clear();
}
context
.read<EditCubit>()
.onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
), ),
const Spacer(), SlidableAction(
Text( onPressed: (BuildContext context) =>
state.item.postedDate, onMoreTapped(state.item, context.rect),
style: const TextStyle( backgroundColor: Palette.orange,
color: Palette.grey, foregroundColor: Palette.white,
), icon: Icons.more_horiz,
), ),
], ],
), ),
), child: Column(
if (state.item is Story) children: <Widget>[
InkWell( Padding(
onTap: () => LinkUtil.launch( padding: const EdgeInsets.only(
state.item.url, left: Dimens.pt6,
useReader: context right: Dimens.pt6,
.read<PreferenceCubit>() ),
.state child: Row(
.useReader, children: <Widget>[
offlineReading: context Text(
.read<StoriesBloc>() state.item.by,
.state style: const TextStyle(
.offlineReading, color: Palette.orange,
), ),
child: Padding( ),
padding: const EdgeInsets.only( const Spacer(),
left: Dimens.pt6, Text(
right: Dimens.pt6, state.item.postedDate,
bottom: Dimens.pt12, style: const TextStyle(
top: Dimens.pt12, color: Palette.grey,
), ),
child: Text( ),
state.item.title, ],
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
), ),
), ),
), if (state.item is Story)
) InkWell(
else onTap: () => LinkUtil.launch(
const SizedBox( state.item.url,
height: Dimens.pt6, useReader: context
.read<PreferenceCubit>()
.state
.useReader,
offlineReading: context
.read<StoriesBloc>()
.state
.offlineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text(
state.item.title,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
TextDimens.pt15,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.contains(
'news.ycombinator.com/item',
)) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)
..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
),
],
), ),
),
if (state.item.text.isNotEmpty) if (state.item.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
style: TextStyle(
fontSize:
MediaQuery.of(context).textScaleFactor *
TextDimens.pt15,
),
linkStyle: TextStyle(
fontSize:
MediaQuery.of(context).textScaleFactor *
TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.contains(
'news.ycombinator.com/item',
)) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
),
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () => context
.read<CommentsCubit>()
.loadAll(state.item as Story),
child: const Text('View all comments'),
),
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox( const SizedBox(
width: Dimens.pt12, height: Dimens.pt8,
), ),
Text( const Divider(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''', height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () => context
.read<CommentsCubit>()
.loadAll(state.item as Story),
child: const Text('View all comments'),
),
),
const Divider(
height: Dimens.zero,
), ),
] else ...<Widget>[ ] else ...<Widget>[
const SizedBox( Row(
width: Dimens.pt4, children: <Widget>[
), if (state.item is Story) ...<Widget>[
TextButton( const SizedBox(
onPressed: context width: Dimens.pt12,
.read<CommentsCubit>() ),
.loadParentThread, Text(
child: state.fetchParentStatus == '''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
CommentsStatus.loading style: const TextStyle(
? const SizedBox( fontSize: TextDimens.pt12,
height: Dimens.pt12, ),
width: Dimens.pt12, ),
child: CustomCircularProgressIndicator( ] else ...<Widget>[
strokeWidth: Dimens.pt2, const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context
.read<CommentsCubit>()
.loadParentThread,
child: state.fetchParentStatus ==
CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child:
CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
),
],
const Spacer(),
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: const <DropdownMenuItem<FetchMode>>[
DropdownMenuItem<FetchMode>(
value: FetchMode.lazy,
child: Text(
'Lazy',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
), ),
) DropdownMenuItem<FetchMode>(
: const Text('View parent thread'), value: FetchMode.eager,
child: Text(
'Eager',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
),
],
onChanged: context
.read<CommentsCubit>()
.onFetchModeChanged,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: const <
DropdownMenuItem<CommentsOrder>>[
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.natural,
child: Text(
'Natural',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.newestFirst,
child: Text(
'Newest first',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.oldestFirst,
child: Text(
'Oldest first',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
),
],
onChanged: context
.read<CommentsCubit>()
.onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
), ),
], ],
const Spacer(), if (state.comments.isEmpty &&
DropdownButton<CommentsOrder>( state.status ==
value: state.order, CommentsStatus.allLoaded) ...<Widget>[
underline: const SizedBox.shrink(), const SizedBox(
items: const <DropdownMenuItem<CommentsOrder>>[ height: 240,
DropdownMenuItem<CommentsOrder>( ),
value: CommentsOrder.natural, const Center(
child: Text( child: Text(
'Natural', 'Nothing yet',
style: TextStyle( style: TextStyle(color: Palette.grey),
fontSize: TextDimens.pt14,
),
),
), ),
DropdownMenuItem<CommentsOrder>( ),
value: CommentsOrder.newestFirst, ],
child: Text(
'Newest first',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.oldestFirst,
child: Text(
'Oldest first',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
),
],
onChanged:
context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
], ],
), );
const Divider( } else if (index == state.comments.length + 1) {
height: Dimens.zero, if ((state.status == CommentsStatus.allLoaded &&
), state.comments.isNotEmpty) ||
], state.onlyShowTargetComment) {
if (state.comments.isEmpty && return SizedBox(
state.status == CommentsStatus.allLoaded) ...<Widget>[ height: 240,
const SizedBox( child: Center(
height: 240, child: Text(happyFace),
), ),
const Center( );
child: Text( } else {
'Nothing yet', return const SizedBox.shrink();
style: TextStyle(color: Palette.grey), }
), }
),
],
for (final Comment comment in state.comments)
FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
level: comment.level,
myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
onReplyTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt.id != index = index - 1;
context final Comment comment = state.comments.elementAt(index);
.read<EditCubit>() return FadeIn(
.state key: ValueKey<String>('${comment.id}-FadeIn'),
.replyingTo child: CommentTile(
?.id) { comment: comment,
commentEditingController.clear(); level: comment.level,
} myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
context.read<EditCubit>().onReplyTapped(cmt); if (cmt.id !=
focusNode.requestFocus(); context.read<EditCubit>().state.replyingTo?.id) {
},
onEditTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear(); commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt); }
focusNode.requestFocus();
}, context.read<EditCubit>().onReplyTapped(cmt);
onMoreTapped: onMoreTapped, focusNode.requestFocus();
onStoryLinkTapped: onStoryLinkTapped, },
onRightMoreTapped: onRightMoreTapped, onEditTapped: (Comment cmt) {
), HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
), ),
if ((state.status == CommentsStatus.allLoaded && );
state.comments.isNotEmpty) || },
state.onlyShowTargetComment)
SizedBox(
height: 240,
child: Center(
child: Text(happyFace),
),
)
],
), ),
); );
@ -742,7 +834,10 @@ class _ItemScreenState extends State<ItemScreen> {
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
goToItemScreen( goToItemScreen(
args: ItemScreenArgs(item: comment), args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true, forceNewScreen: true,
); );
}, },
@ -814,6 +909,7 @@ class _ItemScreenState extends State<ItemScreen> {
context.read<AuthBloc>().state.username, context.read<AuthBloc>().state.username,
onStoryLinkTapped: onStoryLinkTapped, onStoryLinkTapped: onStoryLinkTapped,
actionable: false, actionable: false,
fetchMode: FetchMode.eager,
), ),
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
@ -855,7 +951,7 @@ class _ItemScreenState extends State<ItemScreen> {
} }
} }
void onMoreTapped(Item item) { void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
if (item.dead || item.deleted) { if (item.dead || item.deleted) {
@ -1064,7 +1160,7 @@ class _ItemScreenState extends State<ItemScreen> {
case _MenuAction.downvote: case _MenuAction.downvote:
break; break;
case _MenuAction.share: case _MenuAction.share:
onShareTapped(item); onShareTapped(item, rect);
break; break;
case _MenuAction.flag: case _MenuAction.flag:
onFlagTapped(item); onFlagTapped(item);
@ -1079,8 +1175,12 @@ class _ItemScreenState extends State<ItemScreen> {
}); });
} }
void onShareTapped(Item item) => void onShareTapped(Item item, Rect? rect) {
Share.share('https://news.ycombinator.com/item?id=${item.id}'); Share.share(
'https://news.ycombinator.com/item?id=${item.id}',
sharePositionOrigin: rect,
);
}
void onFlagTapped(Item item) { void onFlagTapped(Item item) {
showDialog<bool>( showDialog<bool>(

View File

@ -283,6 +283,122 @@ class _ProfileScreenState extends State<ProfileScreen>
}, },
activeColor: Palette.orange, activeColor: Palette.orange,
), ),
const SizedBox(
height: Dimens.pt8,
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: const <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default fetch mode'),
Spacer(),
],
),
),
Flexible(
child: Row(
children: const <Widget>[
Text('Default comments order'),
Spacer(),
],
),
),
],
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt16,
),
DropdownButton<FetchMode>(
value: preferenceState.fetchMode,
underline: const SizedBox.shrink(),
items: const <
DropdownMenuItem<FetchMode>>[
DropdownMenuItem<FetchMode>(
value: FetchMode.lazy,
child: Text(
'Lazy',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
),
DropdownMenuItem<FetchMode>(
value: FetchMode.eager,
child: Text(
'Eager',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
),
],
onChanged: context
.read<PreferenceCubit>()
.selectFetchMode,
),
const Spacer(),
],
),
),
Flexible(
child: Row(
children: <Widget>[
DropdownButton<CommentsOrder>(
value: preferenceState.order,
underline: const SizedBox.shrink(),
items: const <
DropdownMenuItem<CommentsOrder>>[
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.natural,
child: Text(
'Natural',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.newestFirst,
child: Text(
'Newest first',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.oldestFirst,
child: Text(
'Oldest first',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
),
],
onChanged: context
.read<PreferenceCubit>()
.selectCommentsOrder,
),
const Spacer(),
],
),
),
],
),
SwitchListTile( SwitchListTile(
title: const Text('Complex Story Tile'), title: const Text('Complex Story Tile'),
subtitle: const Text( subtitle: const Text(
@ -410,7 +526,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Hacki', applicationName: 'Hacki',
applicationVersion: 'v0.2.23', applicationVersion: 'v0.2.28',
applicationIcon: ClipRRect( applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular( Radius.circular(
@ -680,7 +796,7 @@ class _ProfileScreenState extends State<ProfileScreen>
.get<SembastRepository>() .get<SembastRepository>()
.deleteAllCachedComments() .deleteAllCachedComments()
.whenComplete( .whenComplete(
locator.get<CacheRepository>().deleteAll, locator.get<OfflineRepository>().deleteAll,
) )
.whenComplete( .whenComplete(
locator.get<PreferenceRepository>().clearAllReadStories, locator.get<PreferenceRepository>().clearAllReadStories,

View File

@ -34,7 +34,7 @@ class _WebViewScreenState extends State<WebViewScreen> {
), ),
body: WebView( body: WebView(
onWebViewCreated: (WebViewController controller) async { onWebViewCreated: (WebViewController controller) async {
final String? html = await locator.get<CacheRepository>().getHtml( final String? html = await locator.get<OfflineRepository>().getHtml(
url: widget.url, url: widget.url,
); );

View File

@ -7,6 +7,7 @@ import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -16,6 +17,7 @@ class CommentTile extends StatelessWidget {
required this.myUsername, required this.myUsername,
required this.comment, required this.comment,
required this.onStoryLinkTapped, required this.onStoryLinkTapped,
required this.fetchMode,
this.onReplyTapped, this.onReplyTapped,
this.onMoreTapped, this.onMoreTapped,
this.onEditTapped, this.onEditTapped,
@ -31,10 +33,11 @@ class CommentTile extends StatelessWidget {
final int level; final int level;
final bool actionable; final bool actionable;
final Function(Comment)? onReplyTapped; final Function(Comment)? onReplyTapped;
final Function(Comment)? onMoreTapped; final Function(Comment, Rect?)? onMoreTapped;
final Function(Comment)? onEditTapped; final Function(Comment)? onEditTapped;
final Function(Comment)? onRightMoreTapped; final Function(Comment)? onRightMoreTapped;
final Function(String) onStoryLinkTapped; final Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -43,6 +46,7 @@ class CommentTile extends StatelessWidget {
lazy: false, lazy: false,
create: (_) => CollapseCubit( create: (_) => CollapseCubit(
commentId: comment.id, commentId: comment.id,
collapseCache: context.tryRead<CollapseCache>() ?? CollapseCache(),
)..init(), )..init(),
child: BlocBuilder<CollapseCubit, CollapseState>( child: BlocBuilder<CollapseCubit, CollapseState>(
builder: (BuildContext context, CollapseState state) { builder: (BuildContext context, CollapseState state) {
@ -86,8 +90,11 @@ class CommentTile extends StatelessWidget {
icon: Icons.edit, icon: Icons.edit,
), ),
SlidableAction( SlidableAction(
onPressed: (_) => onPressed: (BuildContext context) =>
onMoreTapped?.call(comment), onMoreTapped?.call(
comment,
context.rect,
),
backgroundColor: Palette.orange, backgroundColor: Palette.orange,
foregroundColor: Palette.white, foregroundColor: Palette.white,
icon: Icons.more_horiz, icon: Icons.more_horiz,
@ -275,6 +282,30 @@ class CommentTile extends StatelessWidget {
}, },
), ),
), ),
if (!state.collapsed &&
fetchMode == FetchMode.lazy &&
comment.kids.isNotEmpty &&
!context
.read<CommentsCubit>()
.state
.commentIds
.contains(comment.kids.first))
Center(
child: TextButton(
onPressed: () {
HapticFeedback.selectionClick();
context
.read<CommentsCubit>()
.loadMore(comment: comment);
},
child: Text(
'''Load ${comment.kids.length} ${comment.kids.length > 1 ? 'replies' : 'reply'}''',
style: const TextStyle(
fontSize: TextDimens.pt12,
),
),
),
),
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
), ),
@ -296,7 +327,7 @@ class CommentTile extends StatelessWidget {
: Palette.transparent; : Palette.transparent;
final bool isMyComment = myUsername == comment.by; final bool isMyComment = myUsername == comment.by;
Widget? wrapper = child; Widget wrapper = child;
if (isMyComment && level == 0) { if (isMyComment && level == 0) {
return Container( return Container(
@ -328,7 +359,7 @@ class CommentTile extends StatelessWidget {
); );
} }
return wrapper!; return wrapper;
}, },
); );
}, },
@ -338,7 +369,12 @@ class CommentTile extends StatelessWidget {
); );
} }
static final Map<int, Color> _colors = <int, Color>{};
Color _getColor(int level) { Color _getColor(int level) {
final int initialLevel = level;
if (_colors[initialLevel] != null) return _colors[initialLevel]!;
while (level >= 10) { while (level >= 10) {
level = level - 10; level = level - 10;
} }
@ -359,6 +395,7 @@ class CommentTile extends StatelessWidget {
1, 1,
); );
_colors[initialLevel] = color;
return color; return color;
} }
} }

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart'; import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart'; import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart';
@ -199,23 +200,9 @@ class _LinkPreviewState extends State<LinkPreview> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double screenWidthLowerBound = 428,
screenWidthUpperBound = 850,
picHeightLowerBound = 118,
picHeightUpperBound = 140,
smallPicHeight = 100,
picHeightFactor = 0.14;
final double screenWidth = MediaQuery.of(context).size.width;
final bool showSmallerPreviewPic = screenWidth > screenWidthLowerBound &&
screenWidth < screenWidthUpperBound;
final double height = showSmallerPreviewPic
? smallPicHeight
: (MediaQuery.of(context).size.height * picHeightFactor)
.clamp(picHeightLowerBound, picHeightUpperBound);
final Widget loadingWidget = widget.placeholderWidget ?? final Widget loadingWidget = widget.placeholderWidget ??
Container( Container(
height: height, height: context.storyTileHeight,
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
@ -232,13 +219,13 @@ class _LinkPreviewState extends State<LinkPreview> {
final WebInfo? info = _info as WebInfo?; final WebInfo? info = _info as WebInfo?;
loadedWidget = _info == null loadedWidget = _info == null
? _buildLinkContainer( ? _buildLinkContainer(
height, context.storyTileHeight,
title: _errorTitle, title: _errorTitle,
desc: _errorBody, desc: _errorBody,
imageUri: null, imageUri: null,
) )
: _buildLinkContainer( : _buildLinkContainer(
height, context.storyTileHeight,
title: _errorTitle, title: _errorTitle,
desc: WebAnalyzer.isNotEmpty(info!.description) desc: WebAnalyzer.isNotEmpty(info!.description)
? info.description ? info.description

View File

@ -147,7 +147,7 @@ class LinkView extends StatelessWidget {
Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) { Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 3, 1), padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Container( Container(
@ -168,7 +168,7 @@ class LinkView extends StatelessWidget {
return Expanded( return Expanded(
flex: 2, flex: 2,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 3, 5, 0), padding: const EdgeInsets.fromLTRB(5, 2, 5, 0),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
if (showMetadata) if (showMetadata)

View File

@ -140,7 +140,7 @@ class WebAnalyzer {
while (comment == null && index < story.kids.length) { while (comment == null && index < story.kids.length) {
comment = await locator comment = await locator
.get<CacheRepository>() .get<OfflineRepository>()
.getCachedComment(id: story.kids.elementAt(index)); .getCachedComment(id: story.kids.elementAt(index));
index++; index++;
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.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';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -29,20 +30,7 @@ class StoryTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (showWebPreview) { if (showWebPreview) {
const double screenWidthLowerBound = 428, final double height = context.storyTileHeight;
screenWidthUpperBound = 850,
picHeightLowerBound = 118,
picHeightUpperBound = 140,
smallPicHeight = 100,
picHeightFactor = 0.14;
final double screenWidth = MediaQuery.of(context).size.width;
final bool showSmallerPreviewPic = screenWidth > screenWidthLowerBound &&
screenWidth < screenWidthUpperBound;
final double height = showSmallerPreviewPic
? smallPicHeight
: (MediaQuery.of(context).size.height * picHeightFactor)
.clamp(picHeightLowerBound, picHeightUpperBound);
return TapDownWrapper( return TapDownWrapper(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
@ -143,7 +131,7 @@ class StoryTile extends StatelessWidget {
backgroundColor: Palette.transparent, backgroundColor: Palette.transparent,
borderRadius: Dimens.zero, borderRadius: Dimens.zero,
removeElevation: true, removeElevation: true,
bodyMaxLines: height == smallPicHeight ? 3 : 4, bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title, errorTitle: story.title,
titleStyle: TextStyle( titleStyle: TextStyle(
color: hasRead color: hasRead

View File

@ -0,0 +1,3 @@
export 'collapse_cache.dart';
export 'comment_cache.dart';
export 'draft_cache.dart';

View File

@ -1,12 +1,9 @@
import 'package:hacki/models/models.dart' show Comment;
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
class CacheService { class CollapseCache {
static final Map<int, Comment> _comments = <int, Comment>{}; final Map<int, Set<int>> _kids = <int, Set<int>>{};
static final Map<int, String> _drafts = <int, String>{}; final Set<int> _collapsed = <int>{};
static final Map<int, Set<int>> _kids = <int, Set<int>>{}; final Map<int, Set<int>> _hidden = <int, Set<int>>{};
static final Set<int> _collapsed = <int>{};
static final Map<int, Set<int>> _hidden = <int, Set<int>>{};
final PublishSubject<Map<int, Set<int>>> _hiddenCommentsSubject = final PublishSubject<Map<int, Set<int>>> _hiddenCommentsSubject =
PublishSubject<Map<int, Set<int>>>(); PublishSubject<Map<int, Set<int>>>();
@ -76,19 +73,4 @@ class CacheService {
bool isCollapsed(int commentId) => _collapsed.contains(commentId); bool isCollapsed(int commentId) => _collapsed.contains(commentId);
int totalHidden(int commentId) => _hidden[commentId]?.length ?? 0; int totalHidden(int commentId) => _hidden[commentId]?.length ?? 0;
void cacheComment(Comment comment) => _comments[comment.id] = comment;
Comment? getComment(int id) => _comments[id];
void resetComments() {
_comments.clear();
}
void removeDraft({required int replyingTo}) => _drafts.remove(replyingTo);
void cacheDraft({required String text, required int replyingTo}) =>
_drafts[replyingTo] = text;
String? getDraft({required int replyingTo}) => _drafts[replyingTo];
} }

View File

@ -0,0 +1,13 @@
import 'package:hacki/models/models.dart' show Comment;
class CommentCache {
static final Map<int, Comment> _comments = <int, Comment>{};
void cacheComment(Comment comment) => _comments[comment.id] = comment;
Comment? getComment(int id) => _comments[id];
void resetComments() {
_comments.clear();
}
}

View File

@ -0,0 +1,10 @@
class DraftCache {
static final Map<int, String> _drafts = <int, String>{};
void removeDraft({required int replyingTo}) => _drafts.remove(replyingTo);
void cacheDraft({required String text, required int replyingTo}) =>
_drafts[replyingTo] = text;
String? getDraft({required int replyingTo}) => _drafts[replyingTo];
}

View File

@ -3,15 +3,30 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
class CustomBlocObserver extends BlocObserver { class CustomBlocObserver extends BlocObserver {
@override
void onCreate(BlocBase<dynamic> bloc) {
locator.get<Logger>().v('$bloc created');
super.onCreate(bloc);
}
@override @override
void onEvent( void onEvent(
Bloc<dynamic, dynamic> bloc, Bloc<dynamic, dynamic> bloc,
Object? event, Object? event,
) { ) {
locator.get<Logger>().d(event); locator.get<Logger>().v(event);
super.onEvent(bloc, event); super.onEvent(bloc, event);
} }
@override
void onTransition(
Bloc<dynamic, dynamic> bloc,
Transition<dynamic, dynamic> transition,
) {
locator.get<Logger>().v(transition);
super.onTransition(bloc, transition);
}
@override @override
void onError( void onError(
BlocBase<dynamic> bloc, BlocBase<dynamic> bloc,

View File

@ -1,4 +1,4 @@
export 'cache_service.dart'; export 'caches/caches.dart';
export 'custom_bloc_observer.dart'; export 'custom_bloc_observer.dart';
export 'fetcher.dart'; export 'fetcher.dart';
export 'firebase_client.dart'; export 'firebase_client.dart';

View File

@ -19,7 +19,7 @@ abstract class LinkUtil {
}) { }) {
if (offlineReading) { if (offlineReading) {
locator locator
.get<CacheRepository>() .get<OfflineRepository>()
.hasCachedWebPage(url: link) .hasCachedWebPage(url: link)
.then((bool cached) { .then((bool cached) {
if (cached) { if (cached) {

View File

@ -14,7 +14,7 @@ packages:
name: adaptive_theme name: adaptive_theme
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "3.1.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
@ -22,6 +22,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.11"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -175,14 +182,14 @@ packages:
name: coverage name: coverage
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.2" version: "1.2.0"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.2" version: "3.0.1"
csslib: csslib:
dependency: transitive dependency: transitive
description: description:
@ -196,7 +203,7 @@ packages:
name: dbus name: dbus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.7.5" version: "0.7.6"
diff_match_patch: diff_match_patch:
dependency: transitive dependency: transitive
description: description:
@ -281,6 +288,11 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.0" version: "3.3.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_fadein: flutter_fadein:
dependency: "direct main" dependency: "direct main"
description: description:
@ -410,6 +422,11 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
gbk_codec: gbk_codec:
dependency: "direct main" dependency: "direct main"
description: description:
@ -473,6 +490,11 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "4.0.1"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -605,7 +627,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.15" version: "2.0.16"
path_provider_ios: path_provider_ios:
dependency: "direct main" dependency: "direct main"
description: description:
@ -747,7 +769,7 @@ packages:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.9" version: "4.0.10"
share_plus_linux: share_plus_linux:
dependency: transitive dependency: transitive
description: description:
@ -935,6 +957,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
sync_http:
dependency: transitive
description:
name: sync_http
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
synced_shared_preferences: synced_shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -997,7 +1026,7 @@ packages:
name: typed_data name: typed_data
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.1" version: "1.3.0"
universal_platform: universal_platform:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1088,7 +1117,7 @@ packages:
name: vm_service name: vm_service
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "8.3.0" version: "8.2.2"
wakelock: wakelock:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1138,6 +1167,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
webdriver:
dependency: transitive
description:
name: webdriver
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
webkit_inspection_protocol: webkit_inspection_protocol:
dependency: transitive dependency: transitive
description: description:
@ -1210,4 +1246,4 @@ packages:
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.17.0 <3.0.0" dart: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0" flutter: ">=3.0.3"

View File

@ -1,10 +1,11 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 0.2.23+65 version: 0.2.28+70
publish_to: none publish_to: none
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
flutter: "3.0.3"
dependencies: dependencies:
adaptive_theme: ^3.0.0 adaptive_theme: ^3.0.0
@ -17,7 +18,7 @@ dependencies:
dio: ^4.0.4 dio: ^4.0.4
equatable: 2.0.3 equatable: 2.0.3
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
# feature_discovery: ^0.14.0 # feature_discovery: ^0.14.0
feature_discovery: feature_discovery:
git: git:
url: https://github.com/livinglist/feature_discovery url: https://github.com/livinglist/feature_discovery
@ -47,7 +48,7 @@ dependencies:
path_provider: ^2.0.8 path_provider: ^2.0.8
path_provider_android: ^2.0.8 path_provider_android: ^2.0.8
path_provider_ios: ^2.0.8 path_provider_ios: ^2.0.8
# pull_to_refresh: ^2.0.0 # pull_to_refresh: ^2.0.0
pull_to_refresh: pull_to_refresh:
git: git:
url: https://github.com/livinglist/flutter_pulltorefresh url: https://github.com/livinglist/flutter_pulltorefresh
@ -74,8 +75,10 @@ dev_dependencies:
bloc_test: ^9.0.3 bloc_test: ^9.0.3
flutter_test: flutter_test:
sdk: flutter sdk: flutter
integration_test:
sdk: flutter
mocktail: ^0.3.0 mocktail: ^0.3.0
very_good_analysis: ^2.3.0 very_good_analysis: ^2.4.0
flutter: flutter:
uses-material-design: true uses-material-design: true