Compare commits
92 Commits
Author | SHA1 | Date | |
---|---|---|---|
90ec117b4b | |||
9580e9b3e5 | |||
0afaa5a0aa | |||
fbce1dff73 | |||
b0d6561486 | |||
11639118c5 | |||
e54c893e6c | |||
8c57e5e323 | |||
b4ec7ec44e | |||
8b65256294 | |||
58f7bf14d7 | |||
d9aad3d34e | |||
ed48d95375 | |||
1eaded5694 | |||
70bb78afcb | |||
df2d2478d5 | |||
d5ae60327d | |||
615a092c1e | |||
5a7699d866 | |||
56a9bab3f2 | |||
e9bbf46b4f | |||
10f503a6c0 | |||
582f3156b2 | |||
90fb45146f | |||
c19c54e762 | |||
70e5a84b63 | |||
3a51fa83f2 | |||
cb90751330 | |||
835ed7e841 | |||
125ccd2dd1 | |||
5b991c4287 | |||
7dc3618afe | |||
eef4691814 | |||
9f71701845 | |||
d27203b041 | |||
4f280ec4c9 | |||
72cb2737ca | |||
215203bd16 | |||
3e320faece | |||
1049568246 | |||
71aa42118d | |||
4f21d3e6bd | |||
96d0fe9e5e | |||
69eee3e278 | |||
36bcd996c0 | |||
5fc39d8b8b | |||
5dce7787e1 | |||
8888dde792 | |||
6c8fc4cf87 | |||
ae9cc109db | |||
c8976ed17b | |||
ff7e115418 | |||
0310507c96 | |||
58c646e232 | |||
08328e2ca1 | |||
86b7228ffd | |||
e103c88ca6 | |||
94323a04e0 | |||
4776c375a1 | |||
1f4e6cf41c | |||
be6ed35888 | |||
b2ea50cea6 | |||
109b9287cf | |||
939d55ef0d | |||
3ee60e1a44 | |||
6fe567fa02 | |||
bc2d4f32c9 | |||
91290e9743 | |||
934f184b6f | |||
dbd48eae99 | |||
279007191b | |||
b3fdc20fc5 | |||
3fbf5d4eea | |||
332ffbb773 | |||
346a6c709e | |||
d4fe042245 | |||
b82c4a1777 | |||
7e0d1f0f1d | |||
f405a10c2e | |||
edbad79cd3 | |||
c9d8b2950a | |||
f2bc48f980 | |||
d56697c57c | |||
320ec41aae | |||
d85b3535d5 | |||
f8cd1cbba0 | |||
817ec208d6 | |||
554a165789 | |||
0c680370ef | |||
59541d2fcc | |||
32083c3564 | |||
258dbc4b8b |
7
.github/workflows/publish_ios.yml
vendored
@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build_and_publish:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-13
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
@ -19,6 +19,11 @@ jobs:
|
||||
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
|
||||
|
||||
steps:
|
||||
- name: Set XCode version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.0'
|
||||
|
||||
- name: Check out from git
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
|
32
README.md
@ -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
|
||||
|
||||
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.
|
||||
|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||
@ -35,22 +35,20 @@ Features:
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img width="200" alt="01" src="assets/screenshots/01.png">
|
||||
<img width="200" alt="02" src="assets/screenshots/02.png">
|
||||
<img width="200" alt="03" src="assets/screenshots/03.png">
|
||||
<img width="200" alt="04" src="assets/screenshots/04.png">
|
||||
<img width="200" alt="05" src="assets/screenshots/05.png">
|
||||
<img width="200" alt="06" src="assets/screenshots/06.png">
|
||||
<img width="200" alt="07" src="assets/screenshots/07.png">
|
||||
<img width="200" alt="08" src="assets/screenshots/08.png">
|
||||
<img width="200" alt="09" src="assets/screenshots/09.png">
|
||||
<img width="200" alt="10" src="assets/screenshots/10.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="01" src="assets/screenshots/light-1.png">
|
||||
<img width="400" alt="06" src="assets/screenshots/dark-1.png">
|
||||
<img width="400" alt="02" src="assets/screenshots/light-2.png">
|
||||
<img width="400" alt="07" src="assets/screenshots/dark-2.png">
|
||||
<img width="400" alt="03" src="assets/screenshots/light-3.png">
|
||||
<img width="400" alt="08" src="assets/screenshots/dark-3.png">
|
||||
<img width="400" alt="04" src="assets/screenshots/light-4.png">
|
||||
<img width="400" alt="09" src="assets/screenshots/dark-4.png">
|
||||
<img width="400" alt="05" src="assets/screenshots/light-5.png">
|
||||
<img width="400" alt="10" src="assets/screenshots/dark-5.png">
|
||||
|
||||
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png">
|
||||
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png">
|
||||
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png">
|
||||
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png">
|
||||
<img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
|
||||
<img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
|
||||
<img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
|
||||
<img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
|
||||
</p>
|
||||
|
||||
|
@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
compileSdkVersion 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
@ -50,8 +50,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.jiaqifeng.hacki"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 33
|
||||
minSdkVersion 25
|
||||
targetSdkVersion 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
@ -13,6 +13,9 @@
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@ -20,7 +23,8 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
|
BIN
assets/fonts/exo_2/Exo2-Bold.ttf
Normal file
BIN
assets/fonts/exo_2/Exo2-Regular.ttf
Normal file
BIN
assets/hacki-github.png
Normal file
After Width: | Height: | Size: 419 KiB |
BIN
assets/hacki.xcf
Before Width: | Height: | Size: 548 KiB After Width: | Height: | Size: 333 KiB |
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 341 KiB |
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 359 KiB |
BIN
assets/screenshots/dark-1.png
Normal file
After Width: | Height: | Size: 1003 KiB |
BIN
assets/screenshots/dark-2.png
Normal file
After Width: | Height: | Size: 912 KiB |
BIN
assets/screenshots/dark-3.png
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
assets/screenshots/dark-4.png
Normal file
After Width: | Height: | Size: 734 KiB |
BIN
assets/screenshots/dark-5.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/screenshots/light-1.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/screenshots/light-2.png
Normal file
After Width: | Height: | Size: 893 KiB |
BIN
assets/screenshots/light-3.png
Normal file
After Width: | Height: | Size: 460 KiB |
BIN
assets/screenshots/light-4.png
Normal file
After Width: | Height: | Size: 712 KiB |
BIN
assets/screenshots/light-5.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/screenshots/tablet-dark-1.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/screenshots/tablet-dark-2.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/screenshots/tablet-light-1.png
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
assets/screenshots/tablet-light-2.png
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/tablet-hacki.xcf
Normal file
@ -76,6 +76,15 @@ final class SharedPrefsCore {
|
||||
|
||||
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 {
|
||||
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
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":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let val = params[valKey] as? Bool
|
||||
|
@ -15,6 +15,14 @@ class SyncedSharedPreferences {
|
||||
const MethodChannel(channel),
|
||||
);
|
||||
|
||||
Future<bool?> remove({
|
||||
required String key,
|
||||
}) async {
|
||||
return _channel.invokeMethod('remove', <String, dynamic>{
|
||||
'key': key,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool?> setBool({
|
||||
required String key,
|
||||
required bool val,
|
||||
|
2
fastlane/metadata/android/en-US/changelogs/125.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Ability to customize text scale factor.
|
||||
- Ability to customize app's accent color.
|
4
fastlane/metadata/android/en-US/changelogs/127.txt
Normal 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.
|
5
fastlane/metadata/android/en-US/changelogs/128.txt
Normal 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.
|
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal 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.
|
4
fastlane/metadata/android/en-US/changelogs/131.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- New comment indicator.
|
||||
- Ability to mark stories as read from home page.
|
||||
- Text rendering improvements.
|
||||
- Performance improvements.
|
4
fastlane/metadata/android/en-US/changelogs/132.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- New comment indicator.
|
||||
- Ability to mark stories as read from home page.
|
||||
- Text rendering improvements.
|
||||
- Performance improvements.
|
4
fastlane/metadata/android/en-US/changelogs/134.txt
Normal 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.
|
3
fastlane/metadata/android/en-US/changelogs/135.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Return of true dark mode.
|
||||
- Better comment fetching strategy.
|
||||
- Minor UI fixes.
|
Before Width: | Height: | Size: 522 KiB |
Before Width: | Height: | Size: 835 KiB |
Before Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 298 KiB |
Before Width: | Height: | Size: 820 KiB |
Before Width: | Height: | Size: 868 KiB |
Before Width: | Height: | Size: 121 KiB |
Before Width: | Height: | Size: 375 KiB |
Before Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 414 KiB |
Before Width: | Height: | Size: 530 KiB |
Before Width: | Height: | Size: 406 KiB |
After Width: | Height: | Size: 1003 KiB |
After Width: | Height: | Size: 912 KiB |
After Width: | Height: | Size: 252 KiB |
After Width: | Height: | Size: 734 KiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 1.3 MiB |
After Width: | Height: | Size: 893 KiB |
After Width: | Height: | Size: 460 KiB |
After Width: | Height: | Size: 712 KiB |
After Width: | Height: | Size: 1.0 MiB |
@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
111
ios/Gemfile.lock
@ -1,7 +1,7 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.5)
|
||||
CFPropertyList (3.0.6)
|
||||
rexml
|
||||
activesupport (6.1.7)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
@ -9,28 +9,28 @@ GEM
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
zeitwerk (~> 2.3)
|
||||
addressable (2.8.1)
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
algoliasearch (1.27.5)
|
||||
httpclient (~> 2.8, >= 2.8.3)
|
||||
json (>= 1.5.1)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.680.0)
|
||||
aws-sdk-core (3.168.4)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.889.0)
|
||||
aws-sdk-core (3.191.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.61.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.117.2)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
claide (1.1.0)
|
||||
@ -77,7 +77,7 @@ GEM
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.1.10)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.4)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
@ -86,8 +86,8 @@ GEM
|
||||
escape (0.0.4)
|
||||
ethon (0.15.0)
|
||||
ffi (>= 1.15.0)
|
||||
excon (0.95.0)
|
||||
faraday (1.10.2)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@ -115,8 +115,8 @@ GEM
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.211.0)
|
||||
fastimage (2.3.0)
|
||||
fastlane (2.219.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
@ -135,20 +135,22 @@ GEM
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (~> 2.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
optparse (>= 0.1.1)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
@ -159,9 +161,9 @@ GEM
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.32.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-core (0.9.2)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
@ -169,31 +171,29 @@ GEM
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.16.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.12.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.3.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
@ -204,49 +204,48 @@ GEM
|
||||
i18n (1.12.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
jwt (2.5.0)
|
||||
memoist (0.16.2)
|
||||
json (2.7.1)
|
||||
jwt (2.7.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.16.3)
|
||||
molinillo (0.8.0)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.0.0)
|
||||
multipart-post (2.4.0)
|
||||
nanaimo (0.3.0)
|
||||
nap (1.1.0)
|
||||
naturally (2.2.1)
|
||||
netrc (0.11.0)
|
||||
optparse (0.1.1)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.6.0)
|
||||
plist (3.7.1)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.0.6)
|
||||
rake (13.1.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rexml (3.2.6)
|
||||
rouge (2.0.7)
|
||||
ruby-macho (2.5.1)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.17.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.8)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
typhoeus (1.4.0)
|
||||
@ -256,11 +255,10 @@ GEM
|
||||
uber (0.1.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (1.8.0)
|
||||
webrick (1.7.0)
|
||||
unf_ext (0.0.9.1)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.22.0)
|
||||
xcodeproj (1.24.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
@ -275,6 +273,7 @@ GEM
|
||||
|
||||
PLATFORMS
|
||||
universal-darwin-21
|
||||
universal-darwin-23
|
||||
x86_64-darwin-19
|
||||
|
||||
DEPENDENCIES
|
||||
|
@ -34,5 +34,8 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = 15.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -7,11 +7,11 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_email_sender (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview (0.0.1):
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview/Core (= 0.0.1)
|
||||
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_inappwebview/Core (0.0.1):
|
||||
- flutter_inappwebview_ios/Core (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
@ -20,9 +20,6 @@ PODS:
|
||||
- Flutter
|
||||
- flutter_siri_suggestions (0.0.1):
|
||||
- Flutter
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- in_app_review (0.2.0):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
@ -38,7 +35,7 @@ PODS:
|
||||
- Flutter
|
||||
- MTBBarcodeScanner
|
||||
- ReachabilitySwift (5.0.0)
|
||||
- receive_sharing_intent (0.0.1):
|
||||
- receive_sharing_intent (1.5.3):
|
||||
- Flutter
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
@ -47,7 +44,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
- FlutterMacOS
|
||||
- synced_shared_preferences (0.0.1):
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
@ -64,7 +61,7 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
|
||||
@ -76,7 +73,7 @@ DEPENDENCIES:
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
@ -85,7 +82,6 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FMDB
|
||||
- MTBBarcodeScanner
|
||||
- OrderedSet
|
||||
- ReachabilitySwift
|
||||
@ -99,8 +95,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_email_sender:
|
||||
:path: ".symlinks/plugins/flutter_email_sender/ios"
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_inappwebview_ios:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage:
|
||||
@ -124,7 +120,7 @@ EXTERNAL SOURCES:
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
:path: ".symlinks/plugins/sqflite/darwin"
|
||||
synced_shared_preferences:
|
||||
:path: ".symlinks/plugins/synced_shared_preferences/ios"
|
||||
url_launcher_ios:
|
||||
@ -137,33 +133,32 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
|
||||
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
|
||||
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
||||
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
receive_sharing_intent: 753f808c6be5550247f6a20f2a14972466a5f33c
|
||||
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
||||
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
|
||||
PODFILE CHECKSUM: 0957b955069bb512c22bae4cadad9f4c34161dbe
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.13.0
|
||||
|
@ -230,13 +230,13 @@
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
|
||||
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
@ -291,7 +291,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1330;
|
||||
LastUpgradeCheck = 1430;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
@ -548,7 +548,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@ -636,7 +636,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@ -685,7 +685,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -77,10 +77,8 @@
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app needs camera access to scan QR codes</string>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
<key>FLTEnableWideGamut</key>
|
||||
<false/>
|
||||
<string>This app needs camera access to scan QR codes</string>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -11,13 +11,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc({
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(const AuthState.init()) {
|
||||
@ -31,7 +31,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
|
||||
Future<void> onInitialize(
|
||||
@ -41,7 +41,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
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,
|
||||
/// if user has no public activity (posting a comment or story),
|
||||
@ -89,7 +89,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User? user = await _storiesRepository.fetchUser(id: event.username);
|
||||
final User? user =
|
||||
await _hackerNewsRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(event.username),
|
||||
@ -113,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
await _authRepository.logout();
|
||||
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
await _sembastRepository.deleteAll();
|
||||
await _sembastRepository.deleteCachedComments();
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:bloc_concurrency/bloc_concurrency.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
@ -19,24 +20,32 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required FilterCubit filterCubit,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_filterCubit = filterCubit,
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(const StoriesState.init()) {
|
||||
on<LoadStories>(
|
||||
onLoadStories,
|
||||
transformer: concurrent(),
|
||||
);
|
||||
on<StoriesInitialize>(onInitialize);
|
||||
on<StoriesRefresh>(onRefresh);
|
||||
on<StoriesLoadMore>(onLoadMore);
|
||||
on<StoryLoaded>(onStoryLoaded);
|
||||
on<StoryLoaded>(
|
||||
onStoryLoaded,
|
||||
transformer: sequential(),
|
||||
);
|
||||
on<StoryRead>(onStoryRead);
|
||||
on<StoryUnread>(onStoryUnread);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoriesCancelDownload>(onCancelDownload);
|
||||
@ -49,7 +58,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final FilterCubit _filterCubit;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
DeviceScreenType? deviceScreenType;
|
||||
@ -87,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
),
|
||||
);
|
||||
for (final StoryType type in StoryType.values) {
|
||||
await loadStories(type: type, emit: emit);
|
||||
add(LoadStories(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadStories({
|
||||
required StoryType type,
|
||||
required Emitter<StoriesState> emit,
|
||||
}) async {
|
||||
Future<void> onLoadStories(
|
||||
LoadStories event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
final StoryType type = event.type;
|
||||
if (state.isOfflineReading) {
|
||||
final List<int> ids =
|
||||
await _offlineRepository.getCachedStoryIds(type: type);
|
||||
@ -113,19 +123,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
} else {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
final List<int> ids =
|
||||
await _hackerNewsRepository.fetchStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_storiesRepository
|
||||
await _hackerNewsRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
}).asFuture<void>();
|
||||
add(StoriesLoaded(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(type: event.type));
|
||||
await loadStories(type: event.type, emit: emit);
|
||||
add(LoadStories(type: event.type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +206,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
} else {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
@ -228,16 +238,18 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoryLoaded event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) =>
|
||||
event.story.title.toLowerCase().contains(keyword) ||
|
||||
event.story.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
final Story story = event.story;
|
||||
final bool hasRead = await _preferenceRepository.hasRead(story.id);
|
||||
final bool hidden = _filterCubit.state.keywords.any((String keyword) {
|
||||
// Match word only.
|
||||
final RegExp regExp = RegExp('\\b($keyword)\\b');
|
||||
return regExp.hasMatch(story.title.toLowerCase()) ||
|
||||
regExp.hasMatch(story.text.toLowerCase());
|
||||
});
|
||||
emit(
|
||||
state.copyWithStoryAdded(
|
||||
type: event.type,
|
||||
story: event.story.copyWith(hidden: hidden),
|
||||
story: story.copyWith(hidden: hidden),
|
||||
hasRead: hasRead,
|
||||
),
|
||||
);
|
||||
@ -271,7 +283,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
..remove(StoryType.latest);
|
||||
|
||||
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);
|
||||
prioritizedIds.addAll(ids);
|
||||
}
|
||||
@ -291,7 +304,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
|
||||
final Set<int> latestIds = <int>{};
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(
|
||||
final List<int> ids = await _hackerNewsRepository.fetchStoryIds(
|
||||
type: StoryType.latest,
|
||||
);
|
||||
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
|
||||
@ -345,7 +358,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
_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 (isPrioritized) {
|
||||
@ -375,7 +388,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
/// In other words, we are prioritizing the story itself instead of
|
||||
/// the comments in the story.
|
||||
late final StreamSubscription<Comment>? downloadStream;
|
||||
downloadStream = _storiesRepository
|
||||
downloadStream = _hackerNewsRepository
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.whereType<Comment>()
|
||||
.listen(
|
||||
@ -458,7 +471,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoryRead event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
unawaited(_preferenceRepository.updateHasRead(event.story.id));
|
||||
unawaited(_preferenceRepository.addHasRead(event.story.id));
|
||||
|
||||
emit(
|
||||
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(
|
||||
ClearAllReadStories event,
|
||||
Emitter<StoriesState> emit,
|
||||
|
@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
|
||||
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 {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
@ -95,6 +104,15 @@ class StoryRead extends StoriesEvent {
|
||||
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 {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
|
@ -20,6 +20,8 @@ abstract class Constants {
|
||||
'$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 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 _imagePath = 'assets/images';
|
||||
@ -71,7 +73,7 @@ abstract class RegExpConstants {
|
||||
static const String number = '[0-9]+';
|
||||
}
|
||||
|
||||
abstract class Durations {
|
||||
abstract class AppDurations {
|
||||
static const Duration ms100 = Duration(milliseconds: 100);
|
||||
static const Duration ms200 = Duration(milliseconds: 200);
|
||||
static const Duration ms300 = Duration(milliseconds: 300);
|
||||
@ -81,4 +83,7 @@ abstract class Durations {
|
||||
static const Duration oneSecond = Duration(seconds: 1);
|
||||
static const Duration twoSeconds = Duration(seconds: 2);
|
||||
static const Duration tenSeconds = Duration(seconds: 10);
|
||||
static const Duration sec30 = Duration(seconds: 30);
|
||||
static const Duration oneMinute = Duration(minutes: 1);
|
||||
static const Duration twoMinutes = Duration(minutes: 2);
|
||||
}
|
||||
|
@ -23,12 +23,13 @@ Future<void> setUpLocator() async {
|
||||
output: LogUtil.logOutput(logOutputFile),
|
||||
),
|
||||
)
|
||||
..registerSingleton<StoriesRepository>(StoriesRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
|
||||
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
..registerSingleton<AuthRepository>(AuthRepository())
|
||||
..registerSingleton<PostRepository>(PostRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<OfflineRepository>(OfflineRepository())
|
||||
..registerSingleton<DraftCache>(DraftCache())
|
||||
..registerSingleton<CommentCache>(CommentCache())
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
|
||||
@ -28,11 +29,12 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
collapsedCount: _collapseCache.totalHidden(_commentId),
|
||||
collapsed: _collapseCache.isCollapsed(_commentId),
|
||||
hidden: _collapseCache.isHidden(_commentId),
|
||||
locked: _collapseCache.lockedId == _commentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
void collapse({required VoidCallback onStateChanged}) {
|
||||
if (state.collapsed) {
|
||||
_collapseCache.uncollapse(_commentId);
|
||||
|
||||
@ -42,7 +44,14 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
collapsedCount: 0,
|
||||
),
|
||||
);
|
||||
|
||||
onStateChanged();
|
||||
} else {
|
||||
if (state.locked) {
|
||||
emit(state.copyWith(locked: false));
|
||||
return;
|
||||
}
|
||||
|
||||
final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId);
|
||||
|
||||
emit(
|
||||
@ -51,6 +60,8 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
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
|
||||
Future<void> close() async {
|
||||
await _streamSubscription.cancel();
|
||||
|
@ -4,26 +4,39 @@ class CollapseState extends Equatable {
|
||||
const CollapseState({
|
||||
required this.collapsed,
|
||||
required this.hidden,
|
||||
required this.locked,
|
||||
required this.collapsedCount,
|
||||
});
|
||||
|
||||
const CollapseState.init()
|
||||
: collapsed = false,
|
||||
hidden = false,
|
||||
locked = false,
|
||||
collapsedCount = 0;
|
||||
|
||||
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;
|
||||
|
||||
/// 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;
|
||||
|
||||
CollapseState copyWith({
|
||||
bool? collapsed,
|
||||
bool? hidden,
|
||||
bool? locked,
|
||||
int? collapsedCount,
|
||||
}) {
|
||||
return CollapseState(
|
||||
collapsed: collapsed ?? this.collapsed,
|
||||
hidden: hidden ?? this.hidden,
|
||||
locked: locked ?? this.locked,
|
||||
collapsedCount: collapsedCount ?? this.collapsedCount,
|
||||
);
|
||||
}
|
||||
@ -32,6 +45,7 @@ class CollapseState extends Equatable {
|
||||
List<Object?> get props => <Object?>[
|
||||
collapsed,
|
||||
hidden,
|
||||
locked,
|
||||
collapsedCount,
|
||||
];
|
||||
}
|
||||
|
@ -3,12 +3,14 @@ import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
@ -24,6 +26,7 @@ part 'comments_state.dart';
|
||||
class CommentsCubit extends Cubit<CommentsState> {
|
||||
CommentsCubit({
|
||||
required FilterCubit filterCubit,
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required CollapseCache collapseCache,
|
||||
required bool isOfflineReading,
|
||||
required Item item,
|
||||
@ -31,18 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
@ -54,13 +61,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
|
||||
final FilterCubit _filterCubit;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||
/// fetching comments posted directly to the story.
|
||||
StreamSubscription<Comment>? _streamSubscription;
|
||||
@ -70,6 +83,30 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
|
||||
<int, StreamSubscription<Comment>>{};
|
||||
|
||||
static const int _webFetchingCmtCountLowerLimit = 50;
|
||||
|
||||
Future<bool> get _shouldFetchFromWeb async {
|
||||
final bool isOnWifi = await _isOnWifi;
|
||||
if (isOnWifi) {
|
||||
return switch (state.item) {
|
||||
Story(descendants: final int descendants)
|
||||
when descendants > _webFetchingCmtCountLowerLimit =>
|
||||
true,
|
||||
Comment(kids: final List<int> kids)
|
||||
when kids.length > _webFetchingCmtCountLowerLimit =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> get _isOnWifi async {
|
||||
final ConnectivityResult status = await Connectivity().checkConnectivity();
|
||||
return status == ConnectivityResult.wifi;
|
||||
}
|
||||
|
||||
@override
|
||||
void emit(CommentsState state) {
|
||||
if (!isClosed) {
|
||||
@ -81,6 +118,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetAncestors,
|
||||
AppExceptionHandler? onError,
|
||||
bool fetchFromWeb = true,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
|
||||
emit(
|
||||
@ -91,7 +130,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
),
|
||||
);
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
_streamSubscription = _hackerNewsRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetAncestors!.last.kids,
|
||||
level: targetAncestors.last.level + 1,
|
||||
@ -108,6 +147,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
status: CommentsStatus.inProgress,
|
||||
comments: <Comment>[],
|
||||
matchedComments: <int>[],
|
||||
inThreadSearchQuery: '',
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
@ -115,7 +156,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.isOfflineReading
|
||||
? item
|
||||
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
|
||||
: await _hackerNewsRepository
|
||||
.fetchItem(id: item.id)
|
||||
.then(_toBuildable)
|
||||
.onError((_, __) => item) ??
|
||||
item;
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
@ -128,15 +172,54 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
} else {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
case FetchMode.eager:
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
|
||||
if (fetchFromWeb && shouldFetchFromWeb) {
|
||||
_logger.d('fetching from web.');
|
||||
commentStream = _hackerNewsWebRepository
|
||||
.fetchCommentsStream(state.item)
|
||||
.handleError((dynamic e) {
|
||||
_streamSubscription?.cancel();
|
||||
|
||||
_logger.e(e);
|
||||
|
||||
switch (e.runtimeType) {
|
||||
case RateLimitedException:
|
||||
case RateLimitedWithFallbackException:
|
||||
case PossibleParsingException:
|
||||
if (_preferenceCubit.state.devModeEnabled) {
|
||||
onError?.call(e as AppException);
|
||||
}
|
||||
|
||||
/// If fetching from web failed, fetch using API instead.
|
||||
refresh(onError: onError, fetchFromWeb: false);
|
||||
default:
|
||||
onError?.call(GenericException());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_logger.d('fetching from API.');
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache:
|
||||
useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
}
|
||||
case CommentsOrder.oldestFirst:
|
||||
case CommentsOrder.newestFirst:
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +230,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
Future<void> refresh({
|
||||
required AppExceptionHandler? onError,
|
||||
bool fetchFromWeb = true,
|
||||
}) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.inProgress,
|
||||
@ -180,18 +266,51 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem =
|
||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||
await _hackerNewsRepository.fetchItem(id: item.id) ?? item;
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
|
||||
case FetchMode.eager:
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
|
||||
if (fetchFromWeb && shouldFetchFromWeb) {
|
||||
_logger.d('fetching from web.');
|
||||
commentStream = _hackerNewsWebRepository
|
||||
.fetchCommentsStream(state.item)
|
||||
.handleError((dynamic e) {
|
||||
_logger.e(e);
|
||||
|
||||
switch (e.runtimeType) {
|
||||
case RateLimitedException:
|
||||
case RateLimitedWithFallbackException:
|
||||
case PossibleParsingException:
|
||||
if (_preferenceCubit.state.devModeEnabled) {
|
||||
onError?.call(e as AppException);
|
||||
}
|
||||
|
||||
/// If fetching from web failed, fetch using API instead.
|
||||
refresh(onError: onError, fetchFromWeb: false);
|
||||
default:
|
||||
onError?.call(GenericException());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_logger.d('fetching from API.');
|
||||
commentStream = _hackerNewsRepository
|
||||
.fetchAllCommentsRecursivelyStream(ids: kids);
|
||||
}
|
||||
case CommentsOrder.oldestFirst:
|
||||
case CommentsOrder.newestFirst:
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
@ -213,6 +332,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
item: story,
|
||||
matchedComments: <int>[],
|
||||
),
|
||||
);
|
||||
init();
|
||||
@ -237,14 +357,17 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
/// Ignoring because the subscription will be cancelled in close()
|
||||
// ignore: cancel_subscriptions
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final Map<int, Comment> updatedIdToCommentMap =
|
||||
Map<int, Comment>.from(state.idToCommentMap);
|
||||
updatedIdToCommentMap[comment.id] = comment;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -252,6 +375,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
cmt.copyWith(level: level),
|
||||
),
|
||||
idToCommentMap: updatedIdToCommentMap,
|
||||
),
|
||||
);
|
||||
offset++;
|
||||
@ -281,7 +405,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
HapticFeedbackUtil.light();
|
||||
emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
|
||||
final Item? parent =
|
||||
await _storiesRepository.fetchItem(id: state.item.parent);
|
||||
await _hackerNewsRepository.fetchItem(id: state.item.parent);
|
||||
|
||||
if (parent == null) {
|
||||
return;
|
||||
@ -302,7 +426,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> loadRootThread() async {
|
||||
HapticFeedbackUtil.light();
|
||||
emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
|
||||
final Story? parent = await _storiesRepository
|
||||
final Story? parent = await _hackerNewsRepository
|
||||
.fetchParentStory(id: state.item.id)
|
||||
.then(_toBuildableStory);
|
||||
|
||||
@ -322,7 +446,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
}
|
||||
|
||||
void onOrderChanged(CommentsOrder? order) {
|
||||
void updateOrder(CommentsOrder? order) {
|
||||
if (order == null) return;
|
||||
if (state.order == order) return;
|
||||
HapticFeedbackUtil.selection();
|
||||
@ -335,7 +459,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
void onFetchModeChanged(FetchMode? fetchMode) {
|
||||
void updateFetchMode(FetchMode? fetchMode) {
|
||||
if (fetchMode == null) return;
|
||||
if (state.fetchMode == fetchMode) return;
|
||||
_collapseCache.resetCollapsedComments();
|
||||
@ -349,17 +473,26 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
void scrollTo({
|
||||
required int index,
|
||||
double alignment = 0.0,
|
||||
}) {
|
||||
debugPrint('Scrolling to: $index, alignment: $alignment');
|
||||
itemScrollController.scrollTo(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scroll to next root level comment.
|
||||
void scrollToNextRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToNextRoot({VoidCallback? onError}) {
|
||||
final int totalComments = state.comments.length;
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
// The header is also a part of the list view,
|
||||
// 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))
|
||||
.map(
|
||||
(ItemPosition e) => e.index <= state.comments.length
|
||||
@ -369,9 +502,29 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
/// The index of last comment visible on screen.
|
||||
final int lastVisibleIndex = state.comments.indexOf(onScreenComments.last);
|
||||
final int startIndex = min(lastVisibleIndex + 1, totalComments);
|
||||
if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
|
||||
itemScrollController.scrollTo(
|
||||
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++) {
|
||||
final Comment cmt = state.comments.elementAt(i);
|
||||
@ -380,18 +533,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status == CommentsStatus.allLoaded) {
|
||||
onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll to previous root level comment.
|
||||
void scrollToPreviousRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToPreviousRoot() {
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
// The header is also a part of the list view,
|
||||
@ -419,13 +573,57 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
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) {
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
@ -451,8 +649,12 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
if (comment != null) {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_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(
|
||||
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
@ -461,7 +663,16 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,8 @@ class CommentsState extends Equatable {
|
||||
const CommentsState({
|
||||
required this.item,
|
||||
required this.comments,
|
||||
required this.matchedComments,
|
||||
required this.idToCommentMap,
|
||||
required this.status,
|
||||
required this.fetchParentStatus,
|
||||
required this.fetchRootStatus,
|
||||
@ -20,6 +22,8 @@ class CommentsState extends Equatable {
|
||||
required this.onlyShowTargetComment,
|
||||
required this.isOfflineReading,
|
||||
required this.currentPage,
|
||||
required this.inThreadSearchQuery,
|
||||
required this.inThreadSearchAuthor,
|
||||
});
|
||||
|
||||
CommentsState.init({
|
||||
@ -28,14 +32,19 @@ class CommentsState extends Equatable {
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
}) : comments = <Comment>[],
|
||||
matchedComments = <int>[],
|
||||
idToCommentMap = <int, Comment>{},
|
||||
status = CommentsStatus.idle,
|
||||
fetchParentStatus = CommentsStatus.idle,
|
||||
fetchRootStatus = CommentsStatus.idle,
|
||||
onlyShowTargetComment = false,
|
||||
currentPage = 0;
|
||||
currentPage = 0,
|
||||
inThreadSearchQuery = '',
|
||||
inThreadSearchAuthor = '';
|
||||
|
||||
final Item item;
|
||||
final List<Comment> comments;
|
||||
final Map<int, Comment> idToCommentMap;
|
||||
final CommentsStatus status;
|
||||
final CommentsStatus fetchParentStatus;
|
||||
final CommentsStatus fetchRootStatus;
|
||||
@ -44,10 +53,17 @@ class CommentsState extends Equatable {
|
||||
final bool onlyShowTargetComment;
|
||||
final bool isOfflineReading;
|
||||
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({
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
List<int>? matchedComments,
|
||||
Map<int, Comment>? idToCommentMap,
|
||||
CommentsStatus? status,
|
||||
CommentsStatus? fetchParentStatus,
|
||||
CommentsStatus? fetchRootStatus,
|
||||
@ -56,10 +72,13 @@ class CommentsState extends Equatable {
|
||||
bool? onlyShowTargetComment,
|
||||
bool? isOfflineReading,
|
||||
int? currentPage,
|
||||
String? inThreadSearchQuery,
|
||||
String? inThreadSearchAuthor,
|
||||
}) {
|
||||
return CommentsState(
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
matchedComments: matchedComments ?? this.matchedComments,
|
||||
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
||||
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
|
||||
status: status ?? this.status,
|
||||
@ -69,11 +88,41 @@ class CommentsState extends Equatable {
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||
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();
|
||||
|
||||
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
|
||||
List<Object?> get props => <Object?>[
|
||||
item,
|
||||
@ -86,5 +135,9 @@ class CommentsState extends Equatable {
|
||||
isOfflineReading,
|
||||
currentPage,
|
||||
comments,
|
||||
matchedComments,
|
||||
inThreadSearchQuery,
|
||||
inThreadSearchAuthor,
|
||||
idToCommentMap,
|
||||
];
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ part 'edit_state.dart';
|
||||
class EditCubit extends HydratedCubit<EditState> {
|
||||
EditCubit({DraftCache? draftCache})
|
||||
: _draftCache = draftCache ?? locator.get<DraftCache>(),
|
||||
_debouncer = Debouncer(delay: Durations.oneSecond),
|
||||
_debouncer = Debouncer(delay: AppDurations.oneSecond),
|
||||
super(const EditState.init());
|
||||
|
||||
final DraftCache _draftCache;
|
||||
|
@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'fav_state.dart';
|
||||
|
||||
@ -12,13 +17,18 @@ class FavCubit extends Cubit<FavState> {
|
||||
required AuthBloc authBloc,
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -26,44 +36,43 @@ class FavCubit extends Cubit<FavState> {
|
||||
final AuthBloc _authBloc;
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
late final StreamSubscription<String>? _usernameSubscription;
|
||||
static const int _pageSize = 20;
|
||||
String? _username;
|
||||
|
||||
Future<void> init() async {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.username != _username) {
|
||||
_preferenceRepository
|
||||
.favList(of: authState.username)
|
||||
.then((List<int> favIds) {
|
||||
_usernameSubscription = _authBloc.stream
|
||||
.map((AuthState event) => event.username)
|
||||
.distinct()
|
||||
.listen((String username) {
|
||||
_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(
|
||||
state.copyWith(
|
||||
favIds: favIds,
|
||||
favItems: <Item>[],
|
||||
currentPage: 0,
|
||||
status: Status.success,
|
||||
),
|
||||
);
|
||||
_storiesRepository
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.success,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
_username = authState.username;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -73,7 +82,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;
|
||||
|
||||
@ -89,9 +98,9 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void removeFav(int id) {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
_preferenceRepository.removeFav(username: username, id: id);
|
||||
_preferenceRepository
|
||||
..removeFav(username: username, id: id)
|
||||
..removeFav(username: '', id: id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -119,7 +128,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: state.favIds.sublist(
|
||||
lower,
|
||||
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.inProgress,
|
||||
@ -149,7 +156,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(state.copyWith(favIds: favIds));
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
@ -167,6 +174,34 @@ class FavCubit extends Cubit<FavState> {
|
||||
emit(FavState.init());
|
||||
}
|
||||
|
||||
Future<void> merge({
|
||||
required AppExceptionHandler onError,
|
||||
required VoidCallback onSuccess,
|
||||
}) async {
|
||||
if (_authBloc.state.isLoggedIn) {
|
||||
emit(state.copyWith(mergeStatus: Status.inProgress));
|
||||
try {
|
||||
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
|
||||
of: _authBloc.state.username,
|
||||
);
|
||||
_logger.d('fetched ${ids.length} favorite items from HN.');
|
||||
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));
|
||||
onSuccess();
|
||||
refresh();
|
||||
} on RateLimitedException catch (e) {
|
||||
onError(e);
|
||||
emit(state.copyWith(mergeStatus: Status.failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemLoaded(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -174,4 +209,14 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_usernameSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on FavCubit {
|
||||
String get username => _authBloc.state.username;
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ class FavState extends Equatable {
|
||||
required this.favIds,
|
||||
required this.favItems,
|
||||
required this.status,
|
||||
required this.mergeStatus,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
@ -12,23 +13,27 @@ class FavState extends Equatable {
|
||||
: favIds = <int>[],
|
||||
favItems = <Item>[],
|
||||
status = Status.idle,
|
||||
mergeStatus = Status.idle,
|
||||
currentPage = 0;
|
||||
|
||||
final List<int> favIds;
|
||||
final List<Item> favItems;
|
||||
final Status status;
|
||||
final Status mergeStatus;
|
||||
final int currentPage;
|
||||
|
||||
FavState copyWith({
|
||||
List<int>? favIds,
|
||||
List<Item>? favItems,
|
||||
Status? status,
|
||||
Status? mergeStatus,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return FavState(
|
||||
favIds: favIds ?? this.favIds,
|
||||
favItems: favItems ?? this.favItems,
|
||||
status: status ?? this.status,
|
||||
mergeStatus: mergeStatus ?? this.mergeStatus,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
@ -36,6 +41,7 @@ class FavState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
mergeStatus,
|
||||
currentPage,
|
||||
favIds,
|
||||
favItems,
|
||||
|
@ -10,16 +10,16 @@ part 'history_state.dart';
|
||||
class HistoryCubit extends Cubit<HistoryState> {
|
||||
HistoryCubit({
|
||||
required AuthBloc authBloc,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(HistoryState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
void init() {
|
||||
@ -27,7 +27,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
if (authState.isLoggedIn) {
|
||||
final String username = authState.username;
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(
|
||||
@ -38,7 +38,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
),
|
||||
);
|
||||
if (submittedIds != null) {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: submittedIds.sublist(
|
||||
0,
|
||||
@ -66,7 +66,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: state.submittedIds.sublist(
|
||||
lower,
|
||||
@ -93,12 +93,12 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
),
|
||||
);
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(state.copyWith(submittedIds: submittedIds));
|
||||
if (submittedIds != null) {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: submittedIds.sublist(
|
||||
0,
|
||||
|
@ -9,6 +9,7 @@ import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'notification_state.dart';
|
||||
|
||||
@ -16,23 +17,28 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
NotificationCubit({
|
||||
required AuthBloc authBloc,
|
||||
required PreferenceCubit preferenceCubit,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(NotificationState.init()) {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.isLoggedIn && authState.username != _username) {
|
||||
_authBloc.stream
|
||||
.map((AuthState event) => event.username)
|
||||
.distinct()
|
||||
.listen((String username) {
|
||||
if (username.isNotEmpty) {
|
||||
// Get the user setting.
|
||||
if (_preferenceCubit.state.notificationEnabled) {
|
||||
Future<void>.delayed(Durations.twoSeconds, init);
|
||||
Future<void>.delayed(AppDurations.twoSeconds, init);
|
||||
}
|
||||
|
||||
// Listen for setting changes in the future.
|
||||
@ -44,9 +50,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
_timer?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
_username = authState.username;
|
||||
} else if (!authState.isLoggedIn) {
|
||||
} else {
|
||||
emit(NotificationState.init());
|
||||
}
|
||||
});
|
||||
@ -54,10 +58,10 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
String? _username;
|
||||
final Logger _logger;
|
||||
Timer? _timer;
|
||||
|
||||
static const Duration _refreshInterval = Duration(minutes: 5);
|
||||
@ -74,6 +78,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
});
|
||||
|
||||
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
|
||||
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
|
||||
emit(state.copyWith(unreadCommentsIds: unreadIds));
|
||||
});
|
||||
|
||||
@ -82,7 +87,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
comment ??= await _hackerNewsRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -160,7 +165,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
comment ??= await _hackerNewsRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
|
||||
}
|
||||
@ -184,7 +189,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
}
|
||||
|
||||
Future<void> _fetchReplies() {
|
||||
return _storiesRepository
|
||||
return _hackerNewsRepository
|
||||
.fetchSubmitted(userId: _authBloc.state.username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
@ -194,7 +199,9 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
);
|
||||
|
||||
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> previousKids =
|
||||
(await _sembastRepository.kids(of: id)) ?? <int>[];
|
||||
@ -216,7 +223,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
...state.unreadCommentsIds,
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
await _hackerNewsRepository
|
||||
.fetchComment(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
@ -9,28 +10,33 @@ part 'pin_state.dart';
|
||||
class PinCubit extends Cubit<PinState> {
|
||||
PinCubit({
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(PinState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
|
||||
void init() {
|
||||
emit(PinState.init());
|
||||
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
|
||||
emit(state.copyWith(pinnedStoriesIds: ids));
|
||||
|
||||
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched);
|
||||
_hackerNewsRepository
|
||||
.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)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -39,10 +45,14 @@ class PinCubit extends Cubit<PinState> {
|
||||
),
|
||||
);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
onDone?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void unpinStory(Story story) {
|
||||
void unpinStory(
|
||||
Story story, {
|
||||
VoidCallback? onDone,
|
||||
}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id),
|
||||
@ -50,6 +60,7 @@ class PinCubit extends Cubit<PinState> {
|
||||
),
|
||||
);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
onDone?.call();
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
|
@ -11,13 +11,13 @@ part 'poll_state.dart';
|
||||
class PollCubit extends Cubit<PollState> {
|
||||
PollCubit({
|
||||
required Story story,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _story = story,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(PollState.init());
|
||||
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final Story _story;
|
||||
|
||||
Future<void> init({
|
||||
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
|
||||
if (pollOptionsIds.isEmpty || refresh) {
|
||||
final Story? updatedStory =
|
||||
await _storiesRepository.fetchStory(id: _story.id);
|
||||
await _hackerNewsRepository.fetchStory(id: _story.id);
|
||||
|
||||
if (updatedStory != null) {
|
||||
pollOptionsIds = updatedStory.parts;
|
||||
@ -47,7 +47,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
}
|
||||
|
||||
if (pollOptionsIds.isNotEmpty) {
|
||||
final List<PollOption> pollOptions = (await _storiesRepository
|
||||
final List<PollOption> pollOptions = (await _hackerNewsRepository
|
||||
.fetchPollOptionsStream(ids: pollOptionsIds)
|
||||
.toSet())
|
||||
.toList();
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'package:bloc/bloc.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/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -41,6 +43,16 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
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 {
|
||||
@ -48,6 +60,10 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
case int:
|
||||
final int? value = await _preferenceRepository.getInt(preference.key);
|
||||
return value as T?;
|
||||
case double:
|
||||
final double? value =
|
||||
await _preferenceRepository.getDouble(preference.key);
|
||||
return value as T?;
|
||||
case bool:
|
||||
final bool? value = await _preferenceRepository.getBool(preference.key);
|
||||
return value as T?;
|
||||
@ -56,19 +72,27 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
}
|
||||
}
|
||||
|
||||
void update<T>(Preference<T> preference, {required T to}) {
|
||||
final T value = to;
|
||||
final Preference<T> updatedPreference = preference.copyWith(val: value);
|
||||
void update<T>(Preference<T> preference) {
|
||||
_logger.i('updating $preference to ${preference.val}');
|
||||
|
||||
_logger.i('updating $preference to $value');
|
||||
|
||||
emit(state.copyWithPreference(updatedPreference));
|
||||
emit(state.copyWithPreference(preference));
|
||||
|
||||
switch (T) {
|
||||
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:
|
||||
_preferenceRepository.setBool(preference.key, value as bool);
|
||||
_preferenceRepository.setBool(
|
||||
preference.key,
|
||||
preference.val as bool,
|
||||
);
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
@ -54,8 +54,6 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
|
||||
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get readerEnabled => _isOn<ReaderModePreference>();
|
||||
|
||||
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
|
||||
@ -70,6 +68,25 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
|
||||
|
||||
bool get customTabEnabled => _isOn<CustomTabPreference>();
|
||||
|
||||
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
|
||||
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
|
||||
bool get devModeEnabled => _isOn<DevMode>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
|
||||
MaterialColor get appColor {
|
||||
return materialColors.elementAt(
|
||||
preferences.singleWhereType<AppColorPreference>().val,
|
||||
) as MaterialColor;
|
||||
}
|
||||
|
||||
List<StoryType> get tabs {
|
||||
final String result =
|
||||
preferences.singleWhereType<TabOrderPreference>().val.toString();
|
||||
@ -102,6 +119,9 @@ class PreferenceState extends Equatable {
|
||||
Font get font =>
|
||||
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||
|
||||
DisplayDateFormat get displayDateFormat => DisplayDateFormat.values
|
||||
.elementAt(preferences.singleWhereType<DateFormatPreference>().val);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||
|
@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
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) {
|
||||
final DateTime updatedStart = start.copyWith(
|
||||
second: 0,
|
||||
|
@ -38,8 +38,7 @@ class TabCubit extends Cubit<TabState> {
|
||||
// Check to make sure there's no duplicate.
|
||||
if (updatedTabs.toSet().length == StoryType.values.length) {
|
||||
_preferenceCubit.update<int>(
|
||||
TabOrderPreference(),
|
||||
to: StoryType.convertToSettingsValue(updatedTabs),
|
||||
TabOrderPreference(val: StoryType.convertToSettingsValue(updatedTabs)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,16 +7,16 @@ import 'package:hacki/repositories/repositories.dart';
|
||||
part 'user_state.dart';
|
||||
|
||||
class UserCubit extends Cubit<UserState> {
|
||||
UserCubit({StoriesRepository? storiesRepository})
|
||||
: _storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
UserCubit({HackerNewsRepository? hackerNewsRepository})
|
||||
: _hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(const UserState.init());
|
||||
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: Status.inProgress));
|
||||
_storiesRepository.fetchUser(id: userId).then((User? user) {
|
||||
_hackerNewsRepository.fetchUser(id: userId).then((User? user) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(userId),
|
||||
|
@ -3,7 +3,6 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
extension ContextExtension on BuildContext {
|
||||
T? tryRead<T>() {
|
||||
@ -21,22 +20,37 @@ extension ContextExtension on BuildContext {
|
||||
}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Palette.deepOrange,
|
||||
content: Text(content),
|
||||
backgroundColor: Theme.of(this).colorScheme.primary,
|
||||
content: Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: Theme.of(this).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
action: action != null && label != null
|
||||
? SnackBarAction(
|
||||
label: label,
|
||||
onPressed: action,
|
||||
textColor: Theme.of(this).textTheme.bodyLarge?.color,
|
||||
textColor: Theme.of(this).colorScheme.onPrimary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => showSnackBar(
|
||||
content: Constants.errorMessage,
|
||||
);
|
||||
void showErrorSnackBar([String? message]) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Theme.of(this).colorScheme.errorContainer,
|
||||
content: Text(
|
||||
message ?? Constants.errorMessage,
|
||||
style: TextStyle(
|
||||
color: Theme.of(this).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect? get rect {
|
||||
final RenderBox? box = findRenderObject() as RenderBox?;
|
||||
@ -81,4 +95,8 @@ extension ContextExtension on BuildContext {
|
||||
int get storyTileMaxLines {
|
||||
return _storyTileMaxLines;
|
||||
}
|
||||
|
||||
double get topPadding {
|
||||
return MediaQuery.of(this).padding.top + kToolbarHeight;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
export 'context_extension.dart';
|
||||
export 'date_time_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'item_action_mixin.dart';
|
||||
export 'list_extension.dart';
|
||||
export 'object_extension.dart';
|
||||
export 'set_extension.dart';
|
||||
export 'state_extension.dart';
|
||||
export 'string_extension.dart';
|
||||
export 'widget_extension.dart';
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -12,7 +13,8 @@ import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
extension StateExtension on State {
|
||||
@optionalTypeArgs
|
||||
mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
||||
void showSnackBar({
|
||||
required String content,
|
||||
VoidCallback? action,
|
||||
@ -25,7 +27,8 @@ extension StateExtension on State {
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => context.showErrorSnackBar();
|
||||
void showErrorSnackBar([String? message]) =>
|
||||
context.showErrorSnackBar(message);
|
||||
|
||||
Future<void>? goToItemScreen({
|
||||
required ItemScreenArgs args,
|
||||
@ -102,31 +105,26 @@ extension StateExtension on State {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
||||
linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
|
||||
}
|
||||
|
||||
if (linkToShare != null) {
|
@ -2,21 +2,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
return Padding(
|
||||
padding: value,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
extension ContextMenuBuilder on Widget {
|
||||
Widget contextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState, {
|
||||
required Item item,
|
||||
}) {
|
||||
if (item is! Buildable) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final int start = editableTextState.textEditingValue.selection.base.offset;
|
||||
final int end = editableTextState.textEditingValue.selection.end;
|
||||
|
||||
@ -25,39 +23,24 @@ extension WidgetModifier on Widget {
|
||||
];
|
||||
|
||||
if (start != -1 && end != -1) {
|
||||
String selectedText = item.text.substring(start, end);
|
||||
|
||||
if (item is Buildable) {
|
||||
final Iterable<EmphasisElement> emphasisElements =
|
||||
(item as Buildable).elements.whereType<EmphasisElement>();
|
||||
|
||||
int count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start + count * 2).clamp(0, item.text.length);
|
||||
final int e = (end + count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
|
||||
count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start - count * 2).clamp(0, item.text.length);
|
||||
final int e = (end - count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
final String text = (item as Buildable)
|
||||
.elements
|
||||
.map((LinkifyElement e) => e.text)
|
||||
.reduce((String value, String e) => '$value$e');
|
||||
final String selectedText = text.substring(start, end);
|
||||
|
||||
items.addAll(<ContextMenuButtonItem>[
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wikipediaLink}$selectedText''',
|
||||
context,
|
||||
),
|
||||
label: 'Wikipedia',
|
||||
),
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wiktionaryLink}$selectedText''',
|
||||
context,
|
||||
),
|
||||
label: 'Wiktionary',
|
||||
),
|
||||
@ -70,3 +53,14 @@ extension WidgetModifier on Widget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padded([
|
||||
EdgeInsetsGeometry value = const EdgeInsets.all(Dimens.pt12),
|
||||
]) {
|
||||
return Padding(
|
||||
padding: value,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
180
lib/main.dart
@ -17,16 +17,16 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/fetcher.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/haptic_feedback_util.dart';
|
||||
import 'package:hacki/utils/theme_util.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
@ -46,6 +46,8 @@ void notificationReceiver(NotificationResponse details) =>
|
||||
Future<void> main({bool testing = false}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await initializeDateFormatting(Platform.localeName);
|
||||
|
||||
isTesting = testing;
|
||||
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
@ -133,40 +135,28 @@ Future<void> main({bool testing = false}) async {
|
||||
}
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode = await AdaptiveTheme.getThemeMode();
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final bool trueDarkMode =
|
||||
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
||||
final Font font = Font.values.elementAt(
|
||||
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||
);
|
||||
|
||||
// Uncomment this line to log events from bloc/cubit.
|
||||
// Bloc.observer = CustomBlocObserver();
|
||||
|
||||
HydratedBloc.storage = storage;
|
||||
|
||||
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
|
||||
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
|
||||
|
||||
runApp(
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
font: font,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class HackiApp extends StatelessWidget {
|
||||
const HackiApp({
|
||||
required this.trueDarkMode,
|
||||
required this.font,
|
||||
super.key,
|
||||
this.savedThemeMode,
|
||||
});
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
final Font font;
|
||||
final bool trueDarkMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -243,56 +233,128 @@ class HackiApp extends StatelessWidget {
|
||||
)..init(),
|
||||
),
|
||||
],
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
primarySwatch: Palette.orange,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: trueDarkMode ? Palette.black : null,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
final ThemeData trueDarkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: Palette.black,
|
||||
fontFamily: font.name,
|
||||
);
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
|
||||
) {
|
||||
final AdaptiveThemeMode? mode = snapshot.data;
|
||||
ThemeUtil.updateStatusBarSetting(
|
||||
SchedulerBinding.instance.platformDispatcher.platformBrightness,
|
||||
mode,
|
||||
);
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen:
|
||||
(PreferenceState previous, PreferenceState current) =>
|
||||
previous.trueDarkEnabled != current.trueDarkEnabled,
|
||||
builder: (BuildContext context, PreferenceState prefState) {
|
||||
final bool useTrueDark = prefState.trueDarkEnabled &&
|
||||
(mode == AdaptiveThemeMode.dark ||
|
||||
child: BlocConsumer<PreferenceCubit, PreferenceState>(
|
||||
listenWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
|
||||
listener: (_, PreferenceState state) {
|
||||
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
|
||||
},
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.appColor != current.appColor ||
|
||||
previous.font != current.font ||
|
||||
previous.textScaleFactor != current.textScaleFactor ||
|
||||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
primarySwatch: state.appColor,
|
||||
),
|
||||
fontFamily: state.font.name,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: state.appColor,
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
primarySwatch: state.appColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
fontFamily: state.font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
|
||||
) {
|
||||
final AdaptiveThemeMode? mode = snapshot.data;
|
||||
ThemeUtil.updateStatusBarSetting(
|
||||
SchedulerBinding
|
||||
.instance.platformDispatcher.platformBrightness,
|
||||
mode,
|
||||
);
|
||||
final bool isDarkModeEnabled =
|
||||
mode == AdaptiveThemeMode.dark ||
|
||||
(mode == AdaptiveThemeMode.system &&
|
||||
View.of(context)
|
||||
.platformDispatcher
|
||||
.platformBrightness ==
|
||||
Brightness.dark));
|
||||
Brightness.dark);
|
||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
||||
brightness:
|
||||
isDarkModeEnabled ? Brightness.dark : Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
background: isDarkModeEnabled && state.trueDarkModeEnabled
|
||||
? Palette.black
|
||||
: null,
|
||||
);
|
||||
return FeatureDiscovery(
|
||||
child: MaterialApp.router(
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: (useTrueDark ? trueDarkTheme : theme).copyWith(
|
||||
useMaterial3: false,
|
||||
child: MediaQuery(
|
||||
data: state.textScaleFactor == 1
|
||||
? MediaQuery.of(context)
|
||||
: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
state.textScaleFactor,
|
||||
),
|
||||
),
|
||||
child: MaterialApp.router(
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: state.font.name,
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
trackColor: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return colorScheme.primary.withOpacity(0.6);
|
||||
} else {
|
||||
return Palette.grey.withOpacity(0.2);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
modalElevation: 8,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shadowColor: Palette.black,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: isDarkModeEnabled
|
||||
? Palette.white
|
||||
: Palette.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
sliderTheme: SliderThemeData(
|
||||
inactiveTrackColor:
|
||||
colorScheme.primary.withOpacity(0.5),
|
||||
activeTrackColor: colorScheme.primary,
|
||||
thumbColor: colorScheme.primary,
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
32
lib/models/app_exception.dart
Normal file
@ -0,0 +1,32 @@
|
||||
typedef AppExceptionHandler = void Function(AppException);
|
||||
|
||||
class AppException implements Exception {
|
||||
AppException({
|
||||
required this.message,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
final String? message;
|
||||
final StackTrace? stackTrace;
|
||||
}
|
||||
|
||||
class RateLimitedException extends AppException {
|
||||
RateLimitedException() : super(message: 'Rate limited...');
|
||||
}
|
||||
|
||||
class RateLimitedWithFallbackException extends AppException {
|
||||
RateLimitedWithFallbackException()
|
||||
: super(message: 'Rate limited, fetching from API instead...');
|
||||
}
|
||||
|
||||
class PossibleParsingException extends AppException {
|
||||
PossibleParsingException({
|
||||
required this.itemId,
|
||||
}) : super(message: 'Possible parsing failure...');
|
||||
|
||||
final int itemId;
|
||||
}
|
||||
|
||||
class GenericException extends AppException {
|
||||
GenericException() : super(message: 'Something went wrong...');
|
||||
}
|
@ -6,4 +6,7 @@ enum CommentsOrder {
|
||||
const CommentsOrder(this.description);
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
String toString() => description;
|
||||
}
|
||||
|
19
lib/models/dio/cached_response.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class CachedResponse<T> extends Response<T> {
|
||||
CachedResponse({
|
||||
required super.requestOptions,
|
||||
super.data,
|
||||
super.statusCode,
|
||||
}) : setDateTime = DateTime.now();
|
||||
|
||||
factory CachedResponse.fromResponse(Response<T> response) {
|
||||
return CachedResponse<T>(
|
||||
requestOptions: response.requestOptions,
|
||||
data: response.data,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime setDateTime;
|
||||
}
|
@ -2,7 +2,7 @@ enum DiscoverableFeature {
|
||||
addStoryToFavList(
|
||||
featureId: 'add_story_to_fav_list',
|
||||
title: 'Fav a Story',
|
||||
description: '''Add it to your favorites''',
|
||||
description: '''Add it to your favorites.''',
|
||||
),
|
||||
openStoryInWebView(
|
||||
featureId: 'open_story_in_web_view',
|
||||
@ -25,13 +25,18 @@ enum DiscoverableFeature {
|
||||
featureId: 'jump_up_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
'''Tapping on this button will take you to the previous root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
),
|
||||
jumpDownButton(
|
||||
featureId: 'jump_down_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
'''Tapping on this button will take you to the next root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
),
|
||||
searchInThread(
|
||||
featureId: 'search_in_thread',
|
||||
title: 'Search in Thread',
|
||||
description: '''Search for comments in this thread.''',
|
||||
);
|
||||
|
||||
const DiscoverableFeature({
|
||||
|
54
lib/models/display_date_format.dart
Normal file
@ -0,0 +1,54 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hacki/extensions/date_time_extension.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
enum DisplayDateFormat {
|
||||
timeAgo,
|
||||
yMd,
|
||||
yMEd,
|
||||
yMMMd,
|
||||
yMMMEd;
|
||||
|
||||
String get description {
|
||||
final DateTime exampleDate =
|
||||
DateTime.now().subtract(const Duration(days: 5));
|
||||
return switch (this) {
|
||||
timeAgo => exampleDate.toTimeAgoString(),
|
||||
yMd || yMEd || yMMMd || yMMMEd => () {
|
||||
final String defaultLocale = Platform.localeName;
|
||||
final DateFormat formatter = DateFormat(name, defaultLocale).add_Hm();
|
||||
return formatter.format(exampleDate);
|
||||
}(),
|
||||
};
|
||||
}
|
||||
|
||||
String convertToString(int timestamp) {
|
||||
if (_cache.containsKey(timestamp)) {
|
||||
return _cache[timestamp] ?? 'This is wrong';
|
||||
}
|
||||
|
||||
int updatedTimeStamp = timestamp;
|
||||
if (updatedTimeStamp < 9999999999) {
|
||||
updatedTimeStamp = updatedTimeStamp * 1000;
|
||||
}
|
||||
|
||||
final DateTime date = DateTime.fromMillisecondsSinceEpoch(updatedTimeStamp);
|
||||
|
||||
if (this == timeAgo) {
|
||||
final String dateString = date.toTimeAgoString();
|
||||
_cache[timestamp] = dateString;
|
||||
return dateString;
|
||||
} else {
|
||||
final String defaultLocale = Platform.localeName;
|
||||
final DateFormat formatter = DateFormat(name, defaultLocale).add_Hm();
|
||||
final String dateString = formatter.format(date);
|
||||
_cache[timestamp] = dateString;
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache() => _cache.clear();
|
||||
|
||||
static Map<int, String> _cache = <int, String>{};
|
||||
}
|
@ -5,4 +5,7 @@ enum FetchMode {
|
||||
const FetchMode(this.description);
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
String toString() => description;
|
||||
}
|
||||
|
@ -3,26 +3,11 @@ enum Font {
|
||||
robotoSlab('Roboto Slab', isSerif: true),
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono'),
|
||||
notoSerif('Noto Serif', isSerif: true);
|
||||
notoSerif('Noto Serif', isSerif: true),
|
||||
exo2('Exo 2');
|
||||
|
||||
const Font(this.uiLabel, {this.isSerif = false});
|
||||
|
||||
final String uiLabel;
|
||||
final bool isSerif;
|
||||
|
||||
static Font fromString(String? val) {
|
||||
switch (val) {
|
||||
case 'robotoSlab':
|
||||
return Font.robotoSlab;
|
||||
case 'ubuntu':
|
||||
return Font.ubuntu;
|
||||
case 'ubuntuMono':
|
||||
return Font.ubuntuMono;
|
||||
case 'notoSerif':
|
||||
return Font.notoSerif;
|
||||
case 'roboto':
|
||||
default:
|
||||
return Font.roboto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required super.level,
|
||||
required super.isFromCache,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
@ -33,6 +34,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
deleted: comment.deleted,
|
||||
level: comment.level,
|
||||
hidden: comment.hidden,
|
||||
isFromCache: comment.isFromCache,
|
||||
);
|
||||
|
||||
@override
|
||||
@ -53,6 +55,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
hidden: hidden ?? this.hidden,
|
||||
level: level ?? this.level,
|
||||
elements: elements,
|
||||
isFromCache: isFromCache,
|
||||
);
|
||||
}
|
||||
|
||||
|