Compare commits

...

22 Commits

Author SHA1 Message Date
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
a90d52f348 v0.2.23 (#60)
* fixed #58

* bumped version.
2022-06-23 19:38:14 -07:00
cff4a3c5c4 v0.2.22 (#59)
* bumped version.

* cleaned up code.

* bumped version.

* fixed lint.

* updated android config.

* prevent backup of secure storage.

* small fix.

* cleaned up code.

* cleaned up.

* small fix
2022-06-22 23:44:49 -07:00
502faaf188 corrected spelling. 2022-06-22 10:28:27 -07:00
b952f349fc v0.2.21 (#57)
* replaced StoryScreen with ItemScreen.

* use ItemScreen for share extension.

* fixed getItemId()

* bumped version.

* force new screen on viewing comments in separate thread.

* disable comment thread if comment is deleted or dead.

* navigate to new screen on viewing parent thread.

* bumped version.

* fixed inconsistent fontsize.

* bumped version.
2022-06-21 20:20:09 -07:00
9cefffa518 v0.2.20 (#56)
* bumped version.

* fixed web analyzer.

* improved comments loading mechanism.

* fixed delete all button.

* improved reply box logic.

* improved web analyzer.

* allow users to sort comments.

* fixed styles.

* fixed bugs.

* bumped version.

* fixed comments cubit.

* fixed dead comments.
2022-06-21 02:38:24 -07:00
fe630ea7a9 v0.2.20 (#55)
* bumped version.

* fixed web analyzer.

* improved comments loading mechanism.

* fixed delete all button.

* improved reply box logic.

* improved web analyzer.

* allow users to sort comments.

* fixed styles.

* fixed bugs.

* bumped version.

* fixed comments cubit.
2022-06-21 02:15:42 -07:00
459ab961d1 v0.2.19 hotfix (#52)
* improved offline mode.

* bumped version.

* reset stories count on download event.

* fixed overflow.
2022-06-18 23:40:56 -07:00
362d7005df fixed jank while exiting story screen. 2022-06-18 11:44:34 -07:00
6b7c1d42de fixed offline mode. 2022-06-18 02:38:31 -07:00
2a889bca56 v0.2.19 (#51)
* improved comments loading.

* added web page caching to offline mode.

* improved offline webview.

* fixed web analyzer.

* updated description.
2022-06-18 02:18:03 -07:00
e25026f129 created FUNDING.yml 2022-06-17 21:25:24 -07:00
fefc86275d v0.2.18 (#49)
* allow plaintext http connections (#48)

via https://stackoverflow.com/a/56837613

I was unable to open an article just now, and a fww minutes later unable to open a comment (since the webview is still being forced on my not-yet-updated client, i have to find the artocle to open it.

* bumped version.

* improved link preview.

* added share button.

* added ability to launch third party app for url on Android.

* added support for siri suggestions.

* bumped version.

* added support for app links on Android.

Co-authored-by: Efreak <Efreak@users.noreply.github.com>
2022-06-15 23:56:45 -07:00
1a73a6991e updated screenshots. 2022-06-10 02:28:31 -07:00
36d7f4606e updated screenshots. 2022-06-10 02:27:22 -07:00
9312c56dd0 v0.2.17 (#47)
* improved story screen scrolling.

* bumped version.

* shrink instead of return on tapping back button. #42

* allowed users to view other user's profile. #45

* bumped version.

* added back underline to links.

* fixed overlow of popup menu,
2022-06-10 02:10:23 -07:00
6e71de5913 updated README.md 2022-06-05 22:39:54 -07:00
144 changed files with 3368 additions and 1714 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# These are supported funding model platforms
github: livinglist
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: jfeng_for_open_source
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

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

View File

@ -4,15 +4,12 @@
A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough. A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
[![Play Store](https://img.shields.io/badge/Play%20Store--yellow)](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
[![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/) [![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
[![GH version](https://img.shields.io/github/release/livinglist/hacki.svg?logo=github)](https://github.com/Livinglist/Hacki/releases/latest) [![GH version](https://img.shields.io/github/release/livinglist/hacki.svg?logo=github)](https://github.com/Livinglist/Hacki/releases/latest)
[![Visits Badge](https://badges.pufler.dev/visits/livinglist/Hacki)](https://badges.pufler.dev) [![Visits Badge](https://badges.pufler.dev/visits/livinglist/Hacki)](https://badges.pufler.dev)
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social) [![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart) [![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart)
<noscript><a href="https://liberapay.com/jfeng_for_open_source/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
[<img src="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US) [<img src="assets/images/f_droid_badge.png" height="50">](https://f-droid.org/en/packages/com.jiaqifeng.hacki/) [<img src="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US) [<img src="assets/images/f_droid_badge.png" height="50">](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)

View File

@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
compileSdkVersion 31 compileSdkVersion 32
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
@ -49,10 +49,9 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.jiaqifeng.hacki" applicationId "com.jiaqifeng.hacki"
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 30 targetSdkVersion 32
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@ -78,5 +77,5 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
} }

View File

@ -16,14 +16,18 @@
</queries> </queries>
<application <application
android:label="hacki" android:label="Hacki"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:exported="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
@ -52,6 +56,21 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="http"
android:host="news.ycombinator.com"
android:pathPrefix="/item" />
<data
android:scheme="https"
android:host="news.ycombinator.com"
android:pathPrefix="/item" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage"/>
</full-backup-content>

View File

@ -1,12 +1,12 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext.kotlin_version = '1.7.0'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.1.1' classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 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

@ -0,0 +1 @@
- Offline mode now includes web pages.

View File

@ -0,0 +1 @@
- Offline mode now includes web pages.

View File

@ -0,0 +1 @@
- Offline mode now includes web pages.

View File

@ -0,0 +1 @@
- Offline mode now includes web pages.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 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

@ -14,15 +14,21 @@ PODS:
- Flutter - Flutter
- flutter_secure_storage (3.3.1): - flutter_secure_storage (3.3.1):
- Flutter - Flutter
- flutter_siri_suggestions (0.0.1):
- Flutter
- 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
- ReachabilitySwift (5.0.0) - ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1): - receive_sharing_intent (0.0.1):
- Flutter - Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1): - shared_preferences_ios (0.0.1):
- Flutter - Flutter
- sqflite (0.0.2): - sqflite (0.0.2):
@ -32,8 +38,6 @@ PODS:
- Flutter - Flutter
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- wakelock (0.0.1): - wakelock (0.0.1):
- Flutter - Flutter
- webview_flutter_wkwebview (0.0.1): - webview_flutter_wkwebview (0.0.1):
@ -47,13 +51,15 @@ DEPENDENCIES:
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- 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`)
- 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`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`) - synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`) - wakelock (from `.symlinks/plugins/wakelock/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
- workmanager (from `.symlinks/plugins/workmanager/ios`) - workmanager (from `.symlinks/plugins/workmanager/ios`)
@ -75,10 +81,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions:
: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:
:path: ".symlinks/plugins/receive_sharing_intent/ios" :path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_ios: shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios" :path: ".symlinks/plugins/shared_preferences_ios/ios"
sqflite: sqflite:
@ -87,8 +99,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/synced_shared_preferences/ios" :path: ".symlinks/plugins/synced_shared_preferences/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock: wakelock:
:path: ".symlinks/plugins/wakelock/ios" :path: ".symlinks/plugins/wakelock/ios"
webview_flutter_wkwebview: webview_flutter_wkwebview:
@ -102,16 +112,18 @@ SPEC CHECKSUMS:
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
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
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7 synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -568,7 +568,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -577,7 +577,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.16; MARKETING_VERSION = 0.2.27;
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 = "";
@ -705,7 +705,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -714,7 +714,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.16; MARKETING_VERSION = 0.2.27;
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 = "";
@ -736,7 +736,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -745,7 +745,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.16; MARKETING_VERSION = 0.2.27;
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);
@ -32,15 +37,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoryRead>(onStoryRead); on<StoryRead>(onStoryRead);
on<StoriesLoaded>(onStoriesLoaded); on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload); on<StoriesDownload>(onDownload);
on<StoryDownloaded>(onStoryDownloaded);
on<StoriesExitOffline>(onExitOffline); on<StoriesExitOffline>(onExitOffline);
on<StoriesPageSizeChanged>(onPageSizeChanged); on<StoriesPageSizeChanged>(onPageSizeChanged);
on<ClearAllReadStories>(onClearAllReadStories); on<ClearAllReadStories>(onClearAllReadStories);
} }
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;
@ -70,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(
@ -89,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)),
) )
@ -166,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,
@ -240,39 +247,46 @@ 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> allIds = <int>{}; final Set<int> prioritizedIds = <int>{};
final List<StoryType> prioritizedTypes = <StoryType>[...types]
..remove(StoryType.latest);
for (final StoryType type in types) { 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);
allIds.addAll(ids); prioritizedIds.addAll(ids);
} }
try {
_storiesRepository
.fetchStoriesStream(ids: allIds.toList())
.listen((Story story) async {
if (story.kids.isNotEmpty) {
await _cacheRepository.cacheStory(story: story);
_storiesRepository
.fetchAllChildrenComments(ids: story.kids)
.listen((Comment? comment) async {
if (comment != null) {
await _cacheRepository.cacheComment(comment: comment);
}
});
}
}).onDone(() {
emit( emit(
state.copyWith( state.copyWith(
downloadStatus: StoriesDownloadStatus.finished, storiesDownloaded: 0,
storiesToBeDownloaded: prioritizedIds.length,
), ),
); );
});
try {
await fetchAndCacheStories(
prioritizedIds,
includingWebPage: event.includingWebPage,
isPrioritized: true,
);
final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds(
of: StoryType.latest,
);
await _offlineRepository.cacheStoryIds(of: StoryType.latest, ids: ids);
latestIds.addAll(ids);
await fetchAndCacheStories(
latestIds,
includingWebPage: event.includingWebPage,
isPrioritized: false,
);
} catch (_) { } catch (_) {
emit( emit(
state.copyWith( state.copyWith(
@ -282,6 +296,80 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
} }
Future<void> fetchAndCacheStories(
Iterable<int> ids, {
required bool includingWebPage,
required bool isPrioritized,
}) async {
for (final int id in ids) {
final Story? story = await _storiesRepository.fetchStoryBy(id);
if (story == null) {
if (isPrioritized) {
add(StoryDownloaded(skipped: true));
}
continue;
}
if (story.kids.isEmpty) {
if (isPrioritized) {
add(StoryDownloaded(skipped: true));
}
continue;
}
await _offlineRepository.cacheStory(story: story);
if (story.url.isNotEmpty && includingWebPage) {
_logger.i('downloading ${story.url}');
await _offlineRepository.cacheUrl(url: story.url);
}
_storiesRepository
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
(Comment comment) => unawaited(
_offlineRepository.cacheComment(comment: comment),
),
)
.onDone(() => add(StoryDownloaded(skipped: false)));
}
}
void onStoryDownloaded(StoryDownloaded event, Emitter<StoriesState> emit) {
if (event.skipped) {
final int updatedStoriesToBeDownloaded = state.storiesToBeDownloaded - 1;
emit(
state.copyWith(
storiesToBeDownloaded: updatedStoriesToBeDownloaded,
downloadStatus:
state.storiesDownloaded == updatedStoriesToBeDownloaded
? StoriesDownloadStatus.finished
: null,
),
);
} else {
final int updatedStoriesDownloaded = state.storiesDownloaded + 1;
final int updatedStoriesToBeDownloaded =
updatedStoriesDownloaded > state.storiesToBeDownloaded
? state.storiesToBeDownloaded + 1
: state.storiesToBeDownloaded;
emit(
state.copyWith(
storiesDownloaded: updatedStoriesDownloaded,
storiesToBeDownloaded: updatedStoriesToBeDownloaded,
downloadStatus:
updatedStoriesDownloaded == updatedStoriesToBeDownloaded
? StoriesDownloadStatus.finished
: null,
),
);
}
}
Future<void> onPageSizeChanged( Future<void> onPageSizeChanged(
StoriesPageSizeChanged event, StoriesPageSizeChanged event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
@ -294,9 +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 _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false)); emit(state.copyWith(offlineReading: false));
add(StoriesInitialize()); add(StoriesInitialize());
} }

View File

@ -38,8 +38,21 @@ class StoriesLoadMore extends StoriesEvent {
} }
class StoriesDownload extends StoriesEvent { class StoriesDownload extends StoriesEvent {
StoriesDownload({required this.includingWebPage});
final bool includingWebPage;
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[includingWebPage];
}
class StoryDownloaded extends StoriesEvent {
StoryDownloaded({required this.skipped});
final bool skipped;
@override
List<Object?> get props => <Object?>[skipped];
} }
class StoriesExitOffline extends StoriesEvent { class StoriesExitOffline extends StoriesEvent {

View File

@ -23,6 +23,8 @@ class StoriesState extends Equatable {
required this.offlineReading, required this.offlineReading,
required this.downloadStatus, required this.downloadStatus,
required this.currentPageSize, required this.currentPageSize,
required this.storiesDownloaded,
required this.storiesToBeDownloaded,
}); });
const StoriesState.init({ const StoriesState.init({
@ -61,7 +63,9 @@ class StoriesState extends Equatable {
}) : offlineReading = false, }) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial, downloadStatus = StoriesDownloadStatus.initial,
currentPageSize = 0, currentPageSize = 0,
readStoriesIds = const <int>{}; readStoriesIds = const <int>{},
storiesDownloaded = 0,
storiesToBeDownloaded = 0;
final Map<StoryType, List<Story>> storiesByType; final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType; final Map<StoryType, List<int>> storyIdsByType;
@ -71,6 +75,8 @@ class StoriesState extends Equatable {
final StoriesDownloadStatus downloadStatus; final StoriesDownloadStatus downloadStatus;
final bool offlineReading; final bool offlineReading;
final int currentPageSize; final int currentPageSize;
final int storiesDownloaded;
final int storiesToBeDownloaded;
StoriesState copyWith({ StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType, Map<StoryType, List<Story>>? storiesByType,
@ -81,6 +87,8 @@ class StoriesState extends Equatable {
StoriesDownloadStatus? downloadStatus, StoriesDownloadStatus? downloadStatus,
bool? offlineReading, bool? offlineReading,
int? currentPageSize, int? currentPageSize,
int? storiesDownloaded,
int? storiesToBeDownloaded,
}) { }) {
return StoriesState( return StoriesState(
storiesByType: storiesByType ?? this.storiesByType, storiesByType: storiesByType ?? this.storiesByType,
@ -91,6 +99,9 @@ class StoriesState extends Equatable {
offlineReading: offlineReading ?? this.offlineReading, offlineReading: offlineReading ?? this.offlineReading,
downloadStatus: downloadStatus ?? this.downloadStatus, downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize, currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
storiesToBeDownloaded:
storiesToBeDownloaded ?? this.storiesToBeDownloaded,
); );
} }
@ -102,18 +113,12 @@ class StoriesState extends Equatable {
final Map<StoryType, List<Story>> newMap = final Map<StoryType, List<Story>> newMap =
Map<StoryType, List<Story>>.from(storiesByType); Map<StoryType, List<Story>>.from(storiesByType);
newMap[of] = List<Story>.from(newMap[of]!)..add(story); newMap[of] = List<Story>.from(newMap[of]!)..add(story);
return StoriesState( return copyWith(
storiesByType: newMap, storiesByType: newMap,
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: currentPageByType,
readStoriesIds: <int>{ readStoriesIds: <int>{
...readStoriesIds, ...readStoriesIds,
if (hasRead) story.id, if (hasRead) story.id,
}, },
offlineReading: offlineReading,
downloadStatus: downloadStatus,
currentPageSize: currentPageSize,
); );
} }
@ -124,15 +129,8 @@ class StoriesState extends Equatable {
final Map<StoryType, List<int>> newMap = final Map<StoryType, List<int>> newMap =
Map<StoryType, List<int>>.from(storyIdsByType); Map<StoryType, List<int>>.from(storyIdsByType);
newMap[of] = to; newMap[of] = to;
return StoriesState( return copyWith(
storiesByType: storiesByType,
storyIdsByType: newMap, storyIdsByType: newMap,
statusByType: statusByType,
currentPageByType: currentPageByType,
readStoriesIds: readStoriesIds,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
currentPageSize: currentPageSize,
); );
} }
@ -143,15 +141,8 @@ class StoriesState extends Equatable {
final Map<StoryType, StoriesStatus> newMap = final Map<StoryType, StoriesStatus> newMap =
Map<StoryType, StoriesStatus>.from(statusByType); Map<StoryType, StoriesStatus>.from(statusByType);
newMap[of] = to; newMap[of] = to;
return StoriesState( return copyWith(
storiesByType: storiesByType,
storyIdsByType: storyIdsByType,
statusByType: newMap, statusByType: newMap,
currentPageByType: currentPageByType,
readStoriesIds: readStoriesIds,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
currentPageSize: currentPageSize,
); );
} }
@ -162,15 +153,8 @@ class StoriesState extends Equatable {
final Map<StoryType, int> newMap = final Map<StoryType, int> newMap =
Map<StoryType, int>.from(currentPageByType); Map<StoryType, int>.from(currentPageByType);
newMap[of] = to; newMap[of] = to;
return StoriesState( return copyWith(
storiesByType: storiesByType,
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: newMap, currentPageByType: newMap,
readStoriesIds: readStoriesIds,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
currentPageSize: currentPageSize,
); );
} }
@ -187,15 +171,11 @@ class StoriesState extends Equatable {
final Map<StoryType, int> newCurrentPageMap = final Map<StoryType, int> newCurrentPageMap =
Map<StoryType, int>.from(currentPageByType); Map<StoryType, int>.from(currentPageByType);
newCurrentPageMap[of] = 0; newCurrentPageMap[of] = 0;
return StoriesState( return copyWith(
storiesByType: newStoriesMap, storiesByType: newStoriesMap,
storyIdsByType: newStoryIdsMap, storyIdsByType: newStoryIdsMap,
statusByType: newStatusMap, statusByType: newStatusMap,
currentPageByType: newCurrentPageMap, currentPageByType: newCurrentPageMap,
readStoriesIds: readStoriesIds,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
currentPageSize: currentPageSize,
); );
} }
@ -209,5 +189,7 @@ class StoriesState extends Equatable {
offlineReading, offlineReading,
downloadStatus, downloadStatus,
currentPageSize, currentPageSize,
storiesDownloaded,
storiesToBeDownloaded,
]; ];
} }

View File

@ -9,6 +9,7 @@ abstract class Constants {
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review'; 'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
static const String googlePlayLink = static const String googlePlayLink =
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US'; 'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
static const String sponsorLink = 'https://github.com/sponsors/Livinglist';
static const String _imagePath = 'assets/images'; static const String _imagePath = 'assets/images';
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png'; static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';

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

@ -10,8 +10,8 @@ class CustomRouter {
switch (settings.name) { switch (settings.name) {
case HomeScreen.routeName: case HomeScreen.routeName:
return HomeScreen.route(); return HomeScreen.route();
case StoryScreen.routeName: case ItemScreen.routeName:
return StoryScreen.route(settings.arguments! as StoryScreenArgs); return ItemScreen.route(settings.arguments! as ItemScreenArgs);
case SubmitScreen.routeName: case SubmitScreen.routeName:
return SubmitScreen.route(); return SubmitScreen.route();
default: default:
@ -22,8 +22,8 @@ class CustomRouter {
/// Nested routing for bottom navigation bar. /// Nested routing for bottom navigation bar.
static Route<dynamic> onGenerateNestedRoute(RouteSettings settings) { static Route<dynamic> onGenerateNestedRoute(RouteSettings settings) {
switch (settings.name) { switch (settings.name) {
case StoryScreen.routeName: case ItemScreen.routeName:
return StoryScreen.route(settings.arguments! as StoryScreenArgs); return ItemScreen.route(settings.arguments! as ItemScreenArgs);
case SubmitScreen.routeName: case SubmitScreen.routeName:
return SubmitScreen.route(); return SubmitScreen.route();
default: default:

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

@ -1,32 +1,50 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/main.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:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.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,
required bool offlineReading, required bool offlineReading,
required Story story, 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, story: story)); 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;
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
@ -42,6 +60,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)) {
@ -54,7 +73,7 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchCommentsStream( .fetchAllCommentsStream(
ids: targetParents!.last.kids, ids: targetParents!.last.kids,
level: targetParents.last.level + 1, level: targetParents.last.level + 1,
) )
@ -64,34 +83,50 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
emit(state.copyWith(status: CommentsStatus.loading)); emit(
state.copyWith(
status: CommentsStatus.loading,
comments: <Comment>[],
currentPage: 0,
),
);
final Story story = state.story; final Item item = state.item;
final Story updatedStory = state.offlineReading final Item updatedItem = state.offlineReading
? story ? item
: await _storiesRepository.fetchStoryBy(story.id) ?? story; : await _storiesRepository.fetchItemBy(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids);
emit(state.copyWith(story: updatedStory)); emit(state.copyWith(item: updatedItem));
if (state.offlineReading) { if (state.offlineReading) {
_streamSubscription = _cacheRepository _streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: updatedStory.kids) .getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
} else {
if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository
.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} else { } else {
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchCommentsStream(ids: updatedStory.kids) .fetchAllCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} }
} }
}
Future<void> refresh() async { Future<void> refresh() async {
final bool offlineReading = await _cacheRepository.hasCachedStories; if (state.offlineReading) {
_cacheService.resetCollapsedComments();
if (offlineReading) {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loaded, status: CommentsStatus.loaded,
@ -100,47 +135,149 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
_collapseCache.resetCollapsedComments();
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.loading,
comments: <Comment>[], comments: <Comment>[],
currentPage: 0,
), ),
); );
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
final Story story = state.story; final Item item = state.item;
final Story updatedStory = final Item updatedItem =
await _storiesRepository.fetchStoryBy(story.id) ?? story; await _storiesRepository.fetchItemBy(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids);
if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchCommentsStream(ids: updatedStory.kids) .fetchCommentsStream(
ids: kids,
)
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
}
emit( emit(
state.copyWith( state.copyWith(
story: updatedStory, item: updatedItem,
status: CommentsStatus.loaded, status: CommentsStatus.loaded,
), ),
); );
} }
void loadAll(Story story) { void loadAll(Story story) {
HapticFeedback.lightImpact();
emit( emit(
state.copyWith( state.copyWith(
onlyShowTargetComment: false, onlyShowTargetComment: false,
comments: <Comment>[], item: story,
story: story,
), ),
); );
init(); init();
} }
void loadMore() { /// [comment] is only used for lazy fetching.
void loadMore({Comment? comment}) {
if (state.fetchMode == FetchMode.eager) {
if (_streamSubscription != null) { if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading)); emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume(); _streamSubscription?.resume();
} }
} else {
if (comment == null) return;
final int level = comment.level + 1;
int offset = 0;
_streamSubscription = _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++;
},
);
}
}
Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact());
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent =
await _storiesRepository.fetchParentStory(id: state.item.id);
if (parent == null) {
return;
} else {
await HackiApp.navigatorKey.currentState?.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: parent),
);
emit(
state.copyWith(
fetchParentStatus: CommentsStatus.loaded,
),
);
}
}
void onOrderChanged(CommentsOrder? order) {
if (order == null) return;
if (state.order == order) return;
HapticFeedback.selectionClick();
_streamSubscription?.cancel();
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();
emit(state.copyWith(fetchMode: fetchMode));
init(useCommentCache: true);
}
List<int> sortKids(List<int> kids) {
switch (state.order) {
case CommentsOrder.natural:
return kids;
case CommentsOrder.newestFirst:
return kids.sorted((int a, int b) => b.compareTo(a));
case CommentsOrder.oldestFirst:
return kids.sorted((int a, int b) => a.compareTo(b));
}
} }
void _onDone() { void _onDone() {
@ -155,20 +292,34 @@ 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(
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
final List<Comment> updatedComments = <Comment>[ final List<Comment> updatedComments = <Comment>[
...state.comments, ...state.comments,
comment buildableComment
]; ];
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage && if (state.fetchMode == FetchMode.eager) {
if (updatedComments.length >=
_pageSize + _pageSize * state.currentPage &&
updatedComments.length <= updatedComments.length <=
_pageSize * 2 + _pageSize * state.currentPage) { _pageSize * 2 + _pageSize * state.currentPage) {
final bool isHidden = _collapseCache.isHidden(comment.id);
if (!isHidden) {
_streamSubscription?.pause(); _streamSubscription?.pause();
}
emit( emit(
state.copyWith( state.copyWith(
@ -179,6 +330,32 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
} }
} }
}
static List<LinkifyElement> _linkify(
String text, {
LinkifyOptions options = const LinkifyOptions(),
List<Linkifier> linkifiers = const <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
],
}) {
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
if (text.isEmpty) {
return <LinkifyElement>[];
}
if (linkifiers.isEmpty) {
return list;
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
return list;
}
@override @override
Future<void> close() async { Future<void> close() async {

View File

@ -8,12 +8,25 @@ enum CommentsStatus {
failure, failure,
} }
enum CommentsOrder {
natural,
newestFirst,
oldestFirst,
}
enum FetchMode {
lazy,
eager,
}
class CommentsState extends Equatable { class CommentsState extends Equatable {
const CommentsState({ const CommentsState({
required this.story, required this.item,
required this.comments, required this.comments,
required this.status, required this.status,
required this.collapsed, required this.fetchParentStatus,
required this.order,
required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.offlineReading,
required this.currentPage, required this.currentPage,
@ -21,35 +34,43 @@ class CommentsState extends Equatable {
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.offlineReading,
required this.story, required this.item,
required this.fetchMode,
required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
status = CommentsStatus.init, status = CommentsStatus.init,
collapsed = false, fetchParentStatus = CommentsStatus.init,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0; currentPage = 0;
final Story story; final Item item;
final List<Comment> comments; final List<Comment> comments;
final CommentsStatus status; final CommentsStatus status;
final bool collapsed; final CommentsStatus fetchParentStatus;
final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool offlineReading;
final int currentPage; final int currentPage;
CommentsState copyWith({ CommentsState copyWith({
Story? story, Item? item,
List<Comment>? comments, List<Comment>? comments,
CommentsStatus? status, CommentsStatus? status,
bool? collapsed, CommentsStatus? fetchParentStatus,
CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? offlineReading,
int? currentPage, int? currentPage,
}) { }) {
return CommentsState( return CommentsState(
story: story ?? this.story, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
status: status ?? this.status, status: status ?? this.status,
collapsed: collapsed ?? this.collapsed, order: order ?? this.order,
fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, offlineReading: offlineReading ?? this.offlineReading,
@ -59,10 +80,12 @@ class CommentsState extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
story, item,
comments, comments,
status, status,
collapsed, fetchParentStatus,
order,
fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, offlineReading,
currentPage, currentPage,

View File

@ -14,4 +14,5 @@ export 'search/search_cubit.dart';
export 'split_view/split_view_cubit.dart'; export 'split_view/split_view_cubit.dart';
export 'submit/submit_cubit.dart'; export 'submit/submit_cubit.dart';
export 'time_machine/time_machine_cubit.dart'; export 'time_machine/time_machine_cubit.dart';
export 'user/user_cubit.dart';
export 'vote/vote_cubit.dart'; export 'vote/vote_cubit.dart';

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

@ -39,15 +39,15 @@ class FavCubit extends Cubit<FavState> {
emit( emit(
state.copyWith( state.copyWith(
favIds: favIds, favIds: favIds,
favStories: <Story>[], favItems: <Item>[],
currentPage: 0, currentPage: 0,
), ),
); );
_storiesRepository _storiesRepository
.fetchStoriesStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
) )
.listen(_onStoryLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit( emit(
state.copyWith( state.copyWith(
@ -73,13 +73,13 @@ class FavCubit extends Cubit<FavState> {
), ),
); );
final Story? story = await _storiesRepository.fetchStoryBy(id); final Item? item = await _storiesRepository.fetchItemBy(id: id);
if (story == null) return; if (item == null) return;
emit( emit(
state.copyWith( state.copyWith(
favStories: List<Story>.from(state.favStories)..insert(0, story), favItems: List<Item>.from(state.favItems)..insert(0, item),
), ),
); );
@ -96,8 +96,8 @@ class FavCubit extends Cubit<FavState> {
emit( emit(
state.copyWith( state.copyWith(
favIds: List<int>.from(state.favIds)..remove(id), favIds: List<int>.from(state.favIds)..remove(id),
favStories: List<Story>.from(state.favStories) favItems: List<Item>.from(state.favItems)
..removeWhere((Story e) => e.id == id), ..removeWhere((Item e) => e.id == id),
), ),
); );
@ -120,13 +120,13 @@ class FavCubit extends Cubit<FavState> {
} }
_storiesRepository _storiesRepository
.fetchStoriesStream( .fetchItemsStream(
ids: state.favIds.sublist( ids: state.favIds.sublist(
lower, lower,
upper, upper,
), ),
) )
.listen(_onStoryLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: FavStatus.loaded)); emit(state.copyWith(status: FavStatus.loaded));
}); });
@ -142,7 +142,7 @@ class FavCubit extends Cubit<FavState> {
state.copyWith( state.copyWith(
status: FavStatus.loading, status: FavStatus.loading,
currentPage: 0, currentPage: 0,
favStories: <Story>[], favItems: <Item>[],
favIds: <int>[], favIds: <int>[],
), ),
); );
@ -150,20 +150,20 @@ class FavCubit extends Cubit<FavState> {
_preferenceRepository.favList(of: username).then((List<int> favIds) { _preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(state.copyWith(favIds: favIds)); emit(state.copyWith(favIds: favIds));
_storiesRepository _storiesRepository
.fetchStoriesStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
) )
.listen(_onStoryLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: FavStatus.loaded)); emit(state.copyWith(status: FavStatus.loaded));
}); });
}); });
} }
void _onStoryLoaded(Story story) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(
favStories: List<Story>.from(state.favStories)..add(story), favItems: List<Item>.from(state.favItems)..add(item),
), ),
); );
} }

View File

@ -10,31 +10,31 @@ enum FavStatus {
class FavState extends Equatable { class FavState extends Equatable {
const FavState({ const FavState({
required this.favIds, required this.favIds,
required this.favStories, required this.favItems,
required this.status, required this.status,
required this.currentPage, required this.currentPage,
}); });
FavState.init() FavState.init()
: favIds = <int>[], : favIds = <int>[],
favStories = <Story>[], favItems = <Item>[],
status = FavStatus.init, status = FavStatus.init,
currentPage = 0; currentPage = 0;
final List<int> favIds; final List<int> favIds;
final List<Story> favStories; final List<Item> favItems;
final FavStatus status; final FavStatus status;
final int currentPage; final int currentPage;
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
List<Story>? favStories, List<Item>? favItems,
FavStatus? status, FavStatus? status,
int? currentPage, int? currentPage,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
favStories: favStories ?? this.favStories, favItems: favItems ?? this.favItems,
status: status ?? this.status, status: status ?? this.status,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
@ -43,7 +43,7 @@ class FavState extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
favIds, favIds,
favStories, favItems,
status, status,
currentPage, currentPage,
]; ];

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,19 +3,25 @@ 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 updateStoryScreenArgs(StoryScreenArgs args) { void updateItemScreenArgs(ItemScreenArgs args) {
_cacheService.resetCollapsedComments(); _logger.i('resetting comments in CommentCache');
emit(state.copyWith(storyScreenArgs: args)); _commentCache.resetComments();
emit(state.copyWith(itemScreenArgs: args));
} }
void enableSplitView() => emit(state.copyWith(enabled: true)); void enableSplitView() => emit(state.copyWith(enabled: true));

View File

@ -2,7 +2,7 @@ part of 'split_view_cubit.dart';
class SplitViewState extends Equatable { class SplitViewState extends Equatable {
const SplitViewState({ const SplitViewState({
required this.storyScreenArgs, required this.itemScreenArgs,
required this.expanded, required this.expanded,
required this.enabled, required this.enabled,
}); });
@ -10,21 +10,21 @@ class SplitViewState extends Equatable {
const SplitViewState.init() const SplitViewState.init()
: enabled = false, : enabled = false,
expanded = false, expanded = false,
storyScreenArgs = null; itemScreenArgs = null;
final bool enabled; final bool enabled;
final bool expanded; final bool expanded;
final StoryScreenArgs? storyScreenArgs; final ItemScreenArgs? itemScreenArgs;
SplitViewState copyWith({ SplitViewState copyWith({
bool? enabled, bool? enabled,
bool? expanded, bool? expanded,
StoryScreenArgs? storyScreenArgs, ItemScreenArgs? itemScreenArgs,
}) { }) {
return SplitViewState( return SplitViewState(
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
expanded: expanded ?? this.expanded, expanded: expanded ?? this.expanded,
storyScreenArgs: storyScreenArgs ?? this.storyScreenArgs, itemScreenArgs: itemScreenArgs ?? this.itemScreenArgs,
); );
} }
@ -32,6 +32,6 @@ class SplitViewState extends Equatable {
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
enabled, enabled,
expanded, expanded,
storyScreenArgs, itemScreenArgs,
]; ];
} }

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,26 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
part 'user_state.dart';
class UserCubit extends Cubit<UserState> {
UserCubit({StoriesRepository? storiesRepository})
: _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(UserState.init());
final StoriesRepository _storiesRepository;
void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading));
_storiesRepository.fetchUserBy(userId: userId).then((User user) {
emit(state.copyWith(user: user, status: UserStatus.loaded));
}).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure));
return;
});
}
}

View File

@ -0,0 +1,38 @@
part of 'user_cubit.dart';
enum UserStatus {
initial,
loading,
loaded,
failure,
}
class UserState extends Equatable {
const UserState({
required this.user,
required this.status,
});
UserState.init()
: user = User.empty(),
status = UserStatus.initial;
final User user;
final UserStatus status;
UserState copyWith({
User? user,
UserStatus? status,
}) {
return UserState(
user: user ?? this.user,
status: status ?? this.status,
);
}
@override
List<Object?> get props => <Object?>[
user,
status,
];
}

View File

@ -0,0 +1,19 @@
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;
}
}

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

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.dart';
import 'package:hacki/screens/screens.dart' show StoryScreen, StoryScreenArgs; import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
extension StateExtension on State { extension StateExtension on State {
void showSnackBar({ void showSnackBar({
@ -12,7 +13,7 @@ extension StateExtension on State {
}) { }) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
backgroundColor: Colors.deepOrange, backgroundColor: Palette.deepOrange,
content: Text(content), content: Text(content),
action: action != null && label != null action: action != null && label != null
? SnackBarAction( ? SnackBarAction(
@ -26,14 +27,17 @@ extension StateExtension on State {
); );
} }
Future<void>? goToStoryScreen({required StoryScreenArgs args}) { Future<void>? goToItemScreen({
required ItemScreenArgs args,
bool forceNewScreen = false,
}) {
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled; final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
if (splitViewEnabled) { if (splitViewEnabled && !forceNewScreen) {
context.read<SplitViewCubit>().updateStoryScreenArgs(args); context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else { } else {
return HackiApp.navigatorKey.currentState?.pushNamed( return HackiApp.navigatorKey.currentState?.pushNamed(
StoryScreen.routeName, ItemScreen.routeName,
arguments: args, arguments: args,
); );
} }

View File

@ -1,7 +1,8 @@
extension StringExtension on String { extension StringExtension on String {
int? getItemId() { int? getItemId() {
final RegExp regex = RegExp(r'\d+$'); final RegExp regex = RegExp(r'\d+$');
final String match = regex.stringMatch(this) ?? ''; final RegExp exception = RegExp(r'\)|].*$');
final String match = regex.stringMatch(replaceAll(exception, '')) ?? '';
return int.tryParse(match); return int.tryParse(match);
} }

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,17 +3,21 @@ 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';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/custom_router.dart'; import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/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:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject; import 'package:rxdart/rxdart.dart' show BehaviorSubject;
@ -24,9 +28,17 @@ import 'package:workmanager/workmanager.dart';
final BehaviorSubject<String?> selectNotificationSubject = final BehaviorSubject<String?> selectNotificationSubject =
BehaviorSubject<String?>(); BehaviorSubject<String?>();
Future<void> main() async { // For receiving payload event from siri suggestions.
final BehaviorSubject<String?> siriSuggestionSubject =
BehaviorSubject<String?>();
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(
@ -57,6 +69,16 @@ Future<void> main() async {
badge: true, badge: true,
sound: true, sound: true,
); );
FlutterSiriSuggestions.instance.configure(
onLaunch: (Map<String, dynamic> message) async {
final String? storyId = message['key'] as String?;
if (storyId == null) return;
siriSuggestionSubject.add(storyId);
},
);
} }
final Directory tempDir = await getTemporaryDirectory(); final Directory tempDir = await getTemporaryDirectory();
@ -70,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(
// savedThemeMode: savedThemeMode,
// trueDarkMode: trueDarkMode,
// ),
// );
// },
// blocObserver: CustomBlocObserver(),
// );
runApp( runApp(
HackiApp( HackiApp(
savedThemeMode: savedThemeMode, savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode, trueDarkMode: trueDarkMode,
), ),
); );
} else {
BlocOverrides.runZoned(
() {
runApp(
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
),
);
},
blocObserver: CustomBlocObserver(),
);
}
} }
class HackiApp extends StatelessWidget { class HackiApp extends StatelessWidget {
@ -164,26 +187,22 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (BuildContext context) => PostCubit(), create: (BuildContext context) => PostCubit(),
), ),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
], ],
child: AdaptiveTheme( child: AdaptiveTheme(
light: ThemeData( light: ThemeData(
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
), ),
dark: ThemeData( dark: ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
canvasColor: trueDarkMode ? Colors.black : null, canvasColor: trueDarkMode ? Palette.black : null,
), ),
initial: savedThemeMode ?? AdaptiveThemeMode.system, initial: savedThemeMode ?? AdaptiveThemeMode.system,
builder: (ThemeData theme, ThemeData darkTheme) { builder: (ThemeData theme, ThemeData darkTheme) {
final ThemeData trueDarkTheme = ThemeData( final ThemeData trueDarkTheme = ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
canvasColor: Colors.black, canvasColor: Palette.black,
); );
return FutureBuilder<AdaptiveThemeMode?>( return FutureBuilder<AdaptiveThemeMode?>(
future: AdaptiveTheme.getThemeMode(), future: AdaptiveTheme.getThemeMode(),

View File

@ -0,0 +1,35 @@
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/models.dart';
class BuildableComment extends Comment {
BuildableComment({
required super.id,
required super.time,
required super.parent,
required super.score,
required super.by,
required super.text,
required super.kids,
required super.dead,
required super.deleted,
required super.level,
required this.elements,
});
BuildableComment.fromComment(Comment comment, {required this.elements})
: super(
id: comment.id,
time: comment.time,
parent: comment.parent,
score: comment.score,
by: comment.by,
text: comment.text,
kids: comment.kids,
dead: comment.dead,
deleted: comment.deleted,
level: comment.level,
);
final List<LinkifyElement> elements;
}

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
class Comment extends Item { class Comment extends Item {
@ -12,11 +11,11 @@ class Comment extends Item {
required super.by, required super.by,
required super.text, required super.text,
required super.kids, required super.kids,
required super.dead,
required super.deleted, required super.deleted,
required this.level, required this.level,
}) : super( }) : super(
descendants: 0, descendants: 0,
dead: false,
parts: <int>[], parts: <int>[],
title: '', title: '',
url: '', url: '',
@ -43,8 +42,7 @@ class Comment extends Item {
final int level; final int level;
String get postedDate => String get metadata => '''by $by $postedDate''';
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
Comment copyWith({int? level}) { Comment copyWith({int? level}) {
return Comment( return Comment(
@ -55,6 +53,7 @@ class Comment extends Item {
by: by, by: by,
text: text, text: text,
kids: kids, kids: kids,
dead: dead,
deleted: deleted, deleted: deleted,
level: level ?? this.level, level: level ?? this.level,
); );

View File

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
abstract class Item extends Equatable { abstract class Item extends Equatable {
const Item({ const Item({
@ -54,6 +55,9 @@ abstract class Item extends Equatable {
final List<int> kids; final List<int> kids;
final List<int> parts; final List<int> parts;
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';
bool get isStory => type == 'story'; bool get isStory => type == 'story';

View File

@ -1,3 +1,4 @@
export 'buildable_comment.dart';
export 'comment.dart'; export 'comment.dart';
export 'item.dart'; export 'item.dart';
export 'poll_option.dart'; export 'poll_option.dart';

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
class PollOption extends Item { class PollOption extends Item {
@ -63,9 +62,6 @@ class PollOption extends Item {
final double ratio; final double ratio;
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
PollOption copyWith({double? ratio}) { PollOption copyWith({double? ratio}) {
return PollOption( return PollOption(
id: id, id: id,

View File

@ -1,6 +1,3 @@
import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
enum StoryType { enum StoryType {
@ -94,9 +91,6 @@ class Story extends Item {
String get simpleMetadata => String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate'''; '''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
'descendants': descendants, 'descendants': descendants,
@ -117,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

@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart';
class User { class User {
User({ User({
required this.about, required this.about,
@ -29,6 +31,12 @@ class User {
final String id; final String id;
final int karma; final int karma;
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();
String get description {
return '''$karma karma, created on ${_dateTimeFormatter.format(DateTime.fromMillisecondsSinceEpoch(created * 1000))}''';
}
@override @override
String toString() { String toString() {
final String prettyString = final String prettyString =

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';
@ -38,10 +42,15 @@ class AuthRepository extends PostableRepository {
final bool success = await performDefaultPost(uri, data); final bool success = await performDefaultPost(uri, data);
if (success) { if (success) {
try {
await _preferenceRepository.setAuth( await _preferenceRepository.setAuth(
username: username, username: username,
password: password, password: password,
); );
} catch (_) {
_logger.e(_);
return false;
}
} }
return success; return success;

View File

@ -1,127 +0,0 @@
import 'package:hacki/models/models.dart';
import 'package:hive/hive.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<LazyBox<Map<dynamic, dynamic>>>? commentBox,
}) : _storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
static const String _storyIdBoxName = 'storyIdBox';
static const String _storyBoxName = 'storyBox';
static const String _commentBoxName = 'commentBox';
final Future<Box<List<int>>> _storyIdBox;
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
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<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> deleteAll() async {
return deleteAllStoryIds()
.whenComplete(deleteAllStories)
.whenComplete(deleteAllComments);
}
}

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));
@ -140,8 +165,29 @@ class PreferenceRepository {
required String username, required String username,
required String password, required String password,
}) async { }) async {
await _secureStorage.write(key: _usernameKey, value: username); const AndroidOptions androidOptions = AndroidOptions(resetOnError: true);
await _secureStorage.write(key: _passwordKey, value: password); try {
await _secureStorage.write(
key: _usernameKey,
value: username,
aOptions: androidOptions,
);
await _secureStorage.write(
key: _passwordKey,
value: password,
aOptions: androidOptions,
);
} catch (_) {
try {
await _secureStorage.deleteAll(
aOptions: androidOptions,
);
} catch (_) {
_logger.e(_);
}
rethrow;
}
} }
Future<void> removeAuth() async { Future<void> removeAuth() async {
@ -165,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);
} }
@ -205,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 {

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