Compare commits

..

76 Commits

Author SHA1 Message Date
7dc3618afe update color. (#339) 2023-12-02 23:31:45 -08:00
eef4691814 update Info.plist (#338) 2023-12-02 20:58:39 -08:00
9f71701845 update story tile. (#336) 2023-12-02 04:46:06 -08:00
d27203b041 update Info.plist (#335) 2023-12-02 04:21:58 -08:00
4f280ec4c9 add ability to sync favorites from Hacker News. (#334) 2023-12-01 21:53:48 -08:00
72cb2737ca fix story tile. (#333) 2023-12-01 12:09:14 -08:00
215203bd16 remove error placeholder. (#332) 2023-12-01 11:27:16 -08:00
3e320faece update story title. (#331) 2023-12-01 09:56:19 -08:00
1049568246 bump Flutter version to 3.16.2 (#330) 2023-12-01 01:11:30 -08:00
71aa42118d fix web analyzer (#327) 2023-11-26 09:43:23 +09:00
4f21d3e6bd update pubspec.yaml (#325) 2023-11-15 10:50:00 -08:00
96d0fe9e5e fix new comment indicator. (#324) 2023-11-15 01:15:10 -08:00
69eee3e278 fix url rendering. (#323) 2023-11-14 23:52:05 -08:00
36bcd996c0 bump Flutter version to 3.13.9 (#322) 2023-11-14 23:22:09 -08:00
5fc39d8b8b fix code block formatting. (#321) 2023-11-14 20:25:42 -08:00
5dce7787e1 improve text rendering performance. (#320) 2023-11-14 17:14:06 -08:00
8888dde792 allow marking stories as read from homepage. (#319) 2023-11-14 14:35:27 -08:00
6c8fc4cf87 fix response indicator when lazy fetching is enabled. (#317) 2023-11-13 21:10:47 -08:00
ae9cc109db revert "improve caching strategy. (#312)" (#316) 2023-11-13 19:42:20 -08:00
c8976ed17b improve caching strategy. (#312) 2023-11-11 00:31:09 -08:00
ff7e115418 fix manual pagination button. (#310) 2023-11-06 22:46:44 -08:00
0310507c96 revert html util change. (#309) 2023-11-06 19:40:53 -08:00
58c646e232 update html_util.dart (#308) 2023-11-06 17:10:10 -08:00
08328e2ca1 update url_linkifier.dart (#307) 2023-11-06 14:19:25 -08:00
86b7228ffd improve response indicator. (#306) 2023-11-06 12:45:46 -08:00
e103c88ca6 fix favorites export. (#305) 2023-11-05 22:47:45 -08:00
94323a04e0 fix response indicator. (#304) 2023-11-05 21:22:02 -08:00
4776c375a1 UX improvements on HN and in-thread search. (#303) 2023-11-05 19:48:01 -08:00
1f4e6cf41c fix pagination button. (#298) 2023-11-02 21:50:09 -07:00
be6ed35888 update version. (#297) 2023-11-02 21:09:55 -07:00
b2ea50cea6 add pagination. (#296) 2023-11-02 20:22:51 -07:00
109b9287cf fix offline webview. (#295) 2023-11-02 17:17:46 -07:00
939d55ef0d fix in-thread search. (#294) 2023-11-02 14:51:46 -07:00
3ee60e1a44 improve in-thread search UX. (#293) 2023-11-02 14:34:24 -07:00
6fe567fa02 update design of about dialog. (#292) 2023-11-02 13:42:33 -07:00
bc2d4f32c9 show index on comment tile. (#291) 2023-11-02 13:11:10 -07:00
91290e9743 update README.md (#290) 2023-11-02 12:28:09 -07:00
934f184b6f fix material 3 colors. (#289) 2023-11-02 12:04:43 -07:00
dbd48eae99 fix reply box. (#288) 2023-11-01 23:00:00 -07:00
279007191b update feature description. (#287) 2023-11-01 22:17:57 -07:00
b3fdc20fc5 add ability to use material 3. (#286) 2023-11-01 19:48:09 -07:00
3fbf5d4eea improve shortcut button. (#284) 2023-10-22 20:34:09 -07:00
332ffbb773 bump version. (#282) 2023-10-22 00:14:12 -07:00
346a6c709e fix inconsistent font size. (#281) 2023-10-21 23:50:06 -07:00
d4fe042245 fix border color of comment tile. (#280) 2023-10-21 21:25:29 -07:00
b82c4a1777 update changelogs. (#279) 2023-10-21 20:45:24 -07:00
7e0d1f0f1d add ability to use custom tabs. (#278) 2023-10-21 20:25:45 -07:00
f405a10c2e fix color of quote element. (#277) 2023-10-21 19:43:06 -07:00
edbad79cd3 add ability to customize text scale factor and improve keyword filter. (#276) 2023-10-21 18:50:51 -07:00
c9d8b2950a add ability to change app's primary color. (#275) 2023-10-21 01:02:44 -07:00
f2bc48f980 update project.pbxproj file. (#271) 2023-09-29 19:30:56 -07:00
d56697c57c add ability to render code block inside comment text. (#266) 2023-09-29 18:50:40 -07:00
320ec41aae update url linkifier. (#270) 2023-09-29 16:21:41 -07:00
d85b3535d5 update url linkifier. 2023-09-29 16:15:06 -07:00
f8cd1cbba0 update url_linkifier.dart (#269) 2023-09-29 14:56:11 -07:00
817ec208d6 fix url parser. (#268) 2023-09-29 12:34:20 -07:00
554a165789 fix selectable text. (#267) 2023-09-28 23:46:24 -07:00
0c680370ef add ability to long press on story title to copy link. (#265) 2023-09-28 14:10:08 -07:00
59541d2fcc update Fastfile (#264) 2023-09-28 01:48:20 -07:00
32083c3564 update fastlane. (#263) 2023-09-28 00:05:16 -07:00
258dbc4b8b fix url parsing. (#262) 2023-09-27 23:17:31 -07:00
6c8047ebac feature discovery cleanup. (#259) 2023-09-19 00:16:27 -07:00
00a0135867 fix draft saving. (#258) 2023-09-18 22:49:11 -07:00
1db7be7a2c fix draft saving. (#257) 2023-09-18 22:16:47 -07:00
ff400f9c40 fix reply view. (#256) 2023-09-18 20:31:44 -07:00
f03b45a98a update pubspec.lock (#255) 2023-09-17 17:59:29 -07:00
cbe5bba986 bump flutter version. (#254) 2023-09-17 17:38:44 -07:00
268f4054a3 improve story marking. (#253) 2023-09-11 20:42:33 -07:00
988c5d9881 add haptic feedback. (#252) 2023-09-11 18:08:21 -07:00
e748e2f818 allow swipe gesture in fav screen. (#251) 2023-09-11 17:01:42 -07:00
1b0a0dbda9 add changelog. (#250) 2023-09-11 15:22:32 -07:00
64d68389ba migrate from Navigator to GoRouter (#249) 2023-09-10 22:26:46 -07:00
381c99b353 fix crashing. (#248) 2023-09-08 09:07:48 -07:00
39ee3137f8 fix reply box in full screen. (#247) 2023-09-05 15:17:23 -07:00
0d76be8634 bump flutter version. (#243) 2023-08-22 06:54:59 -07:00
9986f72e11 improve shortcut buttons. (#242) 2023-07-19 21:09:24 -07:00
196 changed files with 4519 additions and 3047 deletions

View File

@ -9,13 +9,14 @@ on:
jobs: jobs:
releases: releases:
name: Check commit name: Check commit
runs-on: ubuntu-latest runs-on: macos-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: checkout all the submodules - name: checkout all the submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: recursive submodules: recursive
fetch-depth: 0
- run: submodules/flutter/bin/flutter doctor - run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get - run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test - run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test

View File

@ -23,6 +23,7 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: recursive submodules: recursive
fetch-depth: 0
- run: submodules/flutter/bin/flutter doctor - run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get - run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test - run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test

View File

@ -1,7 +1,7 @@
# <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 [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough. A [Hacker News](https://news.ycombinator.com/) client built with Flutter.
[![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/)
@ -35,22 +35,20 @@ Features:
<p align="center"> <p align="center">
<img width="200" alt="01" src="assets/screenshots/01.png"> <img width="400" alt="01" src="assets/screenshots/light-1.png">
<img width="200" alt="02" src="assets/screenshots/02.png"> <img width="400" alt="06" src="assets/screenshots/dark-1.png">
<img width="200" alt="03" src="assets/screenshots/03.png"> <img width="400" alt="02" src="assets/screenshots/light-2.png">
<img width="200" alt="04" src="assets/screenshots/04.png"> <img width="400" alt="07" src="assets/screenshots/dark-2.png">
<img width="200" alt="05" src="assets/screenshots/05.png"> <img width="400" alt="03" src="assets/screenshots/light-3.png">
<img width="200" alt="06" src="assets/screenshots/06.png"> <img width="400" alt="08" src="assets/screenshots/dark-3.png">
<img width="200" alt="07" src="assets/screenshots/07.png"> <img width="400" alt="04" src="assets/screenshots/light-4.png">
<img width="200" alt="08" src="assets/screenshots/08.png"> <img width="400" alt="09" src="assets/screenshots/dark-4.png">
<img width="200" alt="09" src="assets/screenshots/09.png"> <img width="400" alt="05" src="assets/screenshots/light-5.png">
<img width="200" alt="10" src="assets/screenshots/10.png"> <img width="400" alt="10" src="assets/screenshots/dark-5.png">
<img width="200" alt="11" src="assets/screenshots/11.png">
<img width="200" alt="12" src="assets/screenshots/12.png">
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png"> <img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png"> <img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png"> <img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png"> <img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
</p> </p>

View File

@ -50,7 +50,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.jiaqifeng.hacki" applicationId "com.jiaqifeng.hacki"
minSdkVersion 26 minSdkVersion 25
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View File

@ -13,6 +13,9 @@
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent> </intent>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries> </queries>
<application <application
@ -20,7 +23,8 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop" android:launchMode="singleTop"

Binary file not shown.

Binary file not shown.

BIN
assets/hacki-github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/tablet-hacki.xcf Normal file

Binary file not shown.

View File

@ -1,121 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_review_platform_interface/method_channel_in_app_review.dart';
import 'package:platform/platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MethodChannelInAppReview methodChannelInAppReview;
late List<MethodCall> log = <MethodCall>[];
const MethodChannel channel = MethodChannel('dev.britannio.in_app_review');
setUp(() {
methodChannelInAppReview = MethodChannelInAppReview();
methodChannelInAppReview.channel = channel;
log = <MethodCall>[];
});
tearDown(() {
log.clear();
});
group('isAvailable', () {
test(
'should invoke the isAvailable method channel',
() async {
// ACT
final bool result = await methodChannelInAppReview.isAvailable();
// ASSERT
expect(log, <Matcher>[isMethodCall('isAvailable', arguments: null)]);
expect(result, isTrue);
},
);
});
group('requestReview', () {
test(
'should invoke the requestReview method channel',
() async {
// ACT
await methodChannelInAppReview.requestReview();
// ASSERT
expect(log, <Matcher>[isMethodCall('requestReview', arguments: null)]);
},
);
});
group('openStoreListing', () {
test(
'should invoke the openStoreListing method channel on Android',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'android');
// ACT
await methodChannelInAppReview.openStoreListing();
// ASSERT
expect(
log,
<Matcher>[isMethodCall('openStoreListing', arguments: null)],
);
},
);
test(
'should invoke the openStoreListing method channel on iOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'ios');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on MacOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'macos');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on Windows',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'windows');
final String microsoftStoreId = 'store_id';
// ACT
await methodChannelInAppReview.openStoreListing(
microsoftStoreId: microsoftStoreId,
);
// ASSERT
expect(log, <Matcher>[
isMethodCall('openStoreListing', arguments: microsoftStoreId)
]);
},
skip:
'The windows uwp implementation still uses the url_launcher package',
);
});
}

View File

@ -76,6 +76,15 @@ final class SharedPrefsCore {
return true return true
} }
fileprivate func remove(key: String?) -> Bool{
if let key = key {
let keyStore = NSUbiquitousKeyValueStore()
keyStore.removeObject(forKey: key)
}
return true
}
} }
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin { public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "remove":
if let params = call.arguments as? [String: Any] {
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.remove(key: key)
result(res)
}
case "setBool": case "setBool":
if let params = call.arguments as? [String: Any] { if let params = call.arguments as? [String: Any] {
let val = params[valKey] as? Bool let val = params[valKey] as? Bool

View File

@ -15,6 +15,14 @@ class SyncedSharedPreferences {
const MethodChannel(channel), const MethodChannel(channel),
); );
Future<bool?> remove({
required String key,
}) async {
return _channel.invokeMethod('remove', <String, dynamic>{
'key': key,
});
}
Future<bool?> setBool({ Future<bool?> setBool({
required String key, required String key,
required bool val, required bool val,

View File

@ -0,0 +1 @@
- Ability to mark a story as read once scrolling past.

View File

@ -0,0 +1,2 @@
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,4 @@
- Ability to use Material 3.
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,5 @@
- Ability to use pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,5 @@
- Ability to use manual pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,4 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

@ -0,0 +1,4 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

@ -0,0 +1,4 @@
- RobotoSlab as default font.
- Material 3 design.
- Ability to sync favorites from your Hacker News account.
- Support for predictive back gesture.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -137,11 +137,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios" :path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 flutter_inappwebview: 3d32228f1304635e7c028b0d4252937730bbc6cf
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
@ -151,19 +151,19 @@ SPEC CHECKSUMS:
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7 synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937 PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
COCOAPODS: 1.11.3 COCOAPODS: 1.13.0

View File

@ -291,7 +291,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1330; LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1300; LastUpgradeCheck = 1430;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1300" LastUpgradeVersion = "1430"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -77,8 +77,8 @@
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string> <string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key> <key>io.flutter.embedded_views_preview</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>

View File

@ -11,13 +11,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({ AuthBloc({
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(), }) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(const AuthState.init()) { super(const AuthState.init()) {
@ -31,7 +31,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository; final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
Future<void> onInitialize( Future<void> onInitialize(
@ -41,7 +41,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; final String? username = await _authRepository.username;
User? user = await _storiesRepository.fetchUser(id: username!); User? user = await _hackerNewsRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation, /// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story), /// if user has no public activity (posting a comment or story),
@ -52,14 +52,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
state.copyWith( state.copyWith(
isLoggedIn: true, isLoggedIn: true,
user: user, user: user,
status: AuthStatus.loaded, status: Status.success,
), ),
); );
} else { } else {
emit( emit(
state.copyWith( state.copyWith(
isLoggedIn: false, isLoggedIn: false,
status: AuthStatus.loaded, status: Status.success,
), ),
); );
} }
@ -81,7 +81,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
} }
Future<void> onLogin(AuthLogin event, Emitter<AuthState> emit) async { Future<void> onLogin(AuthLogin event, Emitter<AuthState> emit) async {
emit(state.copyWith(status: AuthStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final bool successful = await _authRepository.login( final bool successful = await _authRepository.login(
username: event.username, username: event.username,
@ -89,16 +89,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User? user = await _storiesRepository.fetchUser(id: event.username); final User? user =
await _hackerNewsRepository.fetchUser(id: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user ?? User.emptyWithId(event.username), user: user ?? User.emptyWithId(event.username),
isLoggedIn: true, isLoggedIn: true,
status: AuthStatus.loaded, status: Status.success,
), ),
); );
} else { } else {
emit(state.copyWith(status: AuthStatus.failure)); emit(state.copyWith(status: Status.failure));
} }
} }

View File

@ -1,11 +1,5 @@
part of 'auth_bloc.dart'; part of 'auth_bloc.dart';
enum AuthStatus {
loading,
loaded,
failure,
}
class AuthState extends Equatable { class AuthState extends Equatable {
const AuthState({ const AuthState({
required this.user, required this.user,
@ -17,13 +11,13 @@ class AuthState extends Equatable {
const AuthState.init() const AuthState.init()
: user = const User.empty(), : user = const User.empty(),
isLoggedIn = false, isLoggedIn = false,
status = AuthStatus.loaded, status = Status.success,
agreedToEULA = false; agreedToEULA = false;
final User user; final User user;
final bool isLoggedIn; final bool isLoggedIn;
final bool agreedToEULA; final bool agreedToEULA;
final AuthStatus status; final Status status;
String get username => user.id; String get username => user.id;
@ -31,7 +25,7 @@ class AuthState extends Equatable {
User? user, User? user,
bool? isLoggedIn, bool? isLoggedIn,
bool? agreedToEULA, bool? agreedToEULA,
AuthStatus? status, Status? status,
}) { }) {
return AuthState( return AuthState(
user: user ?? this.user, user: user ?? this.user,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
@ -19,24 +20,32 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit, required FilterCubit filterCubit,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger, Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit, _filterCubit = filterCubit,
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) { super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: sequential(),
);
on<StoriesInitialize>(onInitialize); on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh); on<StoriesRefresh>(onRefresh);
on<StoriesLoadMore>(onLoadMore); on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded); on<StoryLoaded>(
onStoryLoaded,
transformer: sequential(),
);
on<StoryRead>(onStoryRead); on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded); on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload); on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload); on<StoriesCancelDownload>(onCancelDownload);
@ -49,7 +58,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit; final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger; final Logger _logger;
DeviceScreenType? deviceScreenType; DeviceScreenType? deviceScreenType;
@ -79,7 +88,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
const StoriesState.init().copyWith( const StoriesState.init().copyWith(
isOfflineReading: hasCachedStories && isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session. // Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial, state.downloadStatus == StoriesDownloadStatus.idle,
currentPageSize: pageSize, currentPageSize: pageSize,
downloadStatus: state.downloadStatus, downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded, storiesDownloaded: state.storiesDownloaded,
@ -87,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
), ),
); );
for (final StoryType type in StoryType.values) { for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit); add(LoadStories(type: type));
} }
} }
Future<void> loadStories({ Future<void> onLoadStories(
required StoryType type, LoadStories event,
required Emitter<StoriesState> emit, Emitter<StoriesState> emit,
}) async { ) async {
final StoryType type = event.type;
if (state.isOfflineReading) { if (state.isOfflineReading) {
final List<int> ids = final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type); await _offlineRepository.getCachedStoryIds(type: type);
@ -113,19 +123,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: type)); add(StoriesLoaded(type: type));
}); });
} else { } else {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type); final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
emit( emit(
state state
.copyWithStoryIdsUpdated(type: type, to: ids) .copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0), .copyWithCurrentPageUpdated(type: type, to: 0),
); );
_storiesRepository await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize)) .fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) { .listen((Story story) {
add(StoryLoaded(story: story, type: type)); add(StoryLoaded(story: story, type: type));
}).onDone(() { }).asFuture<void>();
add(StoriesLoaded(type: type)); add(StoriesLoaded(type: type));
});
} }
} }
@ -133,12 +143,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesRefresh event, StoriesRefresh event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
if (state.statusByType[event.type] == StoriesStatus.loading) return; if (state.statusByType[event.type] == Status.inProgress) return;
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
to: StoriesStatus.loading, to: Status.inProgress,
), ),
); );
@ -146,12 +156,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
to: StoriesStatus.loaded, to: Status.success,
), ),
); );
} else { } else {
emit(state.copyWithRefreshed(type: event.type)); emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit); add(LoadStories(type: event.type));
} }
} }
@ -159,7 +169,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
to: StoriesStatus.loading, to: Status.inProgress,
), ),
); );
@ -196,7 +206,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: event.type)); add(StoriesLoaded(type: event.type));
}); });
} else { } else {
_storiesRepository _hackerNewsRepository
.fetchStoriesStream( .fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist( ids: state.storyIdsByType[event.type]!.sublist(
lower, lower,
@ -218,7 +228,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
to: StoriesStatus.loaded, to: Status.success,
), ),
); );
} }
@ -228,16 +238,18 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoryLoaded event, StoryLoaded event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id); final Story story = event.story;
final bool hidden = _filterCubit.state.keywords.any( final bool hasRead = await _preferenceRepository.hasRead(story.id);
(String keyword) => final bool hidden = _filterCubit.state.keywords.any((String keyword) {
event.story.title.toLowerCase().contains(keyword) || // Match word only.
event.story.text.toLowerCase().contains(keyword), final RegExp regExp = RegExp('\\b($keyword)\\b');
); return regExp.hasMatch(story.title.toLowerCase()) ||
regExp.hasMatch(story.text.toLowerCase());
});
emit( emit(
state.copyWithStoryAdded( state.copyWithStoryAdded(
type: event.type, type: event.type,
story: event.story.copyWith(hidden: hidden), story: story.copyWith(hidden: hidden),
hasRead: hasRead, hasRead: hasRead,
), ),
); );
@ -245,7 +257,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) { void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit( emit(
state.copyWithStatusUpdated(type: event.type, to: StoriesStatus.loaded), state.copyWithStatusUpdated(type: event.type, to: Status.success),
); );
} }
@ -271,7 +283,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
..remove(StoryType.latest); ..remove(StoryType.latest);
for (final StoryType type in prioritizedTypes) { for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type); final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
await _offlineRepository.cacheStoryIds(type: type, ids: ids); await _offlineRepository.cacheStoryIds(type: type, ids: ids);
prioritizedIds.addAll(ids); prioritizedIds.addAll(ids);
} }
@ -291,7 +304,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
); );
final Set<int> latestIds = <int>{}; final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds( final List<int> ids = await _hackerNewsRepository.fetchStoryIds(
type: StoryType.latest, type: StoryType.latest,
); );
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids); await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
@ -345,7 +358,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
_logger.d('fetching story $id'); _logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStory(id: id); final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) { if (story == null) {
if (isPrioritized) { if (isPrioritized) {
@ -375,7 +388,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
/// In other words, we are prioritizing the story itself instead of /// In other words, we are prioritizing the story itself instead of
/// the comments in the story. /// the comments in the story.
late final StreamSubscription<Comment>? downloadStream; late final StreamSubscription<Comment>? downloadStream;
downloadStream = _storiesRepository downloadStream = _hackerNewsRepository
.fetchAllChildrenComments(ids: story.kids) .fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>() .whereType<Comment>()
.listen( .listen(
@ -458,7 +471,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoryRead event, StoryRead event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
unawaited(_preferenceRepository.updateHasRead(event.story.id)); unawaited(_preferenceRepository.addHasRead(event.story.id));
emit( emit(
state.copyWith( state.copyWith(
@ -467,6 +480,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
); );
} }
Future<void> onStoryUnread(
StoryUnread event,
Emitter<StoriesState> emit,
) async {
unawaited(_preferenceRepository.removeHasRead(event.story.id));
emit(
state.copyWith(
readStoriesIds: <int>{...state.readStoriesIds}..remove(event.story.id),
),
);
}
Future<void> onClearAllReadStories( Future<void> onClearAllReadStories(
ClearAllReadStories event, ClearAllReadStories event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,

View File

@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
final StoryType type;
@override
List<Object?> get props => <Object?>[type];
}
class StoriesInitialize extends StoriesEvent { class StoriesInitialize extends StoriesEvent {
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
@ -95,6 +104,15 @@ class StoryRead extends StoriesEvent {
List<Object?> get props => <Object?>[story]; List<Object?> get props => <Object?>[story];
} }
class StoryUnread extends StoriesEvent {
StoryUnread({required this.story});
final Story story;
@override
List<Object?> get props => <Object?>[story];
}
class ClearAllReadStories extends StoriesEvent { class ClearAllReadStories extends StoriesEvent {
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];

View File

@ -1,13 +1,7 @@
part of 'stories_bloc.dart'; part of 'stories_bloc.dart';
enum StoriesStatus {
initial,
loading,
loaded,
}
enum StoriesDownloadStatus { enum StoriesDownloadStatus {
initial, idle,
downloading, downloading,
finished, finished,
failure, failure,
@ -43,12 +37,12 @@ class StoriesState extends Equatable {
StoryType.ask: <int>[], StoryType.ask: <int>[],
StoryType.show: <int>[], StoryType.show: <int>[],
}, },
this.statusByType = const <StoryType, StoriesStatus>{ this.statusByType = const <StoryType, Status>{
StoryType.top: StoriesStatus.initial, StoryType.top: Status.idle,
StoryType.best: StoriesStatus.initial, StoryType.best: Status.idle,
StoryType.latest: StoriesStatus.initial, StoryType.latest: Status.idle,
StoryType.ask: StoriesStatus.initial, StoryType.ask: Status.idle,
StoryType.show: StoriesStatus.initial, StoryType.show: Status.idle,
}, },
this.currentPageByType = const <StoryType, int>{ this.currentPageByType = const <StoryType, int>{
StoryType.top: 0, StoryType.top: 0,
@ -58,7 +52,7 @@ class StoriesState extends Equatable {
StoryType.show: 0, StoryType.show: 0,
}, },
}) : isOfflineReading = false, }) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial, downloadStatus = StoriesDownloadStatus.idle,
currentPageSize = 0, currentPageSize = 0,
readStoriesIds = const <int>{}, readStoriesIds = const <int>{},
storiesDownloaded = 0, storiesDownloaded = 0,
@ -66,7 +60,7 @@ class StoriesState extends Equatable {
final Map<StoryType, List<Story>> storiesByType; final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType; final Map<StoryType, List<int>> storyIdsByType;
final Map<StoryType, StoriesStatus> statusByType; final Map<StoryType, Status> statusByType;
final Map<StoryType, int> currentPageByType; final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds; final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus; final StoriesDownloadStatus downloadStatus;
@ -78,7 +72,7 @@ class StoriesState extends Equatable {
StoriesState copyWith({ StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType, Map<StoryType, List<Story>>? storiesByType,
Map<StoryType, List<int>>? storyIdsByType, Map<StoryType, List<int>>? storyIdsByType,
Map<StoryType, StoriesStatus>? statusByType, Map<StoryType, Status>? statusByType,
Map<StoryType, int>? currentPageByType, Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds, Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus, StoriesDownloadStatus? downloadStatus,
@ -133,10 +127,10 @@ class StoriesState extends Equatable {
StoriesState copyWithStatusUpdated({ StoriesState copyWithStatusUpdated({
required StoryType type, required StoryType type,
required StoriesStatus to, required Status to,
}) { }) {
final Map<StoryType, StoriesStatus> newMap = final Map<StoryType, Status> newMap =
Map<StoryType, StoriesStatus>.from(statusByType); Map<StoryType, Status>.from(statusByType);
newMap[type] = to; newMap[type] = to;
return copyWith( return copyWith(
statusByType: newMap, statusByType: newMap,
@ -162,9 +156,9 @@ class StoriesState extends Equatable {
final Map<StoryType, List<int>> newStoryIdsMap = final Map<StoryType, List<int>> newStoryIdsMap =
Map<StoryType, List<int>>.from(storyIdsByType); Map<StoryType, List<int>>.from(storyIdsByType);
newStoryIdsMap[type] = <int>[]; newStoryIdsMap[type] = <int>[];
final Map<StoryType, StoriesStatus> newStatusMap = final Map<StoryType, Status> newStatusMap =
Map<StoryType, StoriesStatus>.from(statusByType); Map<StoryType, Status>.from(statusByType);
newStatusMap[type] = StoriesStatus.loading; newStatusMap[type] = Status.inProgress;
final Map<StoryType, int> newCurrentPageMap = final Map<StoryType, int> newCurrentPageMap =
Map<StoryType, int>.from(currentPageByType); Map<StoryType, int>.from(currentPageByType);
newCurrentPageMap[type] = 0; newCurrentPageMap[type] = 0;

View File

@ -20,6 +20,8 @@ abstract class Constants {
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.'; '$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/'; static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/'; static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String hackerNewsItemLinkPrefix =
'https://news.ycombinator.com/item?id=';
static const String supportEmail = 'georgefung98@gmail.com'; static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images'; static const String _imagePath = 'assets/images';
@ -34,14 +36,6 @@ abstract class Constants {
static const String logFilename = 'hacki_log.txt'; static const String logFilename = 'hacki_log.txt';
static const String previousLogFileName = 'old_hacki_log.txt'; static const String previousLogFileName = 'old_hacki_log.txt';
/// Feature ids for feature discovery.
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
static const String featureJumpUpButton = 'jump_up_button';
static const String featureJumpDownButton = 'jump_down_button';
static final String happyFace = <String>[ static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧', '(๑•̀ㅂ•́)و✧',
'( ͡• ͜ʖ ͡•)', '( ͡• ͜ʖ ͡•)',
@ -79,7 +73,7 @@ abstract class RegExpConstants {
static const String number = '[0-9]+'; static const String number = '[0-9]+';
} }
abstract class Durations { abstract class AppDurations {
static const Duration ms100 = Duration(milliseconds: 100); static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200); static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300); static const Duration ms300 = Duration(milliseconds: 300);

View File

@ -2,7 +2,7 @@ import 'package:logger/logger.dart';
class CustomLogFilter extends LogFilter { class CustomLogFilter extends LogFilter {
@override @override
Level? get level => Level.verbose; Level? get level => Level.trace;
/// The minimal level allowed in production. /// The minimal level allowed in production.
static const Level _minimalLevel = Level.info; static const Level _minimalLevel = Level.info;

View File

@ -1,53 +1,76 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
/// Custom router. final GoRouter router = GoRouter(
/// observers: <NavigatorObserver>[
/// Handle named routing. locator.get<RouteObserver<ModalRoute<dynamic>>>(),
class CustomRouter { ],
/// Top level routing. initialLocation: HomeScreen.routeName,
static Route<dynamic> onGenerateRoute(RouteSettings settings) { routes: <RouteBase>[
switch (settings.name) { GoRoute(
case HomeScreen.routeName: path: HomeScreen.routeName,
return HomeScreen.route(); builder: (_, __) => const HomeScreen(),
case SubmitScreen.routeName: routes: <RouteBase>[
return SubmitScreen.route(); GoRoute(
case QrCodeScannerScreen.routeName: path: ItemScreen.routeName,
return QrCodeScannerScreen.route(); builder: (_, GoRouterState state) {
case ItemScreen.routeName: final ItemScreenArgs? args = state.extra as ItemScreenArgs?;
return ItemScreen.route(settings.arguments! as ItemScreenArgs); if (args == null) {
case QrCodeViewScreen.routeName: throw GoError("args can't be null");
return QrCodeViewScreen.route(data: settings.arguments! as String); }
default: return ItemScreen.phone(args);
return _errorRoute(); },
}
}
/// Nested routing for bottom navigation bar.
static Route<dynamic> onGenerateNestedRoute(RouteSettings settings) {
switch (settings.name) {
case ItemScreen.routeName:
return ItemScreen.route(settings.arguments! as ItemScreenArgs);
case SubmitScreen.routeName:
return SubmitScreen.route();
default:
return _errorRoute();
}
}
/// Error route.
static Route<dynamic> _errorRoute() {
return MaterialPageRoute<dynamic>(
settings: const RouteSettings(name: '/error'),
builder: (_) => Scaffold(
appBar: AppBar(
title: const Text('Error'),
),
body: Center(
child: Text(Constants.errorMessage),
), ),
],
),
GoRoute(
path: '/${ItemScreen.routeName}',
builder: (_, GoRouterState state) {
final ItemScreenArgs? args = state.extra as ItemScreenArgs?;
if (args == null) {
throw GoError("args can't be null");
}
return ItemScreen.phone(args);
},
),
GoRoute(
path: '/${SubmitScreen.routeName}',
builder: (_, __) => BlocProvider<SubmitCubit>(
create: (_) => SubmitCubit(),
child: const SubmitScreen(),
), ),
); ),
} GoRoute(
} path: '/${QrCodeScannerScreen.routeName}',
builder: (_, __) => const QrCodeScannerScreen(),
),
GoRoute(
path: '/${QrCodeViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? data = state.extra as String?;
if (data == null) {
throw GoError("data can't be null");
}
return QrCodeViewScreen(
data: data,
);
},
),
GoRoute(
path: '/${WebViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? link = state.extra as String?;
if (link == null) {
throw GoError("link can't be null");
}
return WebViewScreen(
url: link,
);
},
),
],
);

View File

@ -20,7 +20,7 @@ class CustomFileOutput extends LogOutput {
IOSink? _sink; IOSink? _sink;
@override @override
void init() { Future<void> init() async {
_sink = file.openWrite( _sink = file.openWrite(
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend, mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: encoding, encoding: encoding,

View File

@ -23,12 +23,13 @@ Future<void> setUpLocator() async {
output: LogUtil.logOutput(logOutputFile), output: LogUtil.logOutput(logOutputFile),
), ),
) )
..registerSingleton<StoriesRepository>(StoriesRepository()) ..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..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<OfflineRepository>(OfflineRepository()) ..registerSingleton<OfflineRepository>(OfflineRepository())
..registerSingleton<DraftCache>(DraftCache()) ..registerSingleton<DraftCache>(DraftCache())
..registerSingleton<CommentCache>(CommentCache()) ..registerSingleton<CommentCache>(CommentCache())

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
@ -28,11 +29,12 @@ class CollapseCubit extends Cubit<CollapseState> {
collapsedCount: _collapseCache.totalHidden(_commentId), collapsedCount: _collapseCache.totalHidden(_commentId),
collapsed: _collapseCache.isCollapsed(_commentId), collapsed: _collapseCache.isCollapsed(_commentId),
hidden: _collapseCache.isHidden(_commentId), hidden: _collapseCache.isHidden(_commentId),
locked: _collapseCache.lockedId == _commentId,
), ),
); );
} }
void collapse() { void collapse({required VoidCallback onStateChanged}) {
if (state.collapsed) { if (state.collapsed) {
_collapseCache.uncollapse(_commentId); _collapseCache.uncollapse(_commentId);
@ -42,7 +44,14 @@ class CollapseCubit extends Cubit<CollapseState> {
collapsedCount: 0, collapsedCount: 0,
), ),
); );
onStateChanged();
} else { } else {
if (state.locked) {
emit(state.copyWith(locked: false));
return;
}
final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId); final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId);
emit( emit(
@ -51,6 +60,8 @@ class CollapseCubit extends Cubit<CollapseState> {
collapsedCount: state.collapsed ? 0 : collapsedCommentIds.length, collapsedCount: state.collapsed ? 0 : collapsedCommentIds.length,
), ),
); );
onStateChanged();
} }
} }
@ -85,6 +96,13 @@ class CollapseCubit extends Cubit<CollapseState> {
} }
} }
/// Prevent the item to be able to collapse, used when the comment
/// text is selected.
void lock() {
_collapseCache.lockedId = _commentId;
emit(state.copyWith(locked: true));
}
@override @override
Future<void> close() async { Future<void> close() async {
await _streamSubscription.cancel(); await _streamSubscription.cancel();

View File

@ -4,26 +4,39 @@ class CollapseState extends Equatable {
const CollapseState({ const CollapseState({
required this.collapsed, required this.collapsed,
required this.hidden, required this.hidden,
required this.locked,
required this.collapsedCount, required this.collapsedCount,
}); });
const CollapseState.init() const CollapseState.init()
: collapsed = false, : collapsed = false,
hidden = false, hidden = false,
locked = false,
collapsedCount = 0; collapsedCount = 0;
final bool collapsed; final bool collapsed;
/// The value determining whether or not the comment should show up in the
/// screen, this is true when the comment's parent is collapsed.
final bool hidden; final bool hidden;
/// The value determining whether or not the comment is collapsable.
/// If [locked] is true then the comment is not collapsable and vice versa.
final bool locked;
/// The number of children under this collapsed comment.
final int collapsedCount; final int collapsedCount;
CollapseState copyWith({ CollapseState copyWith({
bool? collapsed, bool? collapsed,
bool? hidden, bool? hidden,
bool? locked,
int? collapsedCount, int? collapsedCount,
}) { }) {
return CollapseState( return CollapseState(
collapsed: collapsed ?? this.collapsed, collapsed: collapsed ?? this.collapsed,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,
locked: locked ?? this.locked,
collapsedCount: collapsedCount ?? this.collapsedCount, collapsedCount: collapsedCount ?? this.collapsedCount,
); );
} }
@ -32,6 +45,7 @@ class CollapseState extends Equatable {
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
collapsed, collapsed,
hidden, hidden,
locked,
collapsedCount, collapsedCount,
]; ];
} }

View File

@ -6,9 +6,10 @@ import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart'; import 'package:hacki/extensions/extensions.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/screens/screens.dart';
@ -31,18 +32,18 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder, required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
Logger? logger, Logger? logger,
}) : _filterCubit = filterCubit, }) : _filterCubit = filterCubit,
_collapseCache = collapseCache, _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
@ -57,10 +58,14 @@ class CommentsCubit extends Cubit<CommentsState> {
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final Logger _logger; final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
/// The [StreamSubscription] for stream (both lazy or eager) /// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story. /// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
@ -91,7 +96,7 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
_streamSubscription = _storiesRepository _streamSubscription = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream( .fetchAllCommentsRecursivelyStream(
ids: targetAncestors!.last.kids, ids: targetAncestors!.last.kids,
level: targetAncestors.last.level + 1, level: targetAncestors.last.level + 1,
@ -106,8 +111,10 @@ class CommentsCubit extends Cubit<CommentsState> {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.inProgress,
comments: <Comment>[], comments: <Comment>[],
matchedComments: <int>[],
inThreadSearchQuery: '',
currentPage: 0, currentPage: 0,
), ),
); );
@ -115,7 +122,10 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.isOfflineReading final Item updatedItem = state.isOfflineReading
? item ? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ?? : await _hackerNewsRepository
.fetchItem(id: item.id)
.then(_toBuildable)
.onError((_, __) => item) ??
item; item;
final List<int> kids = _sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
@ -128,12 +138,13 @@ class CommentsCubit extends Cubit<CommentsState> {
} else { } else {
switch (state.fetchMode) { switch (state.fetchMode) {
case FetchMode.lazy: case FetchMode.lazy:
commentStream = _storiesRepository.fetchCommentsStream( commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids, ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null, getFromCache: useCommentCache ? _commentCache.getComment : null,
); );
case FetchMode.eager: case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream( commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids, ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null, getFromCache: useCommentCache ? _commentCache.getComment : null,
); );
@ -150,7 +161,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> refresh() async { Future<void> refresh() async {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.inProgress,
), ),
); );
@ -180,16 +191,16 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItem(id: item.id) ?? item; await _hackerNewsRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = _sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream; late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) { if (state.fetchMode == FetchMode.lazy) {
commentStream = _storiesRepository.fetchCommentsStream( commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids, ids: kids,
); );
} else { } else {
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream( commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids, ids: kids,
); );
} }
@ -213,6 +224,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.copyWith( state.copyWith(
onlyShowTargetComment: false, onlyShowTargetComment: false,
item: story, item: story,
matchedComments: <int>[],
), ),
); );
init(); init();
@ -224,7 +236,7 @@ class CommentsCubit extends Cubit<CommentsState> {
void Function(Comment)? onCommentFetched, void Function(Comment)? onCommentFetched,
VoidCallback? onDone, VoidCallback? onDone,
}) { }) {
if (comment == null && state.status == CommentsStatus.loading) return; if (comment == null && state.status == CommentsStatus.inProgress) return;
switch (state.fetchMode) { switch (state.fetchMode) {
case FetchMode.lazy: case FetchMode.lazy:
@ -237,14 +249,17 @@ class CommentsCubit extends Cubit<CommentsState> {
/// Ignoring because the subscription will be cancelled in close() /// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions // ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription = final StreamSubscription<Comment> streamSubscription =
_storiesRepository _hackerNewsRepository
.fetchCommentsStream(ids: comment.kids) .fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment) .asyncMap(_toBuildableComment)
.whereNotNull() .whereNotNull()
.listen((Comment cmt) { .listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent); _collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt); _commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit( emit(
state.copyWith( state.copyWith(
@ -252,6 +267,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.comments.indexOf(comment) + offset + 1, state.comments.indexOf(comment) + offset + 1,
cmt.copyWith(level: level), cmt.copyWith(level: level),
), ),
idToCommentMap: updatedIdToCommentMap,
), ),
); );
offset++; offset++;
@ -269,7 +285,7 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscriptions[comment.id] = streamSubscription; _streamSubscriptions[comment.id] = streamSubscription;
case FetchMode.eager: case FetchMode.eager:
if (_streamSubscription != null) { if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading)); emit(state.copyWith(status: CommentsStatus.inProgress));
_streamSubscription _streamSubscription
?..resume() ?..resume()
..onData(onCommentFetched); ..onData(onCommentFetched);
@ -279,16 +295,16 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadParentThread() async { Future<void> loadParentThread() async {
HapticFeedbackUtil.light(); HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading)); emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
final Item? parent = final Item? parent =
await _storiesRepository.fetchItem(id: state.item.parent); await _hackerNewsRepository.fetchItem(id: state.item.parent);
if (parent == null) { if (parent == null) {
return; return;
} else { } else {
await HackiApp.navigatorKey.currentState?.pushNamed( await router.push(
ItemScreen.routeName, '/${ItemScreen.routeName}',
arguments: ItemScreenArgs(item: parent), extra: ItemScreenArgs(item: parent),
); );
emit( emit(
@ -301,17 +317,17 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadRootThread() async { Future<void> loadRootThread() async {
HapticFeedbackUtil.light(); HapticFeedbackUtil.light();
emit(state.copyWith(fetchRootStatus: CommentsStatus.loading)); emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
final Story? parent = await _storiesRepository final Story? parent = await _hackerNewsRepository
.fetchParentStory(id: state.item.id) .fetchParentStory(id: state.item.id)
.then(_toBuildableStory); .then(_toBuildableStory);
if (parent == null) { if (parent == null) {
return; return;
} else { } else {
await HackiApp.navigatorKey.currentState?.pushNamed( await router.push(
ItemScreen.routeName, '/${ItemScreen.routeName}',
arguments: ItemScreenArgs(item: parent), extra: ItemScreenArgs(item: parent),
); );
emit( emit(
@ -322,7 +338,7 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
} }
void onOrderChanged(CommentsOrder? order) { void updateOrder(CommentsOrder? order) {
if (order == null) return; if (order == null) return;
if (state.order == order) return; if (state.order == order) return;
HapticFeedbackUtil.selection(); HapticFeedbackUtil.selection();
@ -335,7 +351,7 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true); init(useCommentCache: true);
} }
void onFetchModeChanged(FetchMode? fetchMode) { void updateFetchMode(FetchMode? fetchMode) {
if (fetchMode == null) return; if (fetchMode == null) return;
if (state.fetchMode == fetchMode) return; if (state.fetchMode == fetchMode) return;
_collapseCache.resetCollapsedComments(); _collapseCache.resetCollapsedComments();
@ -349,17 +365,26 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true); init(useCommentCache: true);
} }
/// Jump to next root level comment. void scrollTo({
void jump( required int index,
ItemScrollController itemScrollController, double alignment = 0.0,
ItemPositionsListener itemPositionsListener, }) {
) { debugPrint('Scrolling to: $index, alignment: $alignment');
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: AppDurations.ms400,
);
}
/// Scroll to next root level comment.
void scrollToNextRoot({VoidCallback? onError}) {
final int totalComments = state.comments.length; final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value .itemPositions.value
// The header is also a part of the list view, // The header is also a part of the list view,
// thus ignoring it here. // thus ignoring it here.
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge < 0.7) .where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge > 0.1)
.sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index)) .sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index))
.map( .map(
(ItemPosition e) => e.index <= state.comments.length (ItemPosition e) => e.index <= state.comments.length
@ -369,9 +394,29 @@ class CommentsCubit extends Cubit<CommentsState> {
.whereNotNull() .whereNotNull()
.toList(); .toList();
/// The index of last comment visible on screen. if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
final int lastVisibleIndex = state.comments.indexOf(onScreenComments.last); itemScrollController.scrollTo(
final int startIndex = min(lastVisibleIndex + 1, totalComments); index: 1,
alignment: 0.15,
duration: AppDurations.ms400,
);
return;
}
final Comment? firstVisibleRootComment =
onScreenComments.firstWhereOrNull((Comment e) => e.isRoot);
late int startIndex;
if (firstVisibleRootComment != null) {
/// The index of first root level comment visible on screen.
final int firstVisibleRootCommentIndex =
state.comments.indexOf(firstVisibleRootComment);
startIndex = min(firstVisibleRootCommentIndex + 1, totalComments);
} else {
final int lastVisibleCommentIndex =
state.comments.indexOf(onScreenComments.last);
startIndex = min(lastVisibleCommentIndex + 1, totalComments);
}
for (int i = startIndex; i < totalComments; i++) { for (int i = startIndex; i < totalComments; i++) {
final Comment cmt = state.comments.elementAt(i); final Comment cmt = state.comments.elementAt(i);
@ -380,18 +425,19 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: i + 1, index: i + 1,
alignment: 0.15, alignment: 0.15,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
return; return;
} }
} }
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
} }
/// Jump to previous root level comment. /// Scroll to previous root level comment.
void jumpUp( void scrollToPreviousRoot() {
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
final List<Comment> onScreenComments = itemPositionsListener final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value .itemPositions.value
// The header is also a part of the list view, // The header is also a part of the list view,
@ -419,13 +465,57 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo( itemScrollController.scrollTo(
index: i + 1, index: i + 1,
alignment: 0.15, alignment: 0.15,
duration: Durations.ms400, duration: AppDurations.ms400,
); );
return; return;
} }
} }
} }
void search(String query, {String author = ''}) {
resetSearch();
late final bool Function(Comment cmt) conditionSatisfied;
final String lowercaseQuery = query.toLowerCase();
if (query.isEmpty && author.isEmpty) {
return;
} else if (author.isEmpty) {
conditionSatisfied =
(Comment cmt) => cmt.text.toLowerCase().contains(lowercaseQuery);
} else if (query.isEmpty) {
conditionSatisfied = (Comment cmt) => cmt.by == author;
} else {
conditionSatisfied = (Comment cmt) =>
cmt.text.toLowerCase().contains(lowercaseQuery) && cmt.by == author;
}
emit(
state.copyWith(
inThreadSearchQuery: query,
inThreadSearchAuthor: author,
),
);
for (final int i in 0.to(state.comments.length, inclusive: false)) {
final Comment cmt = state.comments.elementAt(i);
if (conditionSatisfied(cmt)) {
emit(
state.copyWith(
matchedComments: <int>[...state.matchedComments, i],
),
);
}
}
}
void resetSearch() => emit(
state.copyWith(
matchedComments: <int>[],
inThreadSearchQuery: '',
inThreadSearchAuthor: '',
),
);
List<int> _sortKids(List<int> kids) { List<int> _sortKids(List<int> kids) {
switch (state.order) { switch (state.order) {
case CommentsOrder.natural: case CommentsOrder.natural:
@ -451,8 +541,12 @@ class CommentsCubit extends Cubit<CommentsState> {
if (comment != null) { if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent); _collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment); _commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
if (state.isOfflineReading) {
_sembastRepository.cacheComment(comment);
}
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any( final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword), (String keyword) => comment.text.toLowerCase().contains(keyword),
); );
@ -461,7 +555,16 @@ class CommentsCubit extends Cubit<CommentsState> {
comment.copyWith(hidden: hidden), comment.copyWith(hidden: hidden),
]; ];
emit(state.copyWith(comments: updatedComments)); final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit(
state.copyWith(
comments: updatedComments,
idToCommentMap: updatedIdToCommentMap,
),
);
} }
} }

View File

@ -1,17 +1,19 @@
part of 'comments_cubit.dart'; part of 'comments_cubit.dart';
enum CommentsStatus { enum CommentsStatus {
init, idle,
loading, inProgress,
loaded, loaded,
allLoaded, allLoaded,
failure, error,
} }
class CommentsState extends Equatable { class CommentsState extends Equatable {
const CommentsState({ const CommentsState({
required this.item, required this.item,
required this.comments, required this.comments,
required this.matchedComments,
required this.idToCommentMap,
required this.status, required this.status,
required this.fetchParentStatus, required this.fetchParentStatus,
required this.fetchRootStatus, required this.fetchRootStatus,
@ -20,6 +22,8 @@ class CommentsState extends Equatable {
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.isOfflineReading, required this.isOfflineReading,
required this.currentPage, required this.currentPage,
required this.inThreadSearchQuery,
required this.inThreadSearchAuthor,
}); });
CommentsState.init({ CommentsState.init({
@ -28,14 +32,19 @@ class CommentsState extends Equatable {
required this.fetchMode, required this.fetchMode,
required this.order, required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
status = CommentsStatus.init, matchedComments = <int>[],
fetchParentStatus = CommentsStatus.init, idToCommentMap = <int, Comment>{},
fetchRootStatus = CommentsStatus.init, status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0; currentPage = 0,
inThreadSearchQuery = '',
inThreadSearchAuthor = '';
final Item item; final Item item;
final List<Comment> comments; final List<Comment> comments;
final Map<int, Comment> idToCommentMap;
final CommentsStatus status; final CommentsStatus status;
final CommentsStatus fetchParentStatus; final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus; final CommentsStatus fetchRootStatus;
@ -44,10 +53,17 @@ class CommentsState extends Equatable {
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool isOfflineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
final String inThreadSearchQuery;
final String inThreadSearchAuthor;
/// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments;
CommentsState copyWith({ CommentsState copyWith({
Item? item, Item? item,
List<Comment>? comments, List<Comment>? comments,
List<int>? matchedComments,
Map<int, Comment>? idToCommentMap,
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus, CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus, CommentsStatus? fetchRootStatus,
@ -56,10 +72,13 @@ class CommentsState extends Equatable {
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? isOfflineReading, bool? isOfflineReading,
int? currentPage, int? currentPage,
String? inThreadSearchQuery,
String? inThreadSearchAuthor,
}) { }) {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
matchedComments: matchedComments ?? this.matchedComments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus, fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus, fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
status: status ?? this.status, status: status ?? this.status,
@ -69,11 +88,41 @@ class CommentsState extends Equatable {
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
isOfflineReading: isOfflineReading ?? this.isOfflineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
); );
} }
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet(); Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
static final Map<int, bool> _isResponseCache = <int, bool>{};
bool isResponse(Comment comment) {
if (_isResponseCache.containsKey(comment.id)) {
return _isResponseCache[comment.id]!;
}
if (comment.isRoot) {
_isResponseCache[comment.id] = false;
return false;
}
final Comment? precedingComment = idToCommentMap[comment.parent];
if (precedingComment == null) {
_isResponseCache[comment.id] = false;
return false;
} else if (item.id == precedingComment.parent && item.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else if (idToCommentMap[precedingComment.parent]?.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else {
_isResponseCache[comment.id] = false;
return false;
}
}
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,
@ -86,5 +135,9 @@ class CommentsState extends Equatable {
isOfflineReading, isOfflineReading,
currentPage, currentPage,
comments, comments,
matchedComments,
inThreadSearchQuery,
inThreadSearchAuthor,
idToCommentMap,
]; ];
} }

View File

@ -12,12 +12,14 @@ part 'edit_state.dart';
class EditCubit extends HydratedCubit<EditState> { class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache}) EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(), : _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: Durations.oneSecond), _debouncer = Debouncer(delay: AppDurations.oneSecond),
super(const EditState.init()); super(const EditState.init());
final DraftCache _draftCache; final DraftCache _draftCache;
final Debouncer _debouncer; final Debouncer _debouncer;
void reset() => emit(const EditState.init());
void onReplyTapped(Item item) { void onReplyTapped(Item item) {
emit( emit(
EditState( EditState(
@ -36,14 +38,6 @@ class EditCubit extends HydratedCubit<EditState> {
); );
} }
void onReplyBoxClosed() {
emit(const EditState.init());
}
void onScrolled() {
emit(const EditState.init());
}
void onReplySubmittedSuccessfully() { void onReplySubmittedSuccessfully() {
if (state.replyingTo != null) { if (state.replyingTo != null) {
_draftCache.removeDraft(replyingTo: state.replyingTo!.id); _draftCache.removeDraft(replyingTo: state.replyingTo!.id);
@ -65,9 +59,14 @@ class EditCubit extends HydratedCubit<EditState> {
} }
} }
void deleteDraft() => clear(); void deleteDraft() {
// Remove draft in storage.
bool called = false; clear();
// Reset cached state.
_cachedState = const EditState.init();
// Reset to init state;
reset();
}
@override @override
EditState? fromJson(Map<String, dynamic> json) { EditState? fromJson(Map<String, dynamic> json) {
@ -96,6 +95,7 @@ class EditCubit extends HydratedCubit<EditState> {
Map<String, dynamic>? toJson(EditState state) { Map<String, dynamic>? toJson(EditState state) {
EditState selected = state; EditState selected = state;
// Override previous draft only when current draft is not empty.
if (state.replyingTo == null || if (state.replyingTo == null ||
(state.replyingTo?.id != _cachedState.replyingTo?.id && (state.replyingTo?.id != _cachedState.replyingTo?.id &&
state.text.isNullOrEmpty)) { state.text.isNullOrEmpty)) {

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
@ -12,13 +15,16 @@ class FavCubit extends Cubit<FavState> {
required AuthBloc authBloc, required AuthBloc authBloc,
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -26,44 +32,42 @@ class FavCubit extends Cubit<FavState> {
final AuthBloc _authBloc; final AuthBloc _authBloc;
final AuthRepository _authRepository; final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 20;
String? _username;
Future<void> init() async { Future<void> init() async {
_authBloc.stream.listen((AuthState authState) { _usernameSubscription = _authBloc.stream
if (authState.username != _username) { .map((AuthState event) => event.username)
_preferenceRepository .distinct()
.favList(of: authState.username) .listen((String username) {
.then((List<int> favIds) { _preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit( emit(
state.copyWith( state.copyWith(
favIds: favIds, status: Status.success,
favItems: <Item>[],
currentPage: 0,
), ),
); );
_storiesRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
status: FavStatus.loaded,
),
);
});
}); });
});
_username = authState.username;
}
}); });
} }
Future<void> addFav(int id) async { Future<void> addFav(int id) async {
final String username = _authBloc.state.username; if (state.favIds.contains(id)) return;
await _preferenceRepository.addFav(username: username, id: id); await _preferenceRepository.addFav(username: username, id: id);
@ -73,7 +77,7 @@ class FavCubit extends Cubit<FavState> {
), ),
); );
final Item? item = await _storiesRepository.fetchItem(id: id); final Item? item = await _hackerNewsRepository.fetchItem(id: id);
if (item == null) return; if (item == null) return;
@ -89,8 +93,6 @@ class FavCubit extends Cubit<FavState> {
} }
void removeFav(int id) { void removeFav(int id) {
final String username = _authBloc.state.username;
_preferenceRepository.removeFav(username: username, id: id); _preferenceRepository.removeFav(username: username, id: id);
emit( emit(
@ -107,7 +109,7 @@ class FavCubit extends Cubit<FavState> {
} }
void loadMore() { void loadMore() {
emit(state.copyWith(status: FavStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage; final int currentPage = state.currentPage;
final int len = state.favIds.length; final int len = state.favIds.length;
emit(state.copyWith(currentPage: currentPage + 1)); emit(state.copyWith(currentPage: currentPage + 1));
@ -119,7 +121,7 @@ class FavCubit extends Cubit<FavState> {
upper = len; upper = len;
} }
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: state.favIds.sublist( ids: state.favIds.sublist(
lower, lower,
@ -128,19 +130,17 @@ class FavCubit extends Cubit<FavState> {
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: FavStatus.loaded)); emit(state.copyWith(status: Status.success));
}); });
} else { } else {
emit(state.copyWith(status: FavStatus.loaded)); emit(state.copyWith(status: Status.success));
} }
} }
void refresh() { void refresh() {
final String username = _authBloc.state.username;
emit( emit(
state.copyWith( state.copyWith(
status: FavStatus.loading, status: Status.inProgress,
currentPage: 0, currentPage: 0,
favItems: <Item>[], favItems: <Item>[],
favIds: <int>[], favIds: <int>[],
@ -149,13 +149,13 @@ 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 _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: FavStatus.loaded)); emit(state.copyWith(status: Status.success));
}); });
}); });
} }
@ -167,6 +167,23 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init()); emit(FavState.init());
} }
Future<void> merge() async {
if (_authBloc.state.isLoggedIn) {
emit(state.copyWith(mergeStatus: Status.inProgress));
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
of: _authBloc.state.username,
);
final List<int> combinedIds = <int>[...ids, ...state.favIds];
final LinkedHashSet<int> mergedIds = LinkedHashSet<int>.from(combinedIds);
await _preferenceRepository.overwriteFav(
username: username,
ids: mergedIds,
);
emit(state.copyWith(mergeStatus: Status.success));
refresh();
}
}
void _onItemLoaded(Item item) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(
@ -174,4 +191,14 @@ class FavCubit extends Cubit<FavState> {
), ),
); );
} }
@override
Future<void> close() {
_usernameSubscription?.cancel();
return super.close();
}
}
extension on FavCubit {
String get username => _authBloc.state.username;
} }

View File

@ -1,41 +1,39 @@
part of 'fav_cubit.dart'; part of 'fav_cubit.dart';
enum FavStatus {
init,
loading,
loaded,
failure,
}
class FavState extends Equatable { class FavState extends Equatable {
const FavState({ const FavState({
required this.favIds, required this.favIds,
required this.favItems, required this.favItems,
required this.status, required this.status,
required this.mergeStatus,
required this.currentPage, required this.currentPage,
}); });
FavState.init() FavState.init()
: favIds = <int>[], : favIds = <int>[],
favItems = <Item>[], favItems = <Item>[],
status = FavStatus.init, status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0; currentPage = 0;
final List<int> favIds; final List<int> favIds;
final List<Item> favItems; final List<Item> favItems;
final FavStatus status; final Status status;
final Status mergeStatus;
final int currentPage; final int currentPage;
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
List<Item>? favItems, List<Item>? favItems,
FavStatus? status, Status? status,
Status? mergeStatus,
int? currentPage, int? currentPage,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
favItems: favItems ?? this.favItems, favItems: favItems ?? this.favItems,
status: status ?? this.status, status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -43,6 +41,7 @@ class FavState extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
mergeStatus,
currentPage, currentPage,
favIds, favIds,
favItems, favItems,

View File

@ -10,16 +10,16 @@ part 'history_state.dart';
class HistoryCubit extends Cubit<HistoryState> { class HistoryCubit extends Cubit<HistoryState> {
HistoryCubit({ HistoryCubit({
required AuthBloc authBloc, required AuthBloc authBloc,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(HistoryState.init()) { super(HistoryState.init()) {
init(); init();
} }
final AuthBloc _authBloc; final AuthBloc _authBloc;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
static const int _pageSize = 20; static const int _pageSize = 20;
void init() { void init() {
@ -27,7 +27,7 @@ class HistoryCubit extends Cubit<HistoryState> {
if (authState.isLoggedIn) { if (authState.isLoggedIn) {
final String username = authState.username; final String username = authState.username;
_storiesRepository _hackerNewsRepository
.fetchSubmitted(userId: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedIds) { .then((List<int>? submittedIds) {
emit( emit(
@ -38,7 +38,7 @@ class HistoryCubit extends Cubit<HistoryState> {
), ),
); );
if (submittedIds != null) { if (submittedIds != null) {
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: submittedIds.sublist( ids: submittedIds.sublist(
0, 0,
@ -54,7 +54,7 @@ class HistoryCubit extends Cubit<HistoryState> {
} }
void loadMore() { void loadMore() {
emit(state.copyWith(status: HistoryStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage; final int currentPage = state.currentPage;
final int len = state.submittedIds.length; final int len = state.submittedIds.length;
emit(state.copyWith(currentPage: currentPage + 1)); emit(state.copyWith(currentPage: currentPage + 1));
@ -66,7 +66,7 @@ class HistoryCubit extends Cubit<HistoryState> {
upper = len; upper = len;
} }
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: state.submittedIds.sublist( ids: state.submittedIds.sublist(
lower, lower,
@ -75,10 +75,10 @@ class HistoryCubit extends Cubit<HistoryState> {
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: HistoryStatus.loaded)); emit(state.copyWith(status: Status.success));
}); });
} else { } else {
emit(state.copyWith(status: HistoryStatus.loaded)); emit(state.copyWith(status: Status.success));
} }
} }
@ -86,19 +86,19 @@ class HistoryCubit extends Cubit<HistoryState> {
final String username = _authBloc.state.username; final String username = _authBloc.state.username;
emit( emit(
state.copyWith( state.copyWith(
status: HistoryStatus.loading, status: Status.inProgress,
currentPage: 0, currentPage: 0,
submittedIds: <int>[], submittedIds: <int>[],
submittedItems: <Item>[], submittedItems: <Item>[],
), ),
); );
_storiesRepository _hackerNewsRepository
.fetchSubmitted(userId: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedIds) { .then((List<int>? submittedIds) {
emit(state.copyWith(submittedIds: submittedIds)); emit(state.copyWith(submittedIds: submittedIds));
if (submittedIds != null) { if (submittedIds != null) {
_storiesRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: submittedIds.sublist( ids: submittedIds.sublist(
0, 0,
@ -107,7 +107,7 @@ class HistoryCubit extends Cubit<HistoryState> {
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
emit(state.copyWith(status: HistoryStatus.loaded)); emit(state.copyWith(status: Status.success));
}); });
} }
}); });

View File

@ -1,12 +1,5 @@
part of 'history_cubit.dart'; part of 'history_cubit.dart';
enum HistoryStatus {
init,
loading,
loaded,
failure,
}
class HistoryState extends Equatable { class HistoryState extends Equatable {
const HistoryState({ const HistoryState({
required this.submittedIds, required this.submittedIds,
@ -18,18 +11,18 @@ class HistoryState extends Equatable {
HistoryState.init() HistoryState.init()
: submittedIds = <int>[], : submittedIds = <int>[],
submittedItems = <Item>[], submittedItems = <Item>[],
status = HistoryStatus.init, status = Status.idle,
currentPage = 0; currentPage = 0;
final List<int> submittedIds; final List<int> submittedIds;
final List<Item> submittedItems; final List<Item> submittedItems;
final HistoryStatus status; final Status status;
final int currentPage; final int currentPage;
HistoryState copyWith({ HistoryState copyWith({
List<int>? submittedIds, List<int>? submittedIds,
List<Item>? submittedItems, List<Item>? submittedItems,
HistoryStatus? status, Status? status,
int? currentPage, int? currentPage,
}) { }) {
return HistoryState( return HistoryState(

View File

@ -16,23 +16,26 @@ class NotificationCubit extends Cubit<NotificationState> {
NotificationCubit({ NotificationCubit({
required AuthBloc authBloc, required AuthBloc authBloc,
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit, _preferenceCubit = preferenceCubit,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
super(NotificationState.init()) { super(NotificationState.init()) {
_authBloc.stream.listen((AuthState authState) { _authBloc.stream
if (authState.isLoggedIn && authState.username != _username) { .map((AuthState event) => event.username)
.distinct()
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting. // Get the user setting.
if (_preferenceCubit.state.notificationEnabled) { if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(Durations.twoSeconds, init); Future<void>.delayed(AppDurations.twoSeconds, init);
} }
// Listen for setting changes in the future. // Listen for setting changes in the future.
@ -44,9 +47,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_timer?.cancel(); _timer?.cancel();
} }
}); });
} else {
_username = authState.username;
} else if (!authState.isLoggedIn) {
emit(NotificationState.init()); emit(NotificationState.init());
} }
}); });
@ -54,10 +55,9 @@ class NotificationCubit extends Cubit<NotificationState> {
final AuthBloc _authBloc; final AuthBloc _authBloc;
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
String? _username;
Timer? _timer; Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5); static const Duration _refreshInterval = Duration(minutes: 5);
@ -82,7 +82,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) { for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id); Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id); comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) { if (comment != null) {
emit( emit(
state.copyWith( state.copyWith(
@ -100,7 +100,7 @@ class NotificationCubit extends Cubit<NotificationState> {
void markAsRead(int id) { void markAsRead(int id) {
Future.doWhile(() { Future.doWhile(() {
if (state.status != NotificationStatus.loading) { if (state.status != Status.inProgress) {
if (state.unreadCommentsIds.contains(id)) { if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds] final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id); ..remove(id);
@ -116,7 +116,7 @@ class NotificationCubit extends Cubit<NotificationState> {
void markAllAsRead() { void markAllAsRead() {
Future.doWhile(() { Future.doWhile(() {
if (state.status != NotificationStatus.loading) { if (state.status != Status.inProgress) {
emit(state.copyWith(unreadCommentsIds: <int>[])); emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]); _preferenceRepository.updateUnreadCommentsIds(<int>[]);
return false; return false;
@ -131,7 +131,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_preferenceCubit.state.notificationEnabled) { _preferenceCubit.state.notificationEnabled) {
emit( emit(
state.copyWith( state.copyWith(
status: NotificationStatus.loading, status: Status.inProgress,
), ),
); );
@ -141,14 +141,14 @@ class NotificationCubit extends Cubit<NotificationState> {
} else { } else {
emit( emit(
state.copyWith( state.copyWith(
status: NotificationStatus.loaded, status: Status.success,
), ),
); );
} }
} }
Future<void> loadMore() async { Future<void> loadMore() async {
emit(state.copyWith(status: NotificationStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage + 1; final int currentPage = state.currentPage + 1;
final int lower = currentPage * _pageSize + state.offset; final int lower = currentPage * _pageSize + state.offset;
@ -160,7 +160,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) { for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id); Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id); comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) { if (comment != null) {
emit(state.copyWith(comments: <Comment>[...state.comments, comment])); emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
} }
@ -169,7 +169,7 @@ class NotificationCubit extends Cubit<NotificationState> {
emit( emit(
state.copyWith( state.copyWith(
status: NotificationStatus.loaded, status: Status.success,
currentPage: currentPage, currentPage: currentPage,
), ),
); );
@ -184,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
} }
Future<void> _fetchReplies() { Future<void> _fetchReplies() {
return _storiesRepository return _hackerNewsRepository
.fetchSubmitted(userId: _authBloc.state.username) .fetchSubmitted(userId: _authBloc.state.username)
.then((List<int>? submittedItems) async { .then((List<int>? submittedItems) async {
if (submittedItems != null) { if (submittedItems != null) {
@ -194,7 +194,9 @@ class NotificationCubit extends Cubit<NotificationState> {
); );
for (final int id in subscribedItems) { for (final int id in subscribedItems) {
await _storiesRepository.fetchItem(id: id).then((Item? item) async { await _hackerNewsRepository
.fetchItem(id: id)
.then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[]; final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids = final List<int> previousKids =
(await _sembastRepository.kids(of: id)) ?? <int>[]; (await _sembastRepository.kids(of: id)) ?? <int>[];
@ -216,7 +218,7 @@ class NotificationCubit extends Cubit<NotificationState> {
...state.unreadCommentsIds, ...state.unreadCommentsIds,
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)), ]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
); );
await _storiesRepository await _hackerNewsRepository
.fetchComment(id: newCommentId) .fetchComment(id: newCommentId)
.then((Comment? comment) { .then((Comment? comment) {
if (comment != null && !comment.dead && !comment.deleted) { if (comment != null && !comment.dead && !comment.deleted) {
@ -237,7 +239,7 @@ class NotificationCubit extends Cubit<NotificationState> {
} }
}).whenComplete( }).whenComplete(
() => emit( () => emit(
state.copyWith(status: NotificationStatus.loaded), state.copyWith(status: Status.success),
), ),
); );
} }

View File

@ -1,12 +1,5 @@
part of 'notification_cubit.dart'; part of 'notification_cubit.dart';
enum NotificationStatus {
initial,
loading,
loaded,
failure,
}
class NotificationState extends Equatable { class NotificationState extends Equatable {
const NotificationState({ const NotificationState({
required this.comments, required this.comments,
@ -23,14 +16,14 @@ class NotificationState extends Equatable {
allCommentsIds = <int>[], allCommentsIds = <int>[],
currentPage = 0, currentPage = 0,
offset = 0, offset = 0,
status = NotificationStatus.initial; status = Status.idle;
final List<Comment> comments; final List<Comment> comments;
final List<int> allCommentsIds; final List<int> allCommentsIds;
final List<int> unreadCommentsIds; final List<int> unreadCommentsIds;
final int currentPage; final int currentPage;
final int offset; final int offset;
final NotificationStatus status; final Status status;
NotificationState copyWith({ NotificationState copyWith({
List<Comment>? comments, List<Comment>? comments,
@ -38,7 +31,7 @@ class NotificationState extends Equatable {
List<int>? unreadCommentsIds, List<int>? unreadCommentsIds,
int? currentPage, int? currentPage,
int? offset, int? offset,
NotificationStatus? status, Status? status,
}) { }) {
return NotificationState( return NotificationState(
comments: comments ?? this.comments, comments: comments ?? this.comments,

View File

@ -1,5 +1,6 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.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';
@ -9,28 +10,33 @@ part 'pin_state.dart';
class PinCubit extends Cubit<PinState> { class PinCubit extends Cubit<PinState> {
PinCubit({ PinCubit({
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _preferenceRepository = }) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PinState.init()) { super(PinState.init()) {
init(); init();
} }
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
void init() { void init() {
emit(PinState.init()); emit(PinState.init());
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) { _preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
emit(state.copyWith(pinnedStoriesIds: ids)); emit(state.copyWith(pinnedStoriesIds: ids));
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched); _hackerNewsRepository
}).whenComplete(() => emit(state.copyWith(status: Status.loaded))); .fetchStoriesStream(ids: ids)
.listen(_onStoryFetched);
}).whenComplete(() => emit(state.copyWith(status: Status.success)));
} }
void pinStory(Story story) { void pinStory(
Story story, {
VoidCallback? onDone,
}) {
if (!state.pinnedStoriesIds.contains(story.id)) { if (!state.pinnedStoriesIds.contains(story.id)) {
emit( emit(
state.copyWith( state.copyWith(
@ -39,10 +45,14 @@ class PinCubit extends Cubit<PinState> {
), ),
); );
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds); _preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
} }
} }
void unpinStory(Story story) { void unpinStory(
Story story, {
VoidCallback? onDone,
}) {
emit( emit(
state.copyWith( state.copyWith(
pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id), pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id),
@ -50,10 +60,11 @@ class PinCubit extends Cubit<PinState> {
), ),
); );
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds); _preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
} }
void refresh() { void refresh() {
if (state.status == Status.loading) return; if (state.status.isLoading) return;
init(); init();
} }

View File

@ -11,13 +11,13 @@ part 'poll_state.dart';
class PollCubit extends Cubit<PollState> { class PollCubit extends Cubit<PollState> {
PollCubit({ PollCubit({
required Story story, required Story story,
StoriesRepository? storiesRepository, HackerNewsRepository? hackerNewsRepository,
}) : _story = story, }) : _story = story,
_storiesRepository = _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PollState.init()); super(PollState.init());
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
final Story _story; final Story _story;
Future<void> init({ Future<void> init({
@ -27,13 +27,13 @@ class PollCubit extends Cubit<PollState> {
emit(PollState.init()); emit(PollState.init());
} }
emit(state.copyWith(status: PollStatus.loading)); emit(state.copyWith(status: Status.inProgress));
List<int> pollOptionsIds = _story.parts; List<int> pollOptionsIds = _story.parts;
if (pollOptionsIds.isEmpty || refresh) { if (pollOptionsIds.isEmpty || refresh) {
final Story? updatedStory = final Story? updatedStory =
await _storiesRepository.fetchStory(id: _story.id); await _hackerNewsRepository.fetchStory(id: _story.id);
if (updatedStory != null) { if (updatedStory != null) {
pollOptionsIds = updatedStory.parts; pollOptionsIds = updatedStory.parts;
@ -42,12 +42,12 @@ class PollCubit extends Cubit<PollState> {
// If pollOptionsIds is still empty, exit loading state. // If pollOptionsIds is still empty, exit loading state.
if (pollOptionsIds.isEmpty) { if (pollOptionsIds.isEmpty) {
emit(state.copyWith(status: PollStatus.loaded)); emit(state.copyWith(status: Status.success));
return; return;
} }
if (pollOptionsIds.isNotEmpty) { if (pollOptionsIds.isNotEmpty) {
final List<PollOption> pollOptions = (await _storiesRepository final List<PollOption> pollOptions = (await _hackerNewsRepository
.fetchPollOptionsStream(ids: pollOptionsIds) .fetchPollOptionsStream(ids: pollOptionsIds)
.toSet()) .toSet())
.toList(); .toList();
@ -72,7 +72,7 @@ class PollCubit extends Cubit<PollState> {
); );
} }
emit(state.copyWith(status: PollStatus.loaded)); emit(state.copyWith(status: Status.success));
} }
} }

View File

@ -1,12 +1,5 @@
part of 'poll_cubit.dart'; part of 'poll_cubit.dart';
enum PollStatus {
initial,
loading,
loaded,
failure,
}
class PollState extends Equatable { class PollState extends Equatable {
const PollState({ const PollState({
required this.totalVotes, required this.totalVotes,
@ -19,18 +12,18 @@ class PollState extends Equatable {
: totalVotes = 0, : totalVotes = 0,
selections = <int>{}, selections = <int>{},
pollOptions = <PollOption>[], pollOptions = <PollOption>[],
status = PollStatus.initial; status = Status.idle;
final int totalVotes; final int totalVotes;
final Set<int> selections; final Set<int> selections;
final List<PollOption> pollOptions; final List<PollOption> pollOptions;
final PollStatus status; final Status status;
PollState copyWith({ PollState copyWith({
int? totalVotes, int? totalVotes,
Set<int>? selections, Set<int>? selections,
List<PollOption>? pollOptions, List<PollOption>? pollOptions,
PollStatus? status, Status? status,
}) { }) {
return PollState( return PollState(
totalVotes: totalVotes ?? this.totalVotes, totalVotes: totalVotes ?? this.totalVotes,

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
part 'post_state.dart'; part 'post_state.dart';
@ -14,31 +15,41 @@ class PostCubit extends Cubit<PostState> {
final PostRepository _postRepository; final PostRepository _postRepository;
Future<void> post({required String text, required int to}) async { Future<void> post({required String text, required int to}) async {
emit(state.copyWith(status: PostStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.comment( final bool successful = await _postRepository.comment(
parentId: to, parentId: to,
text: text, text: text,
); );
if (successful) { if (successful) {
emit(state.copyWith(status: PostStatus.successful)); emit(state.copyWith(status: Status.success));
} else { } else {
emit(state.copyWith(status: PostStatus.failure)); emit(state.copyWith(status: Status.failure));
} }
} }
Future<void> edit({required String text, required int id}) async { Future<void> edit({required String text, required int id}) async {
emit(state.copyWith(status: PostStatus.loading)); emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.edit(id: id, text: text); final bool successful = await _postRepository.edit(id: id, text: text);
if (successful) { if (successful) {
emit(state.copyWith(status: PostStatus.successful)); emit(state.copyWith(status: Status.success));
} else { } else {
emit(state.copyWith(status: PostStatus.failure)); emit(state.copyWith(status: Status.failure));
} }
} }
void reset() { void reset() {
emit(state.copyWith(status: PostStatus.init)); emit(state.copyWith(status: Status.idle));
}
@Deprecated('For debugging only')
Future<bool> getFakeResult() async {
final bool result = await Future<bool>.delayed(
const Duration(seconds: 2),
() => true,
);
return result;
} }
} }

View File

@ -1,20 +1,13 @@
part of 'post_cubit.dart'; part of 'post_cubit.dart';
enum PostStatus {
init,
loading,
successful,
failure,
}
class PostState extends Equatable { class PostState extends Equatable {
const PostState({required this.status}); const PostState({required this.status});
const PostState.init() : status = PostStatus.init; const PostState.init() : status = Status.idle;
final PostStatus status; final Status status;
PostState copyWith({PostStatus? status}) { PostState copyWith({Status? status}) {
return PostState( return PostState(
status: status ?? this.status, status: status ?? this.status,
); );

View File

@ -1,5 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_color_picker/flutter_material_color_picker.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
@ -41,6 +43,16 @@ class PreferenceCubit extends Cubit<PreferenceState> {
return null; return null;
}); });
} }
for (final DoublePreference p
in Preference.allPreferences.whereType<DoublePreference>()) {
initPreference<double>(p).then<double?>((double? value) {
final Preference<dynamic> updatedPreference = p.copyWith(val: value);
emit(state.copyWithPreference(updatedPreference));
return null;
});
}
} }
Future<T?> initPreference<T>(Preference<T> preference) async { Future<T?> initPreference<T>(Preference<T> preference) async {
@ -48,6 +60,10 @@ class PreferenceCubit extends Cubit<PreferenceState> {
case int: case int:
final int? value = await _preferenceRepository.getInt(preference.key); final int? value = await _preferenceRepository.getInt(preference.key);
return value as T?; return value as T?;
case double:
final double? value =
await _preferenceRepository.getDouble(preference.key);
return value as T?;
case bool: case bool:
final bool? value = await _preferenceRepository.getBool(preference.key); final bool? value = await _preferenceRepository.getBool(preference.key);
return value as T?; return value as T?;
@ -56,19 +72,27 @@ class PreferenceCubit extends Cubit<PreferenceState> {
} }
} }
void update<T>(Preference<T> preference, {required T to}) { void update<T>(Preference<T> preference) {
final T value = to; _logger.i('updating $preference to ${preference.val}');
final Preference<T> updatedPreference = preference.copyWith(val: value);
_logger.i('updating $preference to $value'); emit(state.copyWithPreference(preference));
emit(state.copyWithPreference(updatedPreference));
switch (T) { switch (T) {
case int: case int:
_preferenceRepository.setInt(preference.key, value as int); _preferenceRepository.setInt(
preference.key,
preference.val as int,
);
case double:
_preferenceRepository.setDouble(
preference.key,
preference.val as double,
);
case bool: case bool:
_preferenceRepository.setBool(preference.key, value as bool); _preferenceRepository.setBool(
preference.key,
preference.val as bool,
);
default: default:
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -54,8 +54,6 @@ class PreferenceState extends Equatable {
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>(); bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
bool get readerEnabled => _isOn<ReaderModePreference>(); bool get readerEnabled => _isOn<ReaderModePreference>();
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>(); bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
@ -70,6 +68,21 @@ class PreferenceState extends Equatable {
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>(); bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;
MaterialColor get appColor {
return materialColors.elementAt(
preferences.singleWhereType<AppColorPreference>().val,
) as MaterialColor;
}
List<StoryType> get tabs { List<StoryType> get tabs {
final String result = final String result =
preferences.singleWhereType<TabOrderPreference>().val.toString(); preferences.singleWhereType<TabOrderPreference>().val.toString();
@ -87,6 +100,9 @@ class PreferenceState extends Equatable {
return tabs; return tabs;
} }
StoryMarkingMode get storyMarkingMode => StoryMarkingMode.values
.elementAt(preferences.singleWhereType<StoryMarkingModePreference>().val);
FetchMode get fetchMode => FetchMode.values FetchMode get fetchMode => FetchMode.values
.elementAt(preferences.singleWhereType<FetchModePreference>().val); .elementAt(preferences.singleWhereType<FetchModePreference>().val);

View File

@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); search(state.params.query);
} }
void onExactMatchToggled() {
emit(
state.copyWith(
params: state.params.copyWith(
exactMatch: !state.params.exactMatch,
),
),
);
search(state.params.query);
}
void onDateTimeRangeUpdated(DateTime start, DateTime end) { void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith( final DateTime updatedStart = start.copyWith(
second: 0, second: 0,

View File

@ -1,7 +1,7 @@
part of 'search_cubit.dart'; part of 'search_cubit.dart';
enum SearchStatus { enum SearchStatus {
initial, idle,
loading, loading,
loadingMore, loadingMore,
loaded, loaded,
@ -15,7 +15,7 @@ class SearchState extends Equatable {
}); });
SearchState.init() SearchState.init()
: status = SearchStatus.initial, : status = SearchStatus.idle,
results = <Item>[], results = <Item>[],
params = SearchParams.init(); params = SearchParams.init();

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/post_repository.dart'; import 'package:hacki/repositories/post_repository.dart';
part 'submit_state.dart'; part 'submit_state.dart';
@ -25,7 +26,7 @@ class SubmitCubit extends Cubit<SubmitState> {
} }
void onSubmitTapped() { void onSubmitTapped() {
emit(state.copyWith(status: SubmitStatus.submitting)); emit(state.copyWith(status: Status.inProgress));
if (state.title?.isNotEmpty ?? false) { if (state.title?.isNotEmpty ?? false) {
_postRepository _postRepository
@ -35,9 +36,9 @@ class SubmitCubit extends Cubit<SubmitState> {
text: state.text, text: state.text,
) )
.then((bool successful) { .then((bool successful) {
emit(state.copyWith(status: SubmitStatus.submitted)); emit(state.copyWith(status: Status.success));
}).onError((Object? error, StackTrace stackTrace) { }).onError((Object? error, StackTrace stackTrace) {
emit(state.copyWith(status: SubmitStatus.failure)); emit(state.copyWith(status: Status.failure));
}); });
} }
} }

View File

@ -1,12 +1,5 @@
part of 'submit_cubit.dart'; part of 'submit_cubit.dart';
enum SubmitStatus {
initial,
submitting,
submitted,
failure,
}
class SubmitState extends Equatable { class SubmitState extends Equatable {
const SubmitState({ const SubmitState({
required this.title, required this.title,
@ -19,18 +12,18 @@ class SubmitState extends Equatable {
: title = null, : title = null,
url = null, url = null,
text = null, text = null,
status = SubmitStatus.initial; status = Status.idle;
final String? title; final String? title;
final String? url; final String? url;
final String? text; final String? text;
final SubmitStatus status; final Status status;
SubmitState copyWith({ SubmitState copyWith({
String? title, String? title,
String? url, String? url,
String? text, String? text,
SubmitStatus? status, Status? status,
}) { }) {
return SubmitState( return SubmitState(
title: title ?? this.title, title: title ?? this.title,

View File

@ -38,8 +38,7 @@ class TabCubit extends Cubit<TabState> {
// Check to make sure there's no duplicate. // Check to make sure there's no duplicate.
if (updatedTabs.toSet().length == StoryType.values.length) { if (updatedTabs.toSet().length == StoryType.values.length) {
_preferenceCubit.update<int>( _preferenceCubit.update<int>(
TabOrderPreference(), TabOrderPreference(val: StoryType.convertToSettingsValue(updatedTabs)),
to: StoryType.convertToSettingsValue(updatedTabs),
); );
} }
} }

View File

@ -7,24 +7,24 @@ import 'package:hacki/repositories/repositories.dart';
part 'user_state.dart'; part 'user_state.dart';
class UserCubit extends Cubit<UserState> { class UserCubit extends Cubit<UserState> {
UserCubit({StoriesRepository? storiesRepository}) UserCubit({HackerNewsRepository? hackerNewsRepository})
: _storiesRepository = : _hackerNewsRepository =
storiesRepository ?? locator.get<StoriesRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(const UserState.init()); super(const UserState.init());
final StoriesRepository _storiesRepository; final HackerNewsRepository _hackerNewsRepository;
void init({required String userId}) { void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading)); emit(state.copyWith(status: Status.inProgress));
_storiesRepository.fetchUser(id: userId).then((User? user) { _hackerNewsRepository.fetchUser(id: userId).then((User? user) {
emit( emit(
state.copyWith( state.copyWith(
user: user ?? User.emptyWithId(userId), user: user ?? User.emptyWithId(userId),
status: UserStatus.loaded, status: Status.success,
), ),
); );
}).onError((_, __) { }).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure)); emit(state.copyWith(status: Status.failure));
return; return;
}); });
} }

View File

@ -1,12 +1,5 @@
part of 'user_cubit.dart'; part of 'user_cubit.dart';
enum UserStatus {
initial,
loading,
loaded,
failure,
}
class UserState extends Equatable { class UserState extends Equatable {
const UserState({ const UserState({
required this.user, required this.user,
@ -15,14 +8,14 @@ class UserState extends Equatable {
const UserState.init() const UserState.init()
: user = const User.empty(), : user = const User.empty(),
status = UserStatus.initial; status = Status.idle;
final User user; final User user;
final UserStatus status; final Status status;
UserState copyWith({ UserState copyWith({
User? user, User? user,
UserStatus? status, Status? status,
}) { }) {
return UserState( return UserState(
user: user ?? this.user, user: user ?? this.user,

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