Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
d83381a7fd | |||
764ff09345 | |||
ab449adce2 | |||
2ec41b26f2 | |||
19f2107d95 | |||
c9b2d82dfc | |||
56e442b09f | |||
9069efcced | |||
bf6a5667dc | |||
cff73a010b | |||
f0d6cac3fd | |||
a90d52f348 | |||
cff4a3c5c4 | |||
502faaf188 | |||
b952f349fc | |||
9cefffa518 | |||
fe630ea7a9 | |||
459ab961d1 | |||
362d7005df | |||
6b7c1d42de | |||
2a889bca56 | |||
e25026f129 | |||
fefc86275d | |||
1a73a6991e | |||
36d7f4606e | |||
9312c56dd0 | |||
6e71de5913 |
13
.github/FUNDING.yml
vendored
Normal 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']
|
4
.github/workflows/github-actions.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JAVA_VERSION: "11.0"
|
||||
FLUTTER_VERSION: "3.0.0"
|
||||
FLUTTER_VERSION: "3.0.5"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-java@v2
|
||||
@ -20,7 +20,7 @@ jobs:
|
||||
java-version: '17'
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.0.0'
|
||||
flutter-version: '3.0.5'
|
||||
channel: 'stable'
|
||||
- run: flutter pub get
|
||||
- run: flutter analyze
|
||||
|
@ -1,17 +1,13 @@
|
||||
|
||||
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News
|
||||
|
||||
A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
||||
A [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
||||
|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||
[](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
|
||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||
[](https://github.com/Livinglist/Hacki/releases/latest)
|
||||
[](https://badges.pufler.dev)
|
||||
[](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
|
||||
[](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>
|
||||
[](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
|
||||
|
||||
[<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/)
|
||||
|
||||
|
@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
compileSdkVersion 32
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
@ -49,10 +49,9 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.jiaqifeng.hacki"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 30
|
||||
targetSdkVersion 32
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@ -78,5 +77,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
}
|
||||
|
@ -16,14 +16,18 @@
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="hacki"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:label="Hacki"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@ -52,6 +56,21 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
4
android/app/src/main/res/xml/backup_rules.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="FlutterSecureStorage"/>
|
||||
</full-backup-content>
|
@ -1,12 +1,12 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.7.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 820 KiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 121 KiB |
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 375 KiB |
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 406 KiB |
@ -1 +0,0 @@
|
||||
- Bugfixes.
|
@ -1 +0,0 @@
|
||||
- Updates to UI.
|
@ -1 +0,0 @@
|
||||
- Updates to UI.
|
@ -1 +0,0 @@
|
||||
- Updates to UI.
|
@ -1 +0,0 @@
|
||||
- Tapping on comments in notification and history screen will lead you directly to the comment.
|
@ -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.
|
@ -1 +0,0 @@
|
||||
- Added offline mode.
|
@ -1,2 +0,0 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
@ -1,2 +0,0 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
@ -1,2 +0,0 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
1
fastlane/metadata/android/en-US/changelogs/58.txt
Normal file
@ -0,0 +1 @@
|
||||
- Offline mode now includes web pages.
|
1
fastlane/metadata/android/en-US/changelogs/59.txt
Normal file
@ -0,0 +1 @@
|
||||
- Offline mode now includes web pages.
|
1
fastlane/metadata/android/en-US/changelogs/60.txt
Normal file
@ -0,0 +1 @@
|
||||
- Offline mode now includes web pages.
|
1
fastlane/metadata/android/en-US/changelogs/61.txt
Normal file
@ -0,0 +1 @@
|
||||
- Offline mode now includes web pages.
|
2
fastlane/metadata/android/en-US/changelogs/62.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/63.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/64.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/65.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/66.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/67.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/68.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/69.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/70.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/71.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/72.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/73.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 820 KiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 121 KiB |
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 375 KiB |
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 406 KiB |
46
integration_test/app_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
@ -14,15 +14,21 @@ PODS:
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- Flutter
|
||||
- flutter_siri_suggestions (0.0.1):
|
||||
- Flutter
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (5.0.0)
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- ReachabilitySwift (5.0.0)
|
||||
- receive_sharing_intent (0.0.1):
|
||||
- Flutter
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- Flutter
|
||||
- sqflite (0.0.2):
|
||||
@ -32,8 +38,6 @@ PODS:
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- wakelock (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
@ -47,13 +51,15 @@ DEPENDENCIES:
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/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`)
|
||||
- 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`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/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`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
@ -75,10 +81,16 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage:
|
||||
: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: ".symlinks/plugins/path_provider_ios/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
sqflite:
|
||||
@ -87,8 +99,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/synced_shared_preferences/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
||||
wakelock:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
webview_flutter_wkwebview:
|
||||
@ -102,16 +112,18 @@ SPEC CHECKSUMS:
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
@ -568,7 +568,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -577,7 +577,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.16;
|
||||
MARKETING_VERSION = 0.2.30;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -705,7 +705,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -714,7 +714,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.16;
|
||||
MARKETING_VERSION = 0.2.30;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -736,7 +736,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -745,7 +745,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.16;
|
||||
MARKETING_VERSION = 0.2.30;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -7,7 +7,9 @@ import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:responsive_builder/responsive_builder.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'stories_event.dart';
|
||||
part 'stories_state.dart';
|
||||
@ -15,15 +17,18 @@ part 'stories_state.dart';
|
||||
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesBloc({
|
||||
required PreferenceCubit preferenceCubit,
|
||||
CacheRepository? cacheRepository,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(const StoriesState.init()) {
|
||||
on<StoriesInitialize>(onInitialize);
|
||||
on<StoriesRefresh>(onRefresh);
|
||||
@ -32,15 +37,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
on<StoryRead>(onStoryRead);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoryDownloaded>(onStoryDownloaded);
|
||||
on<StoriesExitOffline>(onExitOffline);
|
||||
on<StoriesPageSizeChanged>(onPageSizeChanged);
|
||||
on<ClearAllReadStories>(onClearAllReadStories);
|
||||
}
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final CacheRepository _cacheRepository;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
DeviceScreenType? deviceScreenType;
|
||||
StreamSubscription<PreferenceState>? _streamSubscription;
|
||||
static const int _smallPageSize = 10;
|
||||
@ -70,7 +77,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesPageSizeChanged(pageSize: pageSize));
|
||||
}
|
||||
});
|
||||
final bool hasCachedStories = await _cacheRepository.hasCachedStories;
|
||||
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
|
||||
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
@ -89,13 +96,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
required Emitter<StoriesState> emit,
|
||||
}) async {
|
||||
if (state.offlineReading) {
|
||||
final List<int> ids = await _cacheRepository.getCachedStoryIds(of: of);
|
||||
final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0),
|
||||
);
|
||||
_cacheRepository
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
|
||||
)
|
||||
@ -166,7 +173,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
if (state.offlineReading) {
|
||||
_cacheRepository
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
@ -240,39 +247,46 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
),
|
||||
);
|
||||
|
||||
await _cacheRepository.deleteAllStoryIds();
|
||||
await _cacheRepository.deleteAllStories();
|
||||
await _cacheRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
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);
|
||||
await _cacheRepository.cacheStoryIds(of: type, ids: ids);
|
||||
allIds.addAll(ids);
|
||||
await _offlineRepository.cacheStoryIds(of: type, ids: ids);
|
||||
prioritizedIds.addAll(ids);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
storiesDownloaded: 0,
|
||||
storiesToBeDownloaded: prioritizedIds.length,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.finished,
|
||||
),
|
||||
);
|
||||
});
|
||||
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 (_) {
|
||||
emit(
|
||||
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(
|
||||
StoriesPageSizeChanged event,
|
||||
Emitter<StoriesState> emit,
|
||||
@ -294,9 +382,10 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesExitOffline event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
await _cacheRepository.deleteAllStoryIds();
|
||||
await _cacheRepository.deleteAllStories();
|
||||
await _cacheRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllWebPages();
|
||||
emit(state.copyWith(offlineReading: false));
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
@ -38,8 +38,21 @@ class StoriesLoadMore extends StoriesEvent {
|
||||
}
|
||||
|
||||
class StoriesDownload extends StoriesEvent {
|
||||
StoriesDownload({required this.includingWebPage});
|
||||
|
||||
final bool includingWebPage;
|
||||
|
||||
@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 {
|
||||
|
@ -23,6 +23,8 @@ class StoriesState extends Equatable {
|
||||
required this.offlineReading,
|
||||
required this.downloadStatus,
|
||||
required this.currentPageSize,
|
||||
required this.storiesDownloaded,
|
||||
required this.storiesToBeDownloaded,
|
||||
});
|
||||
|
||||
const StoriesState.init({
|
||||
@ -61,7 +63,9 @@ class StoriesState extends Equatable {
|
||||
}) : offlineReading = false,
|
||||
downloadStatus = StoriesDownloadStatus.initial,
|
||||
currentPageSize = 0,
|
||||
readStoriesIds = const <int>{};
|
||||
readStoriesIds = const <int>{},
|
||||
storiesDownloaded = 0,
|
||||
storiesToBeDownloaded = 0;
|
||||
|
||||
final Map<StoryType, List<Story>> storiesByType;
|
||||
final Map<StoryType, List<int>> storyIdsByType;
|
||||
@ -71,6 +75,8 @@ class StoriesState extends Equatable {
|
||||
final StoriesDownloadStatus downloadStatus;
|
||||
final bool offlineReading;
|
||||
final int currentPageSize;
|
||||
final int storiesDownloaded;
|
||||
final int storiesToBeDownloaded;
|
||||
|
||||
StoriesState copyWith({
|
||||
Map<StoryType, List<Story>>? storiesByType,
|
||||
@ -81,6 +87,8 @@ class StoriesState extends Equatable {
|
||||
StoriesDownloadStatus? downloadStatus,
|
||||
bool? offlineReading,
|
||||
int? currentPageSize,
|
||||
int? storiesDownloaded,
|
||||
int? storiesToBeDownloaded,
|
||||
}) {
|
||||
return StoriesState(
|
||||
storiesByType: storiesByType ?? this.storiesByType,
|
||||
@ -91,6 +99,9 @@ class StoriesState extends Equatable {
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
downloadStatus: downloadStatus ?? this.downloadStatus,
|
||||
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 =
|
||||
Map<StoryType, List<Story>>.from(storiesByType);
|
||||
newMap[of] = List<Story>.from(newMap[of]!)..add(story);
|
||||
return StoriesState(
|
||||
return copyWith(
|
||||
storiesByType: newMap,
|
||||
storyIdsByType: storyIdsByType,
|
||||
statusByType: statusByType,
|
||||
currentPageByType: currentPageByType,
|
||||
readStoriesIds: <int>{
|
||||
...readStoriesIds,
|
||||
if (hasRead) story.id,
|
||||
},
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
currentPageSize: currentPageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@ -124,15 +129,8 @@ class StoriesState extends Equatable {
|
||||
final Map<StoryType, List<int>> newMap =
|
||||
Map<StoryType, List<int>>.from(storyIdsByType);
|
||||
newMap[of] = to;
|
||||
return StoriesState(
|
||||
storiesByType: storiesByType,
|
||||
return copyWith(
|
||||
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 =
|
||||
Map<StoryType, StoriesStatus>.from(statusByType);
|
||||
newMap[of] = to;
|
||||
return StoriesState(
|
||||
storiesByType: storiesByType,
|
||||
storyIdsByType: storyIdsByType,
|
||||
return copyWith(
|
||||
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 =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newMap[of] = to;
|
||||
return StoriesState(
|
||||
storiesByType: storiesByType,
|
||||
storyIdsByType: storyIdsByType,
|
||||
statusByType: statusByType,
|
||||
return copyWith(
|
||||
currentPageByType: newMap,
|
||||
readStoriesIds: readStoriesIds,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
currentPageSize: currentPageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@ -187,15 +171,11 @@ class StoriesState extends Equatable {
|
||||
final Map<StoryType, int> newCurrentPageMap =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newCurrentPageMap[of] = 0;
|
||||
return StoriesState(
|
||||
return copyWith(
|
||||
storiesByType: newStoriesMap,
|
||||
storyIdsByType: newStoryIdsMap,
|
||||
statusByType: newStatusMap,
|
||||
currentPageByType: newCurrentPageMap,
|
||||
readStoriesIds: readStoriesIds,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
currentPageSize: currentPageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@ -209,5 +189,7 @@ class StoriesState extends Equatable {
|
||||
offlineReading,
|
||||
downloadStatus,
|
||||
currentPageSize,
|
||||
storiesDownloaded,
|
||||
storiesToBeDownloaded,
|
||||
];
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ abstract class Constants {
|
||||
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
|
||||
static const String googlePlayLink =
|
||||
'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 hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
|
||||
|
31
lib/config/custom_log_filter.dart
Normal 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;
|
||||
}
|
||||
}
|
@ -10,8 +10,8 @@ class CustomRouter {
|
||||
switch (settings.name) {
|
||||
case HomeScreen.routeName:
|
||||
return HomeScreen.route();
|
||||
case StoryScreen.routeName:
|
||||
return StoryScreen.route(settings.arguments! as StoryScreenArgs);
|
||||
case ItemScreen.routeName:
|
||||
return ItemScreen.route(settings.arguments! as ItemScreenArgs);
|
||||
case SubmitScreen.routeName:
|
||||
return SubmitScreen.route();
|
||||
default:
|
||||
@ -22,8 +22,8 @@ class CustomRouter {
|
||||
/// Nested routing for bottom navigation bar.
|
||||
static Route<dynamic> onGenerateNestedRoute(RouteSettings settings) {
|
||||
switch (settings.name) {
|
||||
case StoryScreen.routeName:
|
||||
return StoryScreen.route(settings.arguments! as StoryScreenArgs);
|
||||
case ItemScreen.routeName:
|
||||
return ItemScreen.route(settings.arguments! as ItemScreenArgs);
|
||||
case SubmitScreen.routeName:
|
||||
return SubmitScreen.route();
|
||||
default:
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.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/services/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
@ -10,16 +11,17 @@ final GetIt locator = GetIt.instance;
|
||||
/// Set up [GetIt] locator.
|
||||
Future<void> setUpLocator() async {
|
||||
locator
|
||||
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
|
||||
..registerSingleton<StoriesRepository>(StoriesRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
..registerSingleton<AuthRepository>(AuthRepository())
|
||||
..registerSingleton<PostRepository>(PostRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<CacheRepository>(CacheRepository())
|
||||
..registerSingleton<CacheService>(CacheService())
|
||||
..registerSingleton<OfflineRepository>(OfflineRepository())
|
||||
..registerSingleton<DraftCache>(DraftCache())
|
||||
..registerSingleton<CommentCache>(CommentCache())
|
||||
..registerSingleton<LocalNotification>(LocalNotification())
|
||||
..registerSingleton<Logger>(Logger())
|
||||
..registerSingleton<RouteObserver<ModalRoute<dynamic>>>(
|
||||
RouteObserver<ModalRoute<dynamic>>(),
|
||||
);
|
||||
|
@ -10,31 +10,31 @@ part 'collapse_state.dart';
|
||||
class CollapseCubit extends Cubit<CollapseState> {
|
||||
CollapseCubit({
|
||||
required int commentId,
|
||||
CacheService? cacheService,
|
||||
CollapseCache? collapseCache,
|
||||
}) : _commentId = commentId,
|
||||
_cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
_collapseCache = collapseCache ?? locator.get<CollapseCache>(),
|
||||
super(const CollapseState.init());
|
||||
|
||||
final int _commentId;
|
||||
final CacheService _cacheService;
|
||||
final CollapseCache _collapseCache;
|
||||
late final StreamSubscription<Map<int, Set<int>>> _streamSubscription;
|
||||
|
||||
void init() {
|
||||
_streamSubscription =
|
||||
_cacheService.hiddenComments.listen(hiddenCommentsStreamListener);
|
||||
_collapseCache.hiddenComments.listen(hiddenCommentsStreamListener);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsedCount: _cacheService.totalHidden(_commentId),
|
||||
collapsed: _cacheService.isCollapsed(_commentId),
|
||||
hidden: _cacheService.isHidden(_commentId),
|
||||
collapsedCount: _collapseCache.totalHidden(_commentId),
|
||||
collapsed: _collapseCache.isCollapsed(_commentId),
|
||||
hidden: _collapseCache.isHidden(_commentId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
if (state.collapsed) {
|
||||
_cacheService.uncollapse(_commentId);
|
||||
_collapseCache.uncollapse(_commentId);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -43,7 +43,7 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int count = _cacheService.collapse(_commentId);
|
||||
final int count = _collapseCache.collapse(_commentId);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
@ -1,36 +1,66 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.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/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'comments_state.dart';
|
||||
|
||||
class CommentsCubit extends Cubit<CommentsState> {
|
||||
CommentsCubit({
|
||||
CacheService? cacheService,
|
||||
CacheRepository? cacheRepository,
|
||||
required CollapseCache collapseCache,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
required bool offlineReading,
|
||||
required Story story,
|
||||
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
|
||||
required Item item,
|
||||
required FetchMode defaultFetchMode,
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
}) : _collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(CommentsState.init(offlineReading: offlineReading, story: story));
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
offlineReading: offlineReading,
|
||||
item: item,
|
||||
fetchMode: defaultFetchMode,
|
||||
order: defaultCommentsOrder,
|
||||
),
|
||||
);
|
||||
|
||||
final CacheService _cacheService;
|
||||
final CacheRepository _cacheRepository;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final Logger _logger;
|
||||
|
||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||
/// fetching comments posted directly to the story.
|
||||
StreamSubscription<Comment>? _streamSubscription;
|
||||
|
||||
/// The map of [StreamSubscription] for streams
|
||||
/// fetching comments lazily. [int] is the id of parent comment.
|
||||
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
|
||||
<int, StreamSubscription<Comment>>{};
|
||||
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@override
|
||||
@ -42,6 +72,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
Future<void> init({
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetParents,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
|
||||
@ -54,7 +85,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetParents!.last.kids,
|
||||
level: targetParents.last.level + 1,
|
||||
)
|
||||
@ -64,34 +95,53 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: CommentsStatus.loading));
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loading,
|
||||
comments: <Comment>[],
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final Story story = state.story;
|
||||
final Story updatedStory = state.offlineReading
|
||||
? story
|
||||
: await _storiesRepository.fetchStoryBy(story.id) ?? story;
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.offlineReading
|
||||
? item
|
||||
: 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) {
|
||||
_streamSubscription = _cacheRepository
|
||||
.getCachedCommentsStream(ids: updatedStory.kids)
|
||||
_streamSubscription = _offlineRepository
|
||||
.getCachedCommentsStream(ids: kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(ids: updatedStory.kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
break;
|
||||
case FetchMode.eager:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final bool offlineReading = await _cacheRepository.hasCachedStories;
|
||||
|
||||
_cacheService.resetCollapsedComments();
|
||||
|
||||
if (offlineReading) {
|
||||
if (state.offlineReading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
@ -103,43 +153,179 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loading,
|
||||
comments: <Comment>[],
|
||||
),
|
||||
);
|
||||
|
||||
await _streamSubscription?.cancel();
|
||||
_collapseCache.resetCollapsedComments();
|
||||
|
||||
final Story story = state.story;
|
||||
final Story updatedStory =
|
||||
await _storiesRepository.fetchStoryBy(story.id) ?? story;
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(ids: updatedStory.kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
await _streamSubscription?.cancel();
|
||||
for (final int id in _streamSubscriptions.keys) {
|
||||
await _streamSubscriptions[id]?.cancel();
|
||||
}
|
||||
_streamSubscriptions.clear();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
story: updatedStory,
|
||||
comments: <Comment>[],
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem =
|
||||
await _storiesRepository.fetchItemBy(id: item.id) ?? item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
item: updatedItem,
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void loadAll(Story story) {
|
||||
HapticFeedback.lightImpact();
|
||||
emit(
|
||||
state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
comments: <Comment>[],
|
||||
story: story,
|
||||
item: story,
|
||||
),
|
||||
);
|
||||
init();
|
||||
}
|
||||
|
||||
void loadMore() {
|
||||
if (_streamSubscription != null) {
|
||||
emit(state.copyWith(status: CommentsStatus.loading));
|
||||
_streamSubscription?.resume();
|
||||
/// [comment] is only used for lazy fetching.
|
||||
void loadMore({Comment? comment}) {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
if (comment == null) return;
|
||||
if (_streamSubscriptions.containsKey(comment.id)) return;
|
||||
|
||||
final int level = comment.level + 1;
|
||||
int offset = 0;
|
||||
|
||||
/// Ignoring because the subscription will be cancelled in close()
|
||||
// ignore: cancel_subscriptions
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
cmt.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(cmt, elements: elements);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: <Comment>[...state.comments]..insert(
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
buildableComment.copyWith(level: level),
|
||||
),
|
||||
),
|
||||
);
|
||||
offset++;
|
||||
})
|
||||
..onDone(() {
|
||||
_streamSubscriptions[comment.id]?.cancel();
|
||||
_streamSubscriptions.remove(comment.id);
|
||||
})
|
||||
..onError((dynamic error) {
|
||||
_logger.e(error);
|
||||
_streamSubscriptions[comment.id]?.cancel();
|
||||
_streamSubscriptions.remove(comment.id);
|
||||
});
|
||||
|
||||
_streamSubscriptions[comment.id] = streamSubscription;
|
||||
break;
|
||||
case FetchMode.eager:
|
||||
if (_streamSubscription != null) {
|
||||
emit(state.copyWith(status: CommentsStatus.loading));
|
||||
_streamSubscription?.resume();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
|
||||
s.cancel();
|
||||
}
|
||||
_streamSubscriptions.clear();
|
||||
emit(state.copyWith(order: order));
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
void onFetchModeChanged(FetchMode? fetchMode) {
|
||||
if (fetchMode == null) return;
|
||||
if (state.fetchMode == fetchMode) return;
|
||||
_collapseCache.resetCollapsedComments();
|
||||
HapticFeedback.selectionClick();
|
||||
_streamSubscription?.cancel();
|
||||
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
|
||||
s.cancel();
|
||||
}
|
||||
_streamSubscriptions.clear();
|
||||
emit(state.copyWith(fetchMode: fetchMode));
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
List<int> sortKids(List<int> kids) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,34 +341,77 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
if (comment != null) {
|
||||
_cacheService
|
||||
..addKid(comment.id, to: comment.parent)
|
||||
..cacheComment(comment);
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.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>[
|
||||
...state.comments,
|
||||
comment
|
||||
buildableComment
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
|
||||
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage &&
|
||||
updatedComments.length <=
|
||||
_pageSize * 2 + _pageSize * state.currentPage) {
|
||||
_streamSubscription?.pause();
|
||||
if (state.fetchMode == FetchMode.eager) {
|
||||
if (updatedComments.length >=
|
||||
_pageSize + _pageSize * state.currentPage &&
|
||||
updatedComments.length <=
|
||||
_pageSize * 2 + _pageSize * state.currentPage) {
|
||||
final bool isHidden = _collapseCache.isHidden(comment.id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentPage: state.currentPage + 1,
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
if (!isHidden) {
|
||||
_streamSubscription?.pause();
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentPage: state.currentPage + 1,
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Future<void> close() async {
|
||||
await _streamSubscription?.cancel();
|
||||
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
|
||||
await s.cancel();
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
@ -8,12 +8,25 @@ enum CommentsStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
enum CommentsOrder {
|
||||
natural,
|
||||
newestFirst,
|
||||
oldestFirst,
|
||||
}
|
||||
|
||||
enum FetchMode {
|
||||
lazy,
|
||||
eager,
|
||||
}
|
||||
|
||||
class CommentsState extends Equatable {
|
||||
const CommentsState({
|
||||
required this.story,
|
||||
required this.item,
|
||||
required this.comments,
|
||||
required this.status,
|
||||
required this.collapsed,
|
||||
required this.fetchParentStatus,
|
||||
required this.order,
|
||||
required this.fetchMode,
|
||||
required this.onlyShowTargetComment,
|
||||
required this.offlineReading,
|
||||
required this.currentPage,
|
||||
@ -21,35 +34,43 @@ class CommentsState extends Equatable {
|
||||
|
||||
CommentsState.init({
|
||||
required this.offlineReading,
|
||||
required this.story,
|
||||
required this.item,
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
}) : comments = <Comment>[],
|
||||
status = CommentsStatus.init,
|
||||
collapsed = false,
|
||||
fetchParentStatus = CommentsStatus.init,
|
||||
onlyShowTargetComment = false,
|
||||
currentPage = 0;
|
||||
|
||||
final Story story;
|
||||
final Item item;
|
||||
final List<Comment> comments;
|
||||
final CommentsStatus status;
|
||||
final bool collapsed;
|
||||
final CommentsStatus fetchParentStatus;
|
||||
final CommentsOrder order;
|
||||
final FetchMode fetchMode;
|
||||
final bool onlyShowTargetComment;
|
||||
final bool offlineReading;
|
||||
final int currentPage;
|
||||
|
||||
CommentsState copyWith({
|
||||
Story? story,
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
CommentsStatus? status,
|
||||
bool? collapsed,
|
||||
CommentsStatus? fetchParentStatus,
|
||||
CommentsOrder? order,
|
||||
FetchMode? fetchMode,
|
||||
bool? onlyShowTargetComment,
|
||||
bool? offlineReading,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return CommentsState(
|
||||
story: story ?? this.story,
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
||||
status: status ?? this.status,
|
||||
collapsed: collapsed ?? this.collapsed,
|
||||
order: order ?? this.order,
|
||||
fetchMode: fetchMode ?? this.fetchMode,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
@ -57,12 +78,16 @@ class CommentsState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
story,
|
||||
item,
|
||||
comments,
|
||||
status,
|
||||
collapsed,
|
||||
fetchParentStatus,
|
||||
order,
|
||||
fetchMode,
|
||||
onlyShowTargetComment,
|
||||
offlineReading,
|
||||
currentPage,
|
||||
|
@ -14,4 +14,5 @@ export 'search/search_cubit.dart';
|
||||
export 'split_view/split_view_cubit.dart';
|
||||
export 'submit/submit_cubit.dart';
|
||||
export 'time_machine/time_machine_cubit.dart';
|
||||
export 'user/user_cubit.dart';
|
||||
export 'vote/vote_cubit.dart';
|
||||
|
@ -1,26 +1,27 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/debouncer.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
part 'edit_state.dart';
|
||||
|
||||
class EditCubit extends Cubit<EditState> {
|
||||
EditCubit({CacheService? cacheService})
|
||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
class EditCubit extends HydratedCubit<EditState> {
|
||||
EditCubit({DraftCache? draftCache})
|
||||
: _draftCache = draftCache ?? locator.get<DraftCache>(),
|
||||
_debouncer = Debouncer(delay: const Duration(seconds: 1)),
|
||||
super(const EditState.init());
|
||||
|
||||
final CacheService _cacheService;
|
||||
final DraftCache _draftCache;
|
||||
final Debouncer _debouncer;
|
||||
|
||||
void onReplyTapped(Item item) {
|
||||
emit(
|
||||
EditState(
|
||||
replyingTo: item,
|
||||
text: _cacheService.getDraft(replyingTo: item.id),
|
||||
text: _draftCache.getDraft(replyingTo: item.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -44,9 +45,10 @@ class EditCubit extends Cubit<EditState> {
|
||||
|
||||
void onReplySubmittedSuccessfully() {
|
||||
if (state.replyingTo != null) {
|
||||
_cacheService.removeDraft(replyingTo: state.replyingTo!.id);
|
||||
_draftCache.removeDraft(replyingTo: state.replyingTo!.id);
|
||||
}
|
||||
emit(const EditState.init());
|
||||
clear();
|
||||
}
|
||||
|
||||
void onTextChanged(String text) {
|
||||
@ -54,11 +56,54 @@ class EditCubit extends Cubit<EditState> {
|
||||
if (state.replyingTo != null) {
|
||||
final int? id = state.replyingTo?.id;
|
||||
_debouncer.run(() {
|
||||
_cacheService.cacheDraft(
|
||||
_draftCache.cacheDraft(
|
||||
text: text,
|
||||
replyingTo: id!,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void deleteDraft() => clear();
|
||||
|
||||
@override
|
||||
EditState? fromJson(Map<String, dynamic> json) {
|
||||
final String text = json['text'] as String? ?? '';
|
||||
final Map<String, dynamic>? itemJson =
|
||||
json['item'] as Map<String, dynamic>?;
|
||||
final Item? replyingTo = itemJson == null ? null : Item.fromJson(itemJson);
|
||||
|
||||
if (replyingTo != null && text.isNotEmpty) {
|
||||
_draftCache.cacheDraft(text: text, replyingTo: replyingTo.id);
|
||||
|
||||
final EditState state = EditState(
|
||||
text: text,
|
||||
replyingTo: replyingTo,
|
||||
);
|
||||
|
||||
cachedState = state;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toJson(EditState state) {
|
||||
EditState selected = state;
|
||||
|
||||
if (state.replyingTo == null ||
|
||||
(state.replyingTo?.id != cachedState.replyingTo?.id &&
|
||||
state.text.isNullOrEmpty)) {
|
||||
selected = cachedState;
|
||||
}
|
||||
|
||||
return <String, dynamic>{
|
||||
'text': selected.text,
|
||||
'item': selected.replyingTo?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static EditState cachedState = const EditState.init();
|
||||
}
|
||||
|
@ -39,15 +39,15 @@ class FavCubit extends Cubit<FavState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
favIds: favIds,
|
||||
favStories: <Story>[],
|
||||
favItems: <Item>[],
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
.listen(_onStoryLoaded)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(
|
||||
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(
|
||||
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(
|
||||
state.copyWith(
|
||||
favIds: List<int>.from(state.favIds)..remove(id),
|
||||
favStories: List<Story>.from(state.favStories)
|
||||
..removeWhere((Story e) => e.id == id),
|
||||
favItems: List<Item>.from(state.favItems)
|
||||
..removeWhere((Item e) => e.id == id),
|
||||
),
|
||||
);
|
||||
|
||||
@ -120,13 +120,13 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
.fetchItemsStream(
|
||||
ids: state.favIds.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen(_onStoryLoaded)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(state.copyWith(status: FavStatus.loaded));
|
||||
});
|
||||
@ -142,7 +142,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
state.copyWith(
|
||||
status: FavStatus.loading,
|
||||
currentPage: 0,
|
||||
favStories: <Story>[],
|
||||
favItems: <Item>[],
|
||||
favIds: <int>[],
|
||||
),
|
||||
);
|
||||
@ -150,20 +150,20 @@ class FavCubit extends Cubit<FavState> {
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(state.copyWith(favIds: favIds));
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
.listen(_onStoryLoaded)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(state.copyWith(status: FavStatus.loaded));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _onStoryLoaded(Story story) {
|
||||
void _onItemLoaded(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
favStories: List<Story>.from(state.favStories)..add(story),
|
||||
favItems: List<Item>.from(state.favItems)..add(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -10,31 +10,31 @@ enum FavStatus {
|
||||
class FavState extends Equatable {
|
||||
const FavState({
|
||||
required this.favIds,
|
||||
required this.favStories,
|
||||
required this.favItems,
|
||||
required this.status,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
FavState.init()
|
||||
: favIds = <int>[],
|
||||
favStories = <Story>[],
|
||||
favItems = <Item>[],
|
||||
status = FavStatus.init,
|
||||
currentPage = 0;
|
||||
|
||||
final List<int> favIds;
|
||||
final List<Story> favStories;
|
||||
final List<Item> favItems;
|
||||
final FavStatus status;
|
||||
final int currentPage;
|
||||
|
||||
FavState copyWith({
|
||||
List<int>? favIds,
|
||||
List<Story>? favStories,
|
||||
List<Item>? favItems,
|
||||
FavStatus? status,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return FavState(
|
||||
favIds: favIds ?? this.favIds,
|
||||
favStories: favStories ?? this.favStories,
|
||||
favItems: favItems ?? this.favItems,
|
||||
status: status ?? this.status,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
@ -43,7 +43,7 @@ class FavState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
favIds,
|
||||
favStories,
|
||||
favItems,
|
||||
status,
|
||||
currentPage,
|
||||
];
|
||||
|
@ -33,7 +33,8 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
_preferenceRepository.shouldShowNotification
|
||||
.then((bool showNotification) {
|
||||
if (showNotification) {
|
||||
init();
|
||||
// Delaying the initialization to prevent janks in home screen.
|
||||
Future<void>.delayed(const Duration(seconds: 2), init);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/comments/comments_cubit.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
|
||||
part 'preference_state.dart';
|
||||
@ -33,6 +35,10 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
.then((bool value) => emit(state.copyWith(markReadStories: value)));
|
||||
_preferenceRepository.shouldShowMetadata
|
||||
.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() {
|
||||
@ -74,4 +80,18 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
emit(state.copyWith(showMetadata: !state.showMetadata));
|
||||
_preferenceRepository.toggleMetadataMode();
|
||||
}
|
||||
|
||||
void selectFetchMode(FetchMode? fetchMode) {
|
||||
if (fetchMode == null || state.fetchMode == fetchMode) return;
|
||||
HapticFeedback.lightImpact();
|
||||
emit(state.copyWith(fetchMode: fetchMode));
|
||||
_preferenceRepository.selectFetchMode(fetchMode);
|
||||
}
|
||||
|
||||
void selectCommentsOrder(CommentsOrder? order) {
|
||||
if (order == null || state.order == order) return;
|
||||
HapticFeedback.lightImpact();
|
||||
emit(state.copyWith(order: order));
|
||||
_preferenceRepository.selectCommentsOrder(order);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ class PreferenceState extends Equatable {
|
||||
required this.useReader,
|
||||
required this.markReadStories,
|
||||
required this.showMetadata,
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
});
|
||||
|
||||
const PreferenceState.init()
|
||||
@ -20,7 +22,9 @@ class PreferenceState extends Equatable {
|
||||
useTrueDark = false,
|
||||
useReader = false,
|
||||
markReadStories = false,
|
||||
showMetadata = false;
|
||||
showMetadata = false,
|
||||
fetchMode = FetchMode.eager,
|
||||
order = CommentsOrder.natural;
|
||||
|
||||
final bool showNotification;
|
||||
final bool showComplexStoryTile;
|
||||
@ -30,6 +34,8 @@ class PreferenceState extends Equatable {
|
||||
final bool useReader;
|
||||
final bool markReadStories;
|
||||
final bool showMetadata;
|
||||
final FetchMode fetchMode;
|
||||
final CommentsOrder order;
|
||||
|
||||
PreferenceState copyWith({
|
||||
bool? showNotification,
|
||||
@ -40,6 +46,8 @@ class PreferenceState extends Equatable {
|
||||
bool? useReader,
|
||||
bool? markReadStories,
|
||||
bool? showMetadata,
|
||||
FetchMode? fetchMode,
|
||||
CommentsOrder? order,
|
||||
}) {
|
||||
return PreferenceState(
|
||||
showNotification: showNotification ?? this.showNotification,
|
||||
@ -50,6 +58,8 @@ class PreferenceState extends Equatable {
|
||||
useReader: useReader ?? this.useReader,
|
||||
markReadStories: markReadStories ?? this.markReadStories,
|
||||
showMetadata: showMetadata ?? this.showMetadata,
|
||||
fetchMode: fetchMode ?? this.fetchMode,
|
||||
order: order ?? this.order,
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,5 +73,7 @@ class PreferenceState extends Equatable {
|
||||
useReader,
|
||||
markReadStories,
|
||||
showMetadata,
|
||||
fetchMode,
|
||||
order,
|
||||
];
|
||||
}
|
||||
|
@ -3,19 +3,25 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'split_view_state.dart';
|
||||
|
||||
class SplitViewCubit extends Cubit<SplitViewState> {
|
||||
SplitViewCubit({CacheService? cacheService})
|
||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
SplitViewCubit({
|
||||
CommentCache? commentCache,
|
||||
Logger? logger,
|
||||
}) : _commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(const SplitViewState.init());
|
||||
|
||||
final CacheService _cacheService;
|
||||
final Logger _logger;
|
||||
final CommentCache _commentCache;
|
||||
|
||||
void updateStoryScreenArgs(StoryScreenArgs args) {
|
||||
_cacheService.resetCollapsedComments();
|
||||
emit(state.copyWith(storyScreenArgs: args));
|
||||
void updateItemScreenArgs(ItemScreenArgs args) {
|
||||
_logger.i('resetting comments in CommentCache');
|
||||
_commentCache.resetComments();
|
||||
emit(state.copyWith(itemScreenArgs: args));
|
||||
}
|
||||
|
||||
void enableSplitView() => emit(state.copyWith(enabled: true));
|
||||
|
@ -2,7 +2,7 @@ part of 'split_view_cubit.dart';
|
||||
|
||||
class SplitViewState extends Equatable {
|
||||
const SplitViewState({
|
||||
required this.storyScreenArgs,
|
||||
required this.itemScreenArgs,
|
||||
required this.expanded,
|
||||
required this.enabled,
|
||||
});
|
||||
@ -10,21 +10,21 @@ class SplitViewState extends Equatable {
|
||||
const SplitViewState.init()
|
||||
: enabled = false,
|
||||
expanded = false,
|
||||
storyScreenArgs = null;
|
||||
itemScreenArgs = null;
|
||||
|
||||
final bool enabled;
|
||||
final bool expanded;
|
||||
final StoryScreenArgs? storyScreenArgs;
|
||||
final ItemScreenArgs? itemScreenArgs;
|
||||
|
||||
SplitViewState copyWith({
|
||||
bool? enabled,
|
||||
bool? expanded,
|
||||
StoryScreenArgs? storyScreenArgs,
|
||||
ItemScreenArgs? itemScreenArgs,
|
||||
}) {
|
||||
return SplitViewState(
|
||||
enabled: enabled ?? this.enabled,
|
||||
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?>[
|
||||
enabled,
|
||||
expanded,
|
||||
storyScreenArgs,
|
||||
itemScreenArgs,
|
||||
];
|
||||
}
|
||||
|
@ -3,34 +3,34 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart' show Comment;
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/cache_service.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
|
||||
part 'time_machine_state.dart';
|
||||
|
||||
class TimeMachineCubit extends Cubit<TimeMachineState> {
|
||||
TimeMachineCubit({
|
||||
SembastRepository? sembastRepository,
|
||||
CacheService? cacheService,
|
||||
CommentCache? commentCache,
|
||||
}) : _sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
super(TimeMachineState.init());
|
||||
|
||||
final SembastRepository _sembastRepository;
|
||||
final CacheService _cacheService;
|
||||
final CommentCache _commentCache;
|
||||
|
||||
Future<void> activateTimeMachine(Comment comment) async {
|
||||
emit(state.copyWith(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);
|
||||
|
||||
while (parent != null) {
|
||||
parents.insert(0, parent);
|
||||
|
||||
final int parentId = parent.parent;
|
||||
parent = _cacheService.getComment(parentId);
|
||||
parent = _commentCache.getComment(parentId);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: parentId);
|
||||
}
|
||||
|
||||
|
26
lib/cubits/user/user_cubit.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
38
lib/cubits/user/user_state.dart
Normal 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,
|
||||
];
|
||||
}
|
58
lib/extensions/context_extension.dart
Normal file
@ -0,0 +1,58 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
extension ContextExtension on BuildContext {
|
||||
T? tryRead<T>() {
|
||||
try {
|
||||
return read<T>();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Rect? get rect {
|
||||
final RenderBox? box = findRenderObject() as RenderBox?;
|
||||
final Rect? rect =
|
||||
box == null ? null : box.localToGlobal(Offset.zero) & box.size;
|
||||
return rect;
|
||||
}
|
||||
|
||||
static double _screenWidth = 0;
|
||||
static double _storyTileHeight = 0;
|
||||
static int _storyTileMaxLines = 4;
|
||||
static const double _screenWidthLowerBound = 428,
|
||||
_screenWidthUpperBound = 850,
|
||||
_picHeightLowerBound = 110,
|
||||
_picHeightUpperBound = 128,
|
||||
_smallPicHeight = 100,
|
||||
_picHeightFactor = 0.3;
|
||||
|
||||
double get storyTileHeight {
|
||||
final double screenWidth =
|
||||
min(MediaQuery.of(this).size.height, MediaQuery.of(this).size.width);
|
||||
|
||||
if (screenWidth == _screenWidth) {
|
||||
return _storyTileHeight;
|
||||
} else {
|
||||
_screenWidth = screenWidth;
|
||||
}
|
||||
|
||||
final bool showSmallerPreviewPic = screenWidth > _screenWidthLowerBound &&
|
||||
screenWidth < _screenWidthUpperBound;
|
||||
final double height = showSmallerPreviewPic
|
||||
? _smallPicHeight
|
||||
: (screenWidth * _picHeightFactor)
|
||||
.clamp(_picHeightLowerBound, _picHeightUpperBound);
|
||||
final int maxLines = height == _smallPicHeight ? 3 : 4;
|
||||
_storyTileMaxLines = maxLines;
|
||||
|
||||
_storyTileHeight = height;
|
||||
return height;
|
||||
}
|
||||
|
||||
int get storyTileMaxLines {
|
||||
return _storyTileMaxLines;
|
||||
}
|
||||
}
|
@ -7,10 +7,8 @@ extension DateTimeExtension on DateTime {
|
||||
return '$gap year${gap == 1 ? '' : 's'} ago';
|
||||
} else if (diff.inDays > 30) {
|
||||
int gap = now.month - month;
|
||||
if (gap == 0) {
|
||||
gap = 1;
|
||||
} else if (gap < 0) {
|
||||
gap = now.month + (12 - month);
|
||||
if (gap <= 0) {
|
||||
gap = now.month + 12 - month;
|
||||
}
|
||||
return '$gap month${gap == 1 ? '' : 's'} ago';
|
||||
} else if (diff.inDays >= 1) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
export 'context_extension.dart';
|
||||
export 'date_time_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'list_extension.dart';
|
||||
|
@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/cubits/cubits.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 {
|
||||
void showSnackBar({
|
||||
@ -12,7 +13,7 @@ extension StateExtension on State {
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
backgroundColor: Palette.deepOrange,
|
||||
content: Text(content),
|
||||
action: action != null && label != null
|
||||
? 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;
|
||||
|
||||
if (splitViewEnabled) {
|
||||
context.read<SplitViewCubit>().updateStoryScreenArgs(args);
|
||||
if (splitViewEnabled && !forceNewScreen) {
|
||||
context.read<SplitViewCubit>().updateItemScreenArgs(args);
|
||||
} else {
|
||||
return HackiApp.navigatorKey.currentState?.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
ItemScreen.routeName,
|
||||
arguments: args,
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
extension StringExtension on String {
|
||||
int? getItemId() {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -12,3 +13,12 @@ extension StringExtension on String {
|
||||
return replaceAllMapped(regex, (_) => '');
|
||||
}
|
||||
}
|
||||
|
||||
extension OptionalStringExtension on String? {
|
||||
bool get isNullOrEmpty {
|
||||
if (this == null) return true;
|
||||
return this!.trim().isEmpty;
|
||||
}
|
||||
|
||||
bool get isNotNullOrEmpty => !isNullOrEmpty;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padding([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
return Padding(
|
||||
padding: value,
|
||||
child: this,
|
||||
|
@ -3,18 +3,23 @@ import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/repositories/repositories.dart' show PreferenceRepository;
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/custom_bloc_observer.dart';
|
||||
import 'package:hacki/services/fetcher.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@ -24,9 +29,23 @@ import 'package:workmanager/workmanager.dart';
|
||||
final BehaviorSubject<String?> selectNotificationSubject =
|
||||
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();
|
||||
|
||||
isTesting = testing;
|
||||
|
||||
final HydratedStorage storage = await HydratedStorage.build(
|
||||
storageDirectory: kIsWeb
|
||||
? HydratedStorage.webStorageDirectory
|
||||
: await getTemporaryDirectory(),
|
||||
);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
unawaited(
|
||||
Workmanager().initialize(
|
||||
@ -57,6 +76,16 @@ Future<void> main() async {
|
||||
badge: 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();
|
||||
@ -70,25 +99,32 @@ Future<void> main() async {
|
||||
final bool trueDarkMode =
|
||||
prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false;
|
||||
|
||||
// Uncomment code below for running with logging.
|
||||
// BlocOverrides.runZoned(
|
||||
// () {
|
||||
// runApp(
|
||||
// HackiApp(
|
||||
// savedThemeMode: savedThemeMode,
|
||||
// trueDarkMode: trueDarkMode,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// blocObserver: CustomBlocObserver(),
|
||||
// );
|
||||
|
||||
runApp(
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
),
|
||||
);
|
||||
if (kReleaseMode) {
|
||||
HydratedBlocOverrides.runZoned(
|
||||
() => runApp(
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
),
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
} else {
|
||||
BlocOverrides.runZoned(
|
||||
() {
|
||||
HydratedBlocOverrides.runZoned(
|
||||
() => runApp(
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
),
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
},
|
||||
blocObserver: CustomBlocObserver(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HackiApp extends StatelessWidget {
|
||||
@ -171,19 +207,19 @@ class HackiApp extends StatelessWidget {
|
||||
],
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
primarySwatch: Colors.orange,
|
||||
primarySwatch: Palette.orange,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.orange,
|
||||
canvasColor: trueDarkMode ? Colors.black : null,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: trueDarkMode ? Palette.black : null,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
final ThemeData trueDarkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.orange,
|
||||
canvasColor: Colors.black,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: Palette.black,
|
||||
);
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
|
35
lib/models/buildable_comment.dart
Normal 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;
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
@ -12,11 +11,11 @@ class Comment extends Item {
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required this.level,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
dead: false,
|
||||
parts: <int>[],
|
||||
title: '',
|
||||
url: '',
|
||||
@ -43,8 +42,7 @@ class Comment extends Item {
|
||||
|
||||
final int level;
|
||||
|
||||
String get postedDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
|
||||
String get metadata => '''by $by $postedDate''';
|
||||
|
||||
Comment copyWith({int? level}) {
|
||||
return Comment(
|
||||
@ -55,11 +53,13 @@ class Comment extends Item {
|
||||
by: by,
|
||||
text: text,
|
||||
kids: kids,
|
||||
dead: dead,
|
||||
deleted: deleted,
|
||||
level: level ?? this.level,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'time': time,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/extensions/date_time_extension.dart';
|
||||
|
||||
abstract class Item extends Equatable {
|
||||
class Item extends Equatable {
|
||||
const Item({
|
||||
required this.id,
|
||||
required this.deleted,
|
||||
@ -34,6 +35,22 @@ abstract class Item extends Equatable {
|
||||
text = '',
|
||||
type = '';
|
||||
|
||||
Item.fromJson(Map<String, dynamic> json)
|
||||
: id = json['id'] as int? ?? 0,
|
||||
score = json['score'] as int? ?? 0,
|
||||
descendants = json['descendants'] as int? ?? 0,
|
||||
time = json['time'] as int? ?? 0,
|
||||
by = json['by'] as String? ?? '',
|
||||
title = json['title'] as String? ?? '',
|
||||
text = json['text'] as String? ?? '',
|
||||
url = json['url'] as String? ?? '',
|
||||
kids = <int>[],
|
||||
dead = json['dead'] as bool? ?? false,
|
||||
deleted = json['deleted'] as bool? ?? false,
|
||||
parent = json['parent'] as int? ?? 0,
|
||||
parts = <int>[],
|
||||
type = json['type'] as String? ?? '';
|
||||
|
||||
final int id;
|
||||
final int time;
|
||||
final int score;
|
||||
@ -54,6 +71,9 @@ abstract class Item extends Equatable {
|
||||
final List<int> kids;
|
||||
final List<int> parts;
|
||||
|
||||
String get postedDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
|
||||
|
||||
bool get isPoll => type == 'poll';
|
||||
|
||||
bool get isStory => type == 'story';
|
||||
@ -61,4 +81,40 @@ abstract class Item extends Equatable {
|
||||
bool get isJob => type == 'job';
|
||||
|
||||
bool get isComment => type == 'comment';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
deleted,
|
||||
by,
|
||||
time,
|
||||
text,
|
||||
dead,
|
||||
parent,
|
||||
kids,
|
||||
url,
|
||||
score,
|
||||
title,
|
||||
type,
|
||||
parts,
|
||||
descendants,
|
||||
];
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
export 'buildable_comment.dart';
|
||||
export 'comment.dart';
|
||||
export 'item.dart';
|
||||
export 'poll_option.dart';
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/item.dart';
|
||||
|
||||
class PollOption extends Item {
|
||||
@ -63,9 +62,6 @@ class PollOption extends Item {
|
||||
|
||||
final double ratio;
|
||||
|
||||
String get postedDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
|
||||
|
||||
PollOption copyWith({double? ratio}) {
|
||||
return PollOption(
|
||||
id: id,
|
||||
@ -83,6 +79,7 @@ class PollOption extends Item {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
|