Compare commits

...

15 Commits

Author SHA1 Message Date
d83381a7fd v0.2.30 (#68)
* bumped version.

* cache  in case app gets killed in the background.

* bumped flutter version.

* updated

* improved 'ReplyBox'.

* fixed lint.

* fixed EditCubit.

* updated EditCubit.

* fixed navigation in tablet mode.

* clear cache after submission.
2022-08-05 22:13:30 -07:00
764ff09345 v0.2.29-hotfix (#67)
* fixed datetime extension.

* updated README.md
2022-07-06 20:37:18 -07:00
ab449adce2 v0.2.29 (#66)
* fixed stuff.

* fixed UI.

* bumped version.
2022-07-06 17:04:14 -07:00
2ec41b26f2 updated README.md 2022-07-06 16:23:05 -07:00
19f2107d95 v0.2.28 (#65)
* bumped version.

* fixed comments cubit and story tile.

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

* fixed comment cubit.

* fixed share button.

* fixed share dialog.

* added lazy loading.

* bumped version.

* fixed lazy loading.

* bumped version.

* updated screenshots.

* added customization of fetch mode and comments order.

* updated screenshots.

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

* replace listview with listview.builder

* added cache.

* bumped version.

* bumped version.

* updated github action.

* bumped version.

* fixed time machine cubit.

* fixed time machine cubit.

* reverted changes.

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

* improved cache.

* improved comment cache.

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

* improved collapse.

* improved download speed.

* improved err handling.

* improved error handling.

* improved error handling.

* improved logging.

* improved logging.

* bumped version.
2022-06-27 00:47:22 -07:00
a90d52f348 v0.2.23 (#60)
* fixed #58

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

* cleaned up code.

* bumped version.

* fixed lint.

* updated android config.

* prevent backup of secure storage.

* small fix.

* cleaned up code.

* cleaned up.

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

* use ItemScreen for share extension.

* fixed getItemId()

* bumped version.

* force new screen on viewing comments in separate thread.

* disable comment thread if comment is deleted or dead.

* navigate to new screen on viewing parent thread.

* bumped version.

* fixed inconsistent fontsize.

* bumped version.
2022-06-21 20:20:09 -07:00
134 changed files with 2823 additions and 1481 deletions

View File

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

View File

@ -1,14 +1,13 @@
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News # <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.
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
[![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/) [![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
[![GH version](https://img.shields.io/github/release/livinglist/hacki.svg?logo=github)](https://github.com/Livinglist/Hacki/releases/latest) [![GH version](https://img.shields.io/github/release/livinglist/hacki.svg?logo=github)](https://github.com/Livinglist/Hacki/releases/latest)
[![Visits Badge](https://badges.pufler.dev/visits/livinglist/Hacki)](https://badges.pufler.dev)
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart) [![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart)
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](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/) [<img src="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US) [<img src="assets/images/f_droid_badge.png" height="50">](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)

View File

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

View File

@ -16,8 +16,10 @@
</queries> </queries>
<application <application
android:label="hacki" android:label="Hacki"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -25,6 +27,7 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:exported="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 935 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 935 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View File

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

View File

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

View File

@ -568,7 +568,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -577,7 +577,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.20; MARKETING_VERSION = 0.2.30;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -705,7 +705,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -714,7 +714,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.20; MARKETING_VERSION = 0.2.30;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -736,7 +736,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -745,7 +745,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.20; MARKETING_VERSION = 0.2.30;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,36 +3,64 @@ import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
part 'comments_state.dart'; part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
CacheService? cacheService, required CollapseCache collapseCache,
CacheRepository? cacheRepository, CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading, required bool offlineReading,
required Story story, required Item item,
}) : _cacheService = cacheService ?? locator.get<CacheService>(), required FetchMode defaultFetchMode,
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(), required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(CommentsState.init(offlineReading: offlineReading, story: story)); _logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
offlineReading: offlineReading,
item: item,
fetchMode: defaultFetchMode,
order: defaultCommentsOrder,
),
);
final CacheService _cacheService; final CollapseCache _collapseCache;
final CacheRepository _cacheRepository; final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final Logger _logger;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
/// The map of [StreamSubscription] for streams
/// fetching comments lazily. [int] is the id of parent comment.
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _pageSize = 20; static const int _pageSize = 20;
@override @override
@ -44,6 +72,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({ Future<void> init({
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetParents, List<Comment>? targetParents,
}) async { }) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
@ -56,7 +85,7 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchCommentsStream( .fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids, ids: targetParents!.last.kids,
level: targetParents.last.level + 1, level: targetParents.last.level + 1,
) )
@ -66,35 +95,48 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
emit(state.copyWith(status: CommentsStatus.loading)); emit(
state.copyWith(
status: CommentsStatus.loading,
comments: <Comment>[],
currentPage: 0,
),
);
final Story story = state.story; final Item item = state.item;
final Story updatedStory = state.offlineReading final Item updatedItem = state.offlineReading
? story ? item
: await _storiesRepository.fetchStoryBy(story.id) ?? story; : await _storiesRepository.fetchItemBy(id: item.id) ?? item;
final List<int> kids = () { final List<int> kids = sortKids(updatedItem.kids);
switch (state.order) {
case CommentsOrder.natural:
return updatedStory.kids;
case CommentsOrder.newestFirst:
return updatedStory.kids.sorted((int a, int b) => b.compareTo(a));
case CommentsOrder.oldestFirst:
return updatedStory.kids.sorted((int a, int b) => a.compareTo(b));
}
}();
emit(state.copyWith(story: updatedStory)); emit(state.copyWith(item: updatedItem));
if (state.offlineReading) { if (state.offlineReading) {
_streamSubscription = _cacheRepository _streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: kids) .getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} else { } else {
_streamSubscription = _storiesRepository switch (state.fetchMode) {
.fetchCommentsStream(ids: kids) case FetchMode.lazy:
.listen(_onCommentFetched) _streamSubscription = _storiesRepository
..onDone(_onDone); .fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
}
} }
} }
@ -108,61 +150,182 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
_cacheService
..resetComments()
..resetCollapsedComments();
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.loading,
comments: <Comment>[],
), ),
); );
_collapseCache.resetCollapsedComments();
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
for (final int id in _streamSubscriptions.keys) {
final Story story = state.story; await _streamSubscriptions[id]?.cancel();
final Story updatedStory = }
await _storiesRepository.fetchStoryBy(story.id) ?? story; _streamSubscriptions.clear();
final List<int> kids = () {
switch (state.order) {
case CommentsOrder.natural:
return updatedStory.kids;
case CommentsOrder.newestFirst:
return updatedStory.kids.sorted((int a, int b) => b.compareTo(a));
case CommentsOrder.oldestFirst:
return updatedStory.kids.sorted((int a, int b) => a.compareTo(b));
}
}();
_streamSubscription = _storiesRepository
.fetchCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
emit( emit(
state.copyWith( 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, status: CommentsStatus.loaded,
), ),
); );
} }
void loadAll(Story story) { void loadAll(Story story) {
HapticFeedback.lightImpact();
emit( emit(
state.copyWith( state.copyWith(
onlyShowTargetComment: false, onlyShowTargetComment: false,
comments: <Comment>[], item: story,
story: story,
), ),
); );
init(); init();
} }
void loadMore() { /// [comment] is only used for lazy fetching.
if (_streamSubscription != null) { void loadMore({Comment? comment}) {
emit(state.copyWith(status: CommentsStatus.loading)); switch (state.fetchMode) {
_streamSubscription?.resume(); case FetchMode.lazy:
if (comment == null) return;
if (_streamSubscriptions.containsKey(comment.id)) return;
final int level = comment.level + 1;
int offset = 0;
/// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final List<LinkifyElement> elements = _linkify(
cmt.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(cmt, elements: elements);
emit(
state.copyWith(
comments: <Comment>[...state.comments]..insert(
state.comments.indexOf(comment) + offset + 1,
buildableComment.copyWith(level: level),
),
),
);
offset++;
})
..onDone(() {
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
})
..onError((dynamic error) {
_logger.e(error);
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
}
break;
}
}
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));
} }
} }
@ -178,9 +341,8 @@ class CommentsCubit extends Cubit<CommentsState> {
void _onCommentFetched(Comment? comment) { void _onCommentFetched(Comment? comment) {
if (comment != null) { if (comment != null) {
_cacheService _collapseCache.addKid(comment.id, to: comment.parent);
..addKid(comment.id, to: comment.parent) _commentCache.cacheComment(comment);
..cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = _linkify( final List<LinkifyElement> elements = _linkify(
@ -197,32 +359,28 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage && if (state.fetchMode == FetchMode.eager) {
updatedComments.length <= if (updatedComments.length >=
_pageSize * 2 + _pageSize * state.currentPage) { _pageSize + _pageSize * state.currentPage &&
final bool isHidden = _cacheService.isHidden(comment.id); updatedComments.length <=
_pageSize * 2 + _pageSize * state.currentPage) {
final bool isHidden = _collapseCache.isHidden(comment.id);
if (!isHidden) { if (!isHidden) {
_streamSubscription?.pause(); _streamSubscription?.pause();
}
emit(
state.copyWith(
currentPage: state.currentPage + 1,
status: CommentsStatus.loaded,
),
);
} }
emit(
state.copyWith(
currentPage: state.currentPage + 1,
status: CommentsStatus.loaded,
),
);
} }
} }
} }
void onOrderChanged(CommentsOrder? order) {
if (order == null) return;
_streamSubscription?.cancel();
emit(state.copyWith(order: order, comments: <Comment>[]));
init();
}
static List<LinkifyElement> _linkify( static List<LinkifyElement> _linkify(
String text, { String text, {
LinkifyOptions options = const LinkifyOptions(), LinkifyOptions options = const LinkifyOptions(),
@ -251,6 +409,9 @@ class CommentsCubit extends Cubit<CommentsState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
await s.cancel();
}
await super.close(); await super.close();
} }
} }

View File

@ -14,12 +14,19 @@ enum CommentsOrder {
oldestFirst, oldestFirst,
} }
enum FetchMode {
lazy,
eager,
}
class CommentsState extends Equatable { class CommentsState extends Equatable {
const CommentsState({ const CommentsState({
required this.story, required this.item,
required this.comments, required this.comments,
required this.status, required this.status,
required this.fetchParentStatus,
required this.order, required this.order,
required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.offlineReading,
required this.currentPage, required this.currentPage,
@ -27,35 +34,43 @@ class CommentsState extends Equatable {
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.offlineReading,
required this.story, required this.item,
required this.fetchMode,
required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
status = CommentsStatus.init, status = CommentsStatus.init,
order = CommentsOrder.natural, fetchParentStatus = CommentsStatus.init,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0; currentPage = 0;
final Story story; final Item item;
final List<Comment> comments; final List<Comment> comments;
final CommentsStatus status; final CommentsStatus status;
final CommentsStatus fetchParentStatus;
final CommentsOrder order; final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool offlineReading;
final int currentPage; final int currentPage;
CommentsState copyWith({ CommentsState copyWith({
Story? story, Item? item,
List<Comment>? comments, List<Comment>? comments,
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus,
CommentsOrder? order, CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? offlineReading,
int? currentPage, int? currentPage,
}) { }) {
return CommentsState( return CommentsState(
story: story ?? this.story, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
status: status ?? this.status, status: status ?? this.status,
order: order ?? this.order, order: order ?? this.order,
fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, offlineReading: offlineReading ?? this.offlineReading,
@ -63,12 +78,16 @@ class CommentsState extends Equatable {
); );
} }
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
story, item,
comments, comments,
status, status,
fetchParentStatus,
order, order,
fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, offlineReading,
currentPage, currentPage,

View File

@ -1,26 +1,27 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/utils/debouncer.dart'; import 'package:hacki/utils/debouncer.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'edit_state.dart'; part 'edit_state.dart';
class EditCubit extends Cubit<EditState> { class EditCubit extends HydratedCubit<EditState> {
EditCubit({CacheService? cacheService}) EditCubit({DraftCache? draftCache})
: _cacheService = cacheService ?? locator.get<CacheService>(), : _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: const Duration(seconds: 1)), _debouncer = Debouncer(delay: const Duration(seconds: 1)),
super(const EditState.init()); super(const EditState.init());
final CacheService _cacheService; final DraftCache _draftCache;
final Debouncer _debouncer; final Debouncer _debouncer;
void onReplyTapped(Item item) { void onReplyTapped(Item item) {
emit( emit(
EditState( EditState(
replyingTo: item, replyingTo: item,
text: _cacheService.getDraft(replyingTo: item.id), text: _draftCache.getDraft(replyingTo: item.id),
), ),
); );
} }
@ -44,9 +45,10 @@ class EditCubit extends Cubit<EditState> {
void onReplySubmittedSuccessfully() { void onReplySubmittedSuccessfully() {
if (state.replyingTo != null) { if (state.replyingTo != null) {
_cacheService.removeDraft(replyingTo: state.replyingTo!.id); _draftCache.removeDraft(replyingTo: state.replyingTo!.id);
} }
emit(const EditState.init()); emit(const EditState.init());
clear();
} }
void onTextChanged(String text) { void onTextChanged(String text) {
@ -54,11 +56,54 @@ class EditCubit extends Cubit<EditState> {
if (state.replyingTo != null) { if (state.replyingTo != null) {
final int? id = state.replyingTo?.id; final int? id = state.replyingTo?.id;
_debouncer.run(() { _debouncer.run(() {
_cacheService.cacheDraft( _draftCache.cacheDraft(
text: text, text: text,
replyingTo: id!, replyingTo: id!,
); );
}); });
} }
} }
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();
} }

View File

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

View File

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

View File

@ -33,7 +33,8 @@ class NotificationCubit extends Cubit<NotificationState> {
_preferenceRepository.shouldShowNotification _preferenceRepository.shouldShowNotification
.then((bool showNotification) { .then((bool showNotification) {
if (showNotification) { if (showNotification) {
init(); // Delaying the initialization to prevent janks in home screen.
Future<void>.delayed(const Duration(seconds: 2), init);
} }
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,8 @@ extension DateTimeExtension on DateTime {
return '$gap year${gap == 1 ? '' : 's'} ago'; return '$gap year${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays > 30) { } else if (diff.inDays > 30) {
int gap = now.month - month; int gap = now.month - month;
if (gap == 0) { if (gap <= 0) {
gap = 1; gap = now.month + 12 - month;
} else if (gap < 0) {
gap = now.month + (12 - month);
} }
return '$gap month${gap == 1 ? '' : 's'} ago'; return '$gap month${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays >= 1) { } else if (diff.inDays >= 1) {

View File

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

View File

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

View File

@ -1,7 +1,8 @@
extension StringExtension on String { extension StringExtension on String {
int? getItemId() { int? getItemId() {
final RegExp regex = RegExp(r'\d+$'); final RegExp regex = RegExp(r'\d+$');
final String match = regex.stringMatch(this) ?? ''; final RegExp exception = RegExp(r'\)|].*$');
final String match = regex.stringMatch(replaceAll(exception, '')) ?? '';
return int.tryParse(match); return int.tryParse(match);
} }
@ -12,3 +13,12 @@ extension StringExtension on String {
return replaceAllMapped(regex, (_) => ''); return replaceAllMapped(regex, (_) => '');
} }
} }
extension OptionalStringExtension on String? {
bool get isNullOrEmpty {
if (this == null) return true;
return this!.trim().isEmpty;
}
bool get isNotNullOrEmpty => !isNullOrEmpty;
}

View File

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

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -14,8 +15,11 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/repositories/repositories.dart' show PreferenceRepository; import 'package:hacki/repositories/repositories.dart' show PreferenceRepository;
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/custom_bloc_observer.dart';
import 'package:hacki/services/fetcher.dart'; import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject; import 'package:rxdart/rxdart.dart' show BehaviorSubject;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -29,9 +33,19 @@ final BehaviorSubject<String?> selectNotificationSubject =
final BehaviorSubject<String?> siriSuggestionSubject = final BehaviorSubject<String?> siriSuggestionSubject =
BehaviorSubject<String?>(); BehaviorSubject<String?>();
Future<void> main() async { late final bool isTesting;
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
isTesting = testing;
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
: await getTemporaryDirectory(),
);
if (Platform.isIOS) { if (Platform.isIOS) {
unawaited( unawaited(
Workmanager().initialize( Workmanager().initialize(
@ -85,25 +99,32 @@ Future<void> main() async {
final bool trueDarkMode = final bool trueDarkMode =
prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false; prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false;
// Uncomment code below for running with logging. if (kReleaseMode) {
// BlocOverrides.runZoned( HydratedBlocOverrides.runZoned(
// () { () => runApp(
// runApp( HackiApp(
// HackiApp( savedThemeMode: savedThemeMode,
// savedThemeMode: savedThemeMode, trueDarkMode: trueDarkMode,
// trueDarkMode: trueDarkMode, ),
// ), ),
// ); storage: storage,
// }, );
// blocObserver: CustomBlocObserver(), } else {
// ); BlocOverrides.runZoned(
() {
runApp( HydratedBlocOverrides.runZoned(
HackiApp( () => runApp(
savedThemeMode: savedThemeMode, HackiApp(
trueDarkMode: trueDarkMode, savedThemeMode: savedThemeMode,
), trueDarkMode: trueDarkMode,
); ),
),
storage: storage,
);
},
blocObserver: CustomBlocObserver(),
);
}
} }
class HackiApp extends StatelessWidget { class HackiApp extends StatelessWidget {
@ -179,22 +200,26 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (BuildContext context) => PostCubit(), create: (BuildContext context) => PostCubit(),
), ),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
], ],
child: AdaptiveTheme( child: AdaptiveTheme(
light: ThemeData( light: ThemeData(
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
), ),
dark: ThemeData( dark: ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
canvasColor: trueDarkMode ? Colors.black : null, canvasColor: trueDarkMode ? Palette.black : null,
), ),
initial: savedThemeMode ?? AdaptiveThemeMode.system, initial: savedThemeMode ?? AdaptiveThemeMode.system,
builder: (ThemeData theme, ThemeData darkTheme) { builder: (ThemeData theme, ThemeData darkTheme) {
final ThemeData trueDarkTheme = ThemeData( final ThemeData trueDarkTheme = ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Colors.orange, primarySwatch: Palette.orange,
canvasColor: Colors.black, canvasColor: Palette.black,
); );
return FutureBuilder<AdaptiveThemeMode?>( return FutureBuilder<AdaptiveThemeMode?>(
future: AdaptiveTheme.getThemeMode(), future: AdaptiveTheme.getThemeMode(),

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
class Comment extends Item { class Comment extends Item {
@ -43,8 +42,7 @@ class Comment extends Item {
final int level; final int level;
String get postedDate => String get metadata => '''by $by $postedDate''';
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
Comment copyWith({int? level}) { Comment copyWith({int? level}) {
return Comment( return Comment(
@ -61,6 +59,7 @@ class Comment extends Item {
); );
} }
@override
Map<String, dynamic> toJson() => <String, dynamic>{ Map<String, dynamic> toJson() => <String, dynamic>{
'id': id, 'id': id,
'time': time, 'time': time,

View File

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
abstract class Item extends Equatable { class Item extends Equatable {
const Item({ const Item({
required this.id, required this.id,
required this.deleted, required this.deleted,
@ -34,6 +35,22 @@ abstract class Item extends Equatable {
text = '', text = '',
type = ''; 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 id;
final int time; final int time;
final int score; final int score;
@ -54,6 +71,9 @@ abstract class Item extends Equatable {
final List<int> kids; final List<int> kids;
final List<int> parts; final List<int> parts;
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';
bool get isStory => type == 'story'; bool get isStory => type == 'story';
@ -61,4 +81,40 @@ abstract class Item extends Equatable {
bool get isJob => type == 'job'; bool get isJob => type == 'job';
bool get isComment => type == 'comment'; 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,
];
} }

View File

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

View File

@ -1,6 +1,3 @@
import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
enum StoryType { enum StoryType {
@ -94,9 +91,7 @@ class Story extends Item {
String get simpleMetadata => String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate'''; '''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get postedDate => @override
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
'descendants': descendants, 'descendants': descendants,
@ -117,9 +112,10 @@ class Story extends Item {
@override @override
String toString() { String toString() {
final String prettyString = // final String prettyString =
const JsonEncoder.withIndent(' ').convert(this); // const JsonEncoder.withIndent(' ').convert(this);
return 'Story $prettyString'; // return 'Story $prettyString';
return 'Story $id';
} }
@override @override

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class User { class User {
@ -39,8 +37,6 @@ class User {
@override @override
String toString() { String toString() {
final String prettyString = return 'User $about, $created, $delay, $id, $karma';
const JsonEncoder.withIndent(' ').convert(this);
return 'User $prettyString';
} }
} }

View File

@ -5,16 +5,20 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/postable_repository.dart'; import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
class AuthRepository extends PostableRepository { class AuthRepository extends PostableRepository {
AuthRepository({ AuthRepository({
Dio? dio, Dio? dio,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository = }) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(dio: dio); super(dio: dio);
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger;
static const String _authority = 'news.ycombinator.com'; static const String _authority = 'news.ycombinator.com';
@ -38,10 +42,15 @@ class AuthRepository extends PostableRepository {
final bool success = await performDefaultPost(uri, data); final bool success = await performDefaultPost(uri, data);
if (success) { if (success) {
await _preferenceRepository.setAuth( try {
username: username, await _preferenceRepository.setAuth(
password: password, username: username,
); password: password,
);
} catch (_) {
_logger.e(_);
return false;
}
} }
return success; return success;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'cache_repository.dart'; export 'offline_repository.dart';
export 'post_repository.dart'; export 'post_repository.dart';
export 'preference_repository.dart'; export 'preference_repository.dart';
export 'search_repository.dart'; export 'search_repository.dart';

View File

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

View File

@ -24,7 +24,9 @@ import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart'; import 'package:responsive_builder/responsive_builder.dart';
@ -46,7 +48,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware { with SingleTickerProviderStateMixin, RouteAware {
final CacheService cacheService = locator.get<CacheService>();
final Throttle featureDiscoveryDismissThrottle = Throttle( final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay, delay: _throttleDelay,
); );
@ -65,9 +66,10 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext(); super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType == if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) { DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed( Future<void>.delayed(
const Duration(milliseconds: 500), const Duration(milliseconds: 500),
cacheService.resetCollapsedComments, locator.get<CommentCache>().resetComments,
); );
} }
} }
@ -101,12 +103,14 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures( if (!isTesting) {
context, FeatureDiscovery.discoverFeatures(
const <String>{ context,
Constants.featureLogIn, const <String>{
}, Constants.featureLogIn,
); },
);
}
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -171,8 +175,8 @@ class _HomeScreenState extends State<HomeScreen>
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story); context.read<PinCubit>().unpinStory(story);
}, },
backgroundColor: Colors.red, backgroundColor: Palette.red,
foregroundColor: Colors.white, foregroundColor: Palette.white,
icon: preferenceState.showComplexStoryTile icon: preferenceState.showComplexStoryTile
? Icons.close ? Icons.close
: null, : null,
@ -181,7 +185,7 @@ class _HomeScreenState extends State<HomeScreen>
], ],
), ),
child: Container( child: Container(
color: Colors.orangeAccent.withOpacity(0.2), color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile( child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'), key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story, story: story,
@ -194,9 +198,9 @@ class _HomeScreenState extends State<HomeScreen>
), ),
if (state.pinnedStories.isNotEmpty) if (state.pinnedStories.isNotEmpty)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 12), padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider( child: Divider(
color: Colors.orangeAccent, color: Palette.orangeAccent,
), ),
), ),
], ],
@ -209,27 +213,32 @@ class _HomeScreenState extends State<HomeScreen>
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size(0, 40), preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
SizedBox( SizedBox(
height: MediaQuery.of(context).padding.top - 8, height: MediaQuery.of(context).padding.top - Dimens.pt8,
), ),
Theme( Theme(
data: ThemeData( data: ThemeData(
highlightColor: Colors.transparent, highlightColor: Palette.transparent,
splashColor: Colors.transparent, splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor, primaryColor: Theme.of(context).primaryColor,
), ),
child: TabBar( child: TabBar(
isScrollable: true, isScrollable: true,
controller: tabController, controller: tabController,
indicatorColor: Colors.orange, indicatorColor: Palette.orange,
indicator: CircleTabIndicator( indicator: CircleTabIndicator(
color: Colors.orange, color: Palette.orange,
radius: 2, radius: Dimens.pt2,
),
indicatorPadding: const EdgeInsets.only(
bottom: Dimens.pt8,
), ),
indicatorPadding: const EdgeInsets.only(bottom: 8),
onTap: (_) { onTap: (_) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
@ -242,10 +251,12 @@ class _HomeScreenState extends State<HomeScreen>
child: Text( child: Text(
StoriesBloc.types.elementAt(i).label, StoriesBloc.types.elementAt(i).label,
style: TextStyle( style: TextStyle(
fontSize: currentIndex == i ? 14 : 10, fontSize: currentIndex == i
? TextDimens.pt14
: TextDimens.pt10,
color: currentIndex == i color: currentIndex == i
? Colors.orange ? Palette.orange
: Colors.grey, : Palette.grey,
), ),
), ),
), ),
@ -263,8 +274,8 @@ class _HomeScreenState extends State<HomeScreen>
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon( tapTarget: const Icon(
Icons.person, Icons.person,
size: 16, size: TextDimens.pt16,
color: Colors.white, color: Palette.white,
), ),
featureId: Constants.featureLogIn, featureId: Constants.featureLogIn,
title: const Text('Log in for more'), title: const Text('Log in for more'),
@ -274,7 +285,7 @@ class _HomeScreenState extends State<HomeScreen>
'posted in the past, and get in-app ' 'posted in the past, and get in-app '
'notification when there is new reply to ' 'notification when there is new reply to '
'your comments or stories.', 'your comments or stories.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: BlocBuilder<NotificationCubit, child: BlocBuilder<NotificationCubit,
NotificationState>( NotificationState>(
@ -292,19 +303,21 @@ class _HomeScreenState extends State<HomeScreen>
showBadge: state.unreadCommentsIds.isNotEmpty, showBadge: state.unreadCommentsIds.isNotEmpty,
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
badgeContent: Container( badgeContent: Container(
height: 3, height: Dimens.pt3,
width: 3, width: Dimens.pt3,
decoration: const BoxDecoration( decoration: const BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.white, color: Palette.white,
), ),
), ),
child: Icon( child: Icon(
Icons.person, Icons.person,
size: currentIndex == 5 ? 16 : 12, size: currentIndex == 5
? TextDimens.pt16
: TextDimens.pt12,
color: currentIndex == 5 color: currentIndex == 5
? Colors.orange ? Palette.orange
: Colors.grey, : Palette.grey,
), ),
); );
}, },
@ -375,16 +388,16 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) { if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId(); context.read<ReminderCubit>().removeLastReadStoryId();
} else { } else {
final StoryScreenArgs args = StoryScreenArgs(story: story); final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id); context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) { if (splitViewEnabled) {
context.read<SplitViewCubit>().updateStoryScreenArgs(args); context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else { } else {
HackiApp.navigatorKey.currentState HackiApp.navigatorKey.currentState
?.pushNamed( ?.pushNamed(
StoryScreen.routeName, ItemScreen.routeName,
arguments: args, arguments: args,
) )
.whenComplete(() { .whenComplete(() {
@ -436,13 +449,13 @@ class _HomeScreenState extends State<HomeScreen>
final int? id = event.getItemId(); final int? id = event.getItemId();
if (id != null) { if (id != null) {
locator locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
.get<StoriesRepository>()
.fetchParentStory(id: id)
.then((Story? story) {
if (mounted) { if (mounted) {
if (story != null) { if (item != null) {
goToStoryScreen(args: StoryScreenArgs(story: story)); goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
} }
} }
}); });
@ -462,8 +475,8 @@ class _HomeScreenState extends State<HomeScreen>
showSnackBar(content: 'Something went wrong...'); showSnackBar(content: 'Something went wrong...');
return; return;
} }
final StoryScreenArgs args = StoryScreenArgs(story: story); final ItemScreenArgs args = ItemScreenArgs(item: story);
goToStoryScreen(args: args); goToItemScreen(args: args);
}); });
} }
@ -487,8 +500,8 @@ class _HomeScreenState extends State<HomeScreen>
showSnackBar(content: 'Something went wrong...'); showSnackBar(content: 'Something went wrong...');
return; return;
} }
final StoryScreenArgs args = StoryScreenArgs(story: story); final ItemScreenArgs args = ItemScreenArgs(item: story);
goToStoryScreen(args: args); goToItemScreen(args: args);
}); });
} }
} }
@ -509,10 +522,10 @@ class _MobileHomeScreen extends StatelessWidget {
Positioned.fill(child: homeScreen), Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown) if (!context.read<ReminderCubit>().state.hasShown)
const Positioned( const Positioned(
left: 24, left: Dimens.pt24,
right: 24, right: Dimens.pt24,
bottom: 36, bottom: Dimens.pt36,
height: 40, height: Dimens.pt40,
child: CountdownReminder(), child: CountdownReminder(),
), ),
], ],
@ -536,7 +549,7 @@ class _TabletHomeScreen extends StatelessWidget {
double homeScreenWidth = 428; double homeScreenWidth = 428;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) { if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345.0; homeScreenWidth = 345;
} }
return BlocBuilder<SplitViewCubit, SplitViewState>( return BlocBuilder<SplitViewCubit, SplitViewState>(
@ -546,26 +559,26 @@ class _TabletHomeScreen extends StatelessWidget {
return Stack( return Stack(
children: <Widget>[ children: <Widget>[
AnimatedPositioned( AnimatedPositioned(
left: 0, left: Dimens.zero,
top: 0, top: Dimens.zero,
bottom: 0, bottom: Dimens.zero,
width: state.expanded ? 0 : homeScreenWidth, width: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: homeScreen, child: homeScreen,
), ),
Positioned( Positioned(
left: 24, left: Dimens.pt24,
bottom: 36, bottom: Dimens.pt36,
height: 40, height: Dimens.pt40,
width: homeScreenWidth - 24, width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(), child: const CountdownReminder(),
), ),
AnimatedPositioned( AnimatedPositioned(
right: 0, right: Dimens.zero,
top: 0, top: Dimens.zero,
bottom: 0, bottom: Dimens.zero,
left: state.expanded ? 0 : homeScreenWidth, left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: const _TabletStoryView(), child: const _TabletStoryView(),
@ -586,10 +599,10 @@ class _TabletStoryView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SplitViewCubit, SplitViewState>( return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) => buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.storyScreenArgs != current.storyScreenArgs, previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) { builder: (BuildContext context, SplitViewState state) {
if (state.storyScreenArgs != null) { if (state.itemScreenArgs != null) {
return StoryScreen.build(context, state.storyScreenArgs!); return ItemScreen.build(context, state.itemScreenArgs!);
} }
return Material( return Material(

View File

@ -2,16 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/story/widgets/fav_icon_button.dart'; import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/story/widgets/link_icon_button.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/screens/story/widgets/pin_icon_button.dart';
import 'package:hacki/screens/story/widgets/scroll_up_icon_button.dart';
class CustomAppBar extends AppBar { class CustomAppBar extends AppBar {
CustomAppBar({ CustomAppBar({
Key? key, Key? key,
required ScrollController scrollController, required ScrollController scrollController,
required Story story, required Item item,
required Color backgroundColor, required Color backgroundColor,
required Future<bool> Function() onBackgroundTap, required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss, required Future<bool> Function() onDismiss,
@ -21,7 +19,7 @@ class CustomAppBar extends AppBar {
}) : super( }) : super(
key: key, key: key,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
elevation: 0, elevation: Dimens.zero,
actions: <Widget>[ actions: <Widget>[
if (splitViewEnabled) ...<Widget>[ if (splitViewEnabled) ...<Widget>[
IconButton( IconButton(
@ -29,7 +27,7 @@ class CustomAppBar extends AppBar {
expanded ?? false expanded ?? false
? FeatherIcons.minimize2 ? FeatherIcons.minimize2
: FeatherIcons.maximize2, : FeatherIcons.maximize2,
size: 20, size: TextDimens.pt20,
), ),
onPressed: () { onPressed: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
@ -41,18 +39,19 @@ class CustomAppBar extends AppBar {
ScrollUpIconButton( ScrollUpIconButton(
scrollController: scrollController, scrollController: scrollController,
), ),
PinIconButton( if (item is Story)
story: story, PinIconButton(
onBackgroundTap: onBackgroundTap, story: item,
onDismiss: onDismiss, onBackgroundTap: onBackgroundTap,
), onDismiss: onDismiss,
),
FavIconButton( FavIconButton(
storyId: story.id, storyId: item.id,
onBackgroundTap: onBackgroundTap, onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss, onDismiss: onDismiss,
), ),
LinkIconButton( LinkIconButton(
storyId: story.id, storyId: item.id,
onBackgroundTap: onBackgroundTap, onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss, onDismiss: onDismiss,
), ),

View File

@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/styles/styles.dart';
class FavIconButton extends StatelessWidget { class FavIconButton extends StatelessWidget {
const FavIconButton({ const FavIconButton({
@ -37,17 +38,17 @@ class FavIconButton extends StatelessWidget {
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: Icon( tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: Colors.white, color: Palette.white,
), ),
featureId: Constants.featureAddStoryToFavList, featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'), title: const Text('Fav a Story'),
description: const Text( description: const Text(
'Add it to your favorites.', 'Add it to your favorites.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: Icon( child: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Colors.orange : Theme.of(context).iconTheme.color, color: isFav ? Palette.orange : Theme.of(context).iconTheme.color,
), ),
), ),
onPressed: () { onPressed: () {

View File

@ -4,6 +4,7 @@ import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget { class LinkIconButton extends StatelessWidget {
@ -33,7 +34,7 @@ class LinkIconButton extends StatelessWidget {
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon( tapTarget: const Icon(
Icons.stream, Icons.stream,
color: Colors.white, color: Palette.white,
), ),
featureId: Constants.featureOpenStoryInWebView, featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'), title: const Text('Open in Browser'),
@ -41,7 +42,7 @@ class LinkIconButton extends StatelessWidget {
'Want more than just reading and replying? ' 'Want more than just reading and replying? '
'You can tap here to open this story in a ' 'You can tap here to open this story in a '
'browser.', 'browser.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: const Icon( child: const Icon(
Icons.stream, Icons.stream,

View File

@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class PinIconButton extends StatelessWidget { class PinIconButton extends StatelessWidget {
const PinIconButton({ const PinIconButton({
@ -43,7 +44,7 @@ class PinIconButton extends StatelessWidget {
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: Icon( tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined, pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.white, color: Palette.white,
), ),
featureId: Constants.featurePinToTop, featureId: Constants.featurePinToTop,
title: const Text('Pin a Story'), title: const Text('Pin a Story'),
@ -51,12 +52,12 @@ class PinIconButton extends StatelessWidget {
'Pin this story to the top of your ' 'Pin this story to the top of your '
'home screen so that you can come' 'home screen so that you can come'
' back later.', ' back later.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: Icon( child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined, pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned color: pinned
? Colors.orange ? Palette.orange
: Theme.of(context).iconTheme.color, : Theme.of(context).iconTheme.color,
), ),
), ),

View File

@ -5,6 +5,7 @@ import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class PollView extends StatelessWidget { class PollView extends StatelessWidget {
const PollView({ const PollView({
@ -21,29 +22,29 @@ class PollView extends StatelessWidget {
return Column( return Column(
children: <Widget>[ children: <Widget>[
const SizedBox( const SizedBox(
height: 24, height: Dimens.pt24,
), ),
if (state.status == PollStatus.loading) ...<Widget>[ if (state.status == PollStatus.loading) ...<Widget>[
const LinearProgressIndicator(), const LinearProgressIndicator(),
const SizedBox( const SizedBox(
height: 24, height: Dimens.pt24,
), ),
] else ...<Widget>[ ] else ...<Widget>[
Row( Row(
children: <Widget>[ children: <Widget>[
const SizedBox( const SizedBox(
width: 24, width: Dimens.pt24,
), ),
Text( Text(
'Total votes: ${state.totalVotes}', 'Total votes: ${state.totalVotes}',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: TextDimens.pt14,
), ),
), ),
], ],
), ),
const SizedBox( const SizedBox(
height: 12, height: Dimens.pt12,
), ),
], ],
for (final PollOption option in state.pollOptions) for (final PollOption option in state.pollOptions)
@ -97,9 +98,9 @@ class PollView extends StatelessWidget {
builder: (BuildContext context, VoteState voteState) { builder: (BuildContext context, VoteState voteState) {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 12, left: Dimens.pt12,
right: 24, right: Dimens.pt24,
bottom: 4, bottom: Dimens.pt4,
), ),
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
@ -111,9 +112,9 @@ class PollView extends StatelessWidget {
icon: Icon( icon: Icon(
Icons.arrow_drop_up, Icons.arrow_drop_up,
color: voteState.vote == Vote.up color: voteState.vote == Vote.up
? Colors.orange ? Palette.orange
: Colors.grey, : Palette.grey,
size: 36, size: TextDimens.pt36,
), ),
), ),
Expanded( Expanded(
@ -126,16 +127,16 @@ class PollView extends StatelessWidget {
Text( Text(
'''${option.score} vote${option.score > 1 ? 's' : ''}''', '''${option.score} vote${option.score > 1 ? 's' : ''}''',
style: const TextStyle( style: const TextStyle(
color: Colors.grey, color: Palette.grey,
fontSize: 12, fontSize: TextDimens.pt12,
), ),
), ),
const SizedBox( const SizedBox(
height: 4, height: Dimens.pt4,
), ),
LinearProgressIndicator( LinearProgressIndicator(
value: option.ratio, value: option.ratio,
color: Colors.deepOrange, color: Palette.deepOrange,
), ),
], ],
), ),
@ -161,7 +162,7 @@ class PollView extends StatelessWidget {
}) { }) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
backgroundColor: Colors.deepOrange, backgroundColor: Palette.deepOrange,
content: Text(content), content: Text(content),
action: action != null && label != null action: action != null && label != null
? SnackBarAction( ? SnackBarAction(

View File

@ -5,7 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart'; import 'package:hacki/utils/link_util.dart';
class ReplyBox extends StatefulWidget { class ReplyBox extends StatefulWidget {
@ -34,6 +37,8 @@ class _ReplyBoxState extends State<ReplyBox> {
bool expanded = false; bool expanded = false;
double? expandedHeight; double? expandedHeight;
static const double _collapsedHeight = 100;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
expandedHeight ??= MediaQuery.of(context).size.height; expandedHeight ??= MediaQuery.of(context).size.height;
@ -53,20 +58,21 @@ class _ReplyBoxState extends State<ReplyBox> {
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: expanded bottom: expanded
? 0 ? Dimens.zero
: widget.splitViewEnabled : widget.splitViewEnabled
? MediaQuery.of(context).viewInsets.bottom ? MediaQuery.of(context).viewInsets.bottom
: 0, : Dimens.zero,
), ),
child: AnimatedContainer( child: AnimatedContainer(
height: expanded ? expandedHeight : 100, height: expanded ? expandedHeight : _collapsedHeight,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled) if (!context.read<SplitViewCubit>().state.enabled)
BoxShadow( BoxShadow(
color: expanded ? Colors.transparent : Colors.black26, color:
blurRadius: 40, expanded ? Palette.transparent : Palette.black26,
blurRadius: Dimens.pt40,
), ),
], ],
), ),
@ -75,28 +81,32 @@ class _ReplyBoxState extends State<ReplyBox> {
children: <Widget>[ children: <Widget>[
if (context.read<SplitViewCubit>().state.enabled) if (context.read<SplitViewCubit>().state.enabled)
const Divider( const Divider(
height: 0, height: Dimens.zero,
), ),
AnimatedContainer( AnimatedContainer(
height: expanded ? 36 : 0, height: expanded ? Dimens.pt36 : Dimens.zero,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
), ),
Row( Row(
children: <Widget>[ children: <Widget>[
Padding( Expanded(
padding: const EdgeInsets.symmetric( child: Padding(
horizontal: 12, padding: const EdgeInsets.only(
vertical: 8, left: Dimens.pt12,
), top: Dimens.pt8,
child: Text( bottom: Dimens.pt8,
replyingTo == null ),
? 'Editing' child: Text(
: 'Replying ' replyingTo == null
'${replyingTo.by}', ? 'Editing'
style: const TextStyle(color: Colors.grey), : 'Replying '
'${replyingTo.by}',
style: const TextStyle(color: Palette.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
const Spacer(),
if (!isLoading) ...<Widget>[ if (!isLoading) ...<Widget>[
...<Widget>[ ...<Widget>[
if (replyingTo != null) if (replyingTo != null)
@ -107,8 +117,8 @@ class _ReplyBoxState extends State<ReplyBox> {
key: const Key('quote'), key: const Key('quote'),
icon: const Icon( icon: const Icon(
FeatherIcons.code, FeatherIcons.code,
color: Colors.orange, color: Palette.orange,
size: 18, size: TextDimens.pt18,
), ),
onPressed: onPressed:
expanded ? showTextPopup : null, expanded ? showTextPopup : null,
@ -120,8 +130,8 @@ class _ReplyBoxState extends State<ReplyBox> {
expanded expanded
? FeatherIcons.minimize2 ? FeatherIcons.minimize2
: FeatherIcons.maximize2, : FeatherIcons.maximize2,
color: Colors.orange, color: Palette.orange,
size: 18, size: TextDimens.pt18,
), ),
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -134,9 +144,42 @@ class _ReplyBoxState extends State<ReplyBox> {
key: const Key('close'), key: const Key('close'),
icon: const Icon( icon: const Icon(
Icons.close, Icons.close,
color: Colors.orange, color: Palette.orange,
), ),
onPressed: () { onPressed: () {
final EditState state =
context.read<EditCubit>().state;
if (state.replyingTo != null &&
state.text.isNotNullOrEmpty) {
showDialog<void>(
context: context,
builder: (BuildContext context) =>
AlertDialog(
title: const Text('Save draft?'),
actions: <Widget>[
TextButton(
onPressed: () {
context
.read<EditCubit>()
.deleteDraft();
Navigator.pop(context);
},
child: const Text(
'No',
style: TextStyle(
color: Palette.red,
),
),
),
TextButton(
onPressed: () =>
Navigator.pop(context),
child: const Text('Yes'),
),
],
),
);
}
widget.onCloseTapped(); widget.onCloseTapped();
expanded = false; expanded = false;
}, },
@ -145,15 +188,15 @@ class _ReplyBoxState extends State<ReplyBox> {
if (isLoading) if (isLoading)
const Padding( const Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: 12, vertical: Dimens.pt12,
horizontal: 16, horizontal: Dimens.pt16,
), ),
child: SizedBox( child: SizedBox(
height: 24, height: Dimens.pt24,
width: 24, width: Dimens.pt24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.orange, color: Palette.orange,
strokeWidth: 2, strokeWidth: Dimens.pt2,
), ),
), ),
) )
@ -162,7 +205,7 @@ class _ReplyBoxState extends State<ReplyBox> {
key: const Key('send'), key: const Key('send'),
icon: const Icon( icon: const Icon(
Icons.send, Icons.send,
color: Colors.orange, color: Palette.orange,
), ),
onPressed: () { onPressed: () {
widget.onSendTapped(); widget.onSendTapped();
@ -173,7 +216,9 @@ class _ReplyBoxState extends State<ReplyBox> {
), ),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt16,
),
child: TextField( child: TextField(
focusNode: widget.focusNode, focusNode: widget.focusNode,
controller: widget.textEditingController, controller: widget.textEditingController,
@ -183,7 +228,7 @@ class _ReplyBoxState extends State<ReplyBox> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
hintText: '...', hintText: '...',
hintStyle: TextStyle( hintStyle: TextStyle(
color: Colors.grey, color: Palette.grey,
), ),
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
border: InputBorder.none, border: InputBorder.none,
@ -216,8 +261,8 @@ class _ReplyBoxState extends State<ReplyBox> {
builder: (_) { builder: (_) {
return AlertDialog( return AlertDialog(
insetPadding: const EdgeInsets.symmetric( insetPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: Dimens.pt12,
vertical: 24, vertical: Dimens.pt24,
), ),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
content: ConstrainedBox( content: ConstrainedBox(
@ -229,18 +274,41 @@ class _ReplyBoxState extends State<ReplyBox> {
children: <Widget>[ children: <Widget>[
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 12, left: Dimens.pt12,
top: 6, top: Dimens.pt6,
), ),
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Text( Text(
replyingTo?.by ?? '', replyingTo?.by ?? '',
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Palette.grey),
), ),
const Spacer(), const Spacer(),
if (replyingTo != null)
TextButton(
child: const Text('View thread'),
onPressed: () {
HapticFeedback.lightImpact();
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
useCommentCache: true,
),
forceNewScreen: true,
);
},
),
TextButton( TextButton(
child: const Text('Copy All'), child: const Text('Copy all'),
onPressed: () => FlutterClipboard.copy( onPressed: () => FlutterClipboard.copy(
replyingTo?.text ?? '', replyingTo?.text ?? '',
).then((_) => HapticFeedback.selectionClick()), ).then((_) => HapticFeedback.selectionClick()),
@ -248,8 +316,8 @@ class _ReplyBoxState extends State<ReplyBox> {
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.close, Icons.close,
color: Colors.orange, color: Palette.orange,
size: 18, size: TextDimens.pt18,
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
@ -261,17 +329,17 @@ class _ReplyBoxState extends State<ReplyBox> {
thumbVisibility: true, thumbVisibility: true,
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 12, left: Dimens.pt12,
right: 6, right: Dimens.pt6,
top: 6, top: Dimens.pt6,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: SelectableLinkify( child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(), scrollPhysics: const NeverScrollableScrollPhysics(),
linkStyle: TextStyle( linkStyle: TextStyle(
fontSize: fontSize: MediaQuery.of(context).textScaleFactor *
MediaQuery.of(context).textScaleFactor * 15, TextDimens.pt15,
color: Colors.orange, color: Palette.orange,
), ),
onOpen: (LinkableElement link) => onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url), LinkUtil.launch(link.url),

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