Compare commits
68 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab43d1a2c4 | |||
cf5c0b3263 | |||
d7295afa41 | |||
1ecddf9d5b | |||
479903ed77 | |||
1e4c10e819 | |||
473a65427a | |||
ad6ccc9376 | |||
995dfed85d | |||
0e74f88a8d | |||
c2e6d7ea98 | |||
e46432b86c | |||
9763a94e1d | |||
077fcbf9da | |||
9cdb6b7383 | |||
d01524020d | |||
fb2072676e | |||
162c7a2689 | |||
e218527953 | |||
3dddfa66cf | |||
3fd0a9a1ea | |||
7a35fe451d | |||
575ba8c2ef | |||
e82998bb32 | |||
3389e98861 | |||
92a743f2f0 | |||
4148240daf | |||
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 |
2
.github/workflows/commit_check.yml
vendored
@ -7,7 +7,7 @@ on:
|
||||
- '!master'
|
||||
|
||||
jobs:
|
||||
releases:
|
||||
commit_check:
|
||||
name: Check commit
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
|
23
.github/workflows/parser_check.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
name: Parser Check
|
||||
|
||||
on:
|
||||
# Allow manual builds of this workflow.
|
||||
workflow_dispatch: { }
|
||||
# Run this job every hour.
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
parser_check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 0.5
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
- name: Verify comment text parser
|
||||
working-directory: ./scripts/bin
|
||||
run: |
|
||||
dart pub get
|
||||
dart parser_verifier.dart -t ${{ secrets.GITHUB_TOKEN }}
|
16
.github/workflows/publish_ios.yml
vendored
@ -1,16 +1,21 @@
|
||||
name: Publish (iOS)
|
||||
|
||||
on:
|
||||
# Allow manual builds of this workflow
|
||||
# Allow manual builds of this workflow.
|
||||
workflow_dispatch: {}
|
||||
# Run the workflow whenever a new tag named 'v*' is pushed
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Only build when any of these directories has been modified.
|
||||
paths:
|
||||
- lib/**
|
||||
- pubspec.lock
|
||||
- pubspec.yaml
|
||||
- submodules/**
|
||||
|
||||
jobs:
|
||||
build_and_publish:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-13
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
@ -19,6 +24,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:
|
||||
|
30
README.md
@ -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>
|
||||
|
||||
|
@ -11,3 +11,4 @@ linter:
|
||||
analyzer:
|
||||
exclude:
|
||||
- "submodules/**"
|
||||
- "scripts/**"
|
||||
|
@ -1,3 +1,10 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
@ -21,10 +23,6 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
@ -33,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
compileSdkVersion 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
@ -51,7 +49,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "com.jiaqifeng.hacki"
|
||||
minSdkVersion 25
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@ -80,7 +78,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0"
|
||||
}
|
||||
|
||||
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]
|
||||
|
@ -23,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"
|
||||
|
@ -1,16 +1,3 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
|
@ -1,11 +1,25 @@
|
||||
include ':app'
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||
def properties = new Properties()
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
BIN
assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
Normal file
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 |
7
assets/remote-config.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"athingComtrSelector": "#hnmain > tbody > tr > td > table > tbody > .athing.comtr",
|
||||
"commentTextSelector": "td > table > tbody > tr > td.default > div.comment > div.commtext",
|
||||
"commentHeadSelector": "td > table > tbody > tr > td.default > div > span > a",
|
||||
"commentAgeSelector": "td > table > tbody > tr > td.default > div > span > span.age",
|
||||
"commentIndentSelector": "td > table > tbody > tr > td.ind"
|
||||
}
|
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
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.
|
2
fastlane/metadata/android/en-US/changelogs/145.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Favicon in mini story tile.
|
||||
- UX improvements.
|
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
|
||||
|
@ -1,17 +1,17 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- ReachabilitySwift
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- 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):
|
||||
@ -37,8 +34,7 @@ PODS:
|
||||
- qr_code_scanner (0.2.0):
|
||||
- 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 +43,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):
|
||||
@ -60,11 +56,11 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
|
||||
- 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 +72,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,22 +81,20 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FMDB
|
||||
- MTBBarcodeScanner
|
||||
- OrderedSet
|
||||
- ReachabilitySwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
:path: ".symlinks/plugins/connectivity_plus/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
: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 +118,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 +131,31 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_inappwebview: 3d32228f1304635e7c028b0d4252937730bbc6cf
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
receive_sharing_intent: 753f808c6be5550247f6a20f2a14972466a5f33c
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
||||
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
|
||||
PODFILE CHECKSUM: 0957b955069bb512c22bae4cadad9f4c34161dbe
|
||||
|
||||
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"
|
||||
|
@ -114,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';
|
||||
@ -12,6 +13,7 @@ import 'package:responsive_builder/responsive_builder.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'stories_event.dart';
|
||||
|
||||
part 'stories_state.dart';
|
||||
|
||||
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
@ -32,17 +34,24 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(const StoriesState.init()) {
|
||||
on<LoadStories>(
|
||||
onLoadStories,
|
||||
transformer: sequential(),
|
||||
);
|
||||
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);
|
||||
on<StoryDownloaded>(onStoryDownloaded);
|
||||
on<StoriesExitOffline>(onExitOffline);
|
||||
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
|
||||
on<StoriesExitOfflineMode>(onExitOfflineMode);
|
||||
on<StoriesPageSizeChanged>(onPageSizeChanged);
|
||||
on<ClearAllReadStories>(onClearAllReadStories);
|
||||
}
|
||||
@ -54,7 +63,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
DeviceScreenType? deviceScreenType;
|
||||
StreamSubscription<PreferenceState>? _streamSubscription;
|
||||
StreamSubscription<PreferenceState>? _preferenceSubscription;
|
||||
static const int _smallPageSize = 10;
|
||||
static const int _largePageSize = 20;
|
||||
static const int _tabletSmallPageSize = 15;
|
||||
@ -64,70 +73,76 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesInitialize event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
_streamSubscription ??=
|
||||
_preferenceCubit.stream.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.complexStoryTileEnabled;
|
||||
_preferenceSubscription ??= _preferenceCubit.stream
|
||||
.distinct((PreferenceState previous, PreferenceState next) {
|
||||
return previous.isComplexStoryTileEnabled ==
|
||||
next.isComplexStoryTileEnabled;
|
||||
})
|
||||
//.debounceTime(AppDurations.twoSeconds)
|
||||
.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.isComplexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
|
||||
if (pageSize != state.currentPageSize) {
|
||||
add(StoriesPageSizeChanged(pageSize: pageSize));
|
||||
}
|
||||
});
|
||||
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
|
||||
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
|
||||
final bool isComplexTile = _preferenceCubit.state.isComplexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
const StoriesState.init().copyWith(
|
||||
isOfflineReading: hasCachedStories &&
|
||||
// Only go into offline mode in the next session.
|
||||
state.downloadStatus == StoriesDownloadStatus.idle,
|
||||
currentPageSize: pageSize,
|
||||
downloadStatus: state.downloadStatus,
|
||||
storiesDownloaded: state.storiesDownloaded,
|
||||
storiesToBeDownloaded: state.storiesToBeDownloaded,
|
||||
isOfflineReading: state.isOfflineReading,
|
||||
),
|
||||
);
|
||||
for (final StoryType type in StoryType.values) {
|
||||
await loadStories(type: type, emit: emit);
|
||||
|
||||
for (final StoryType type in _preferenceCubit.state.tabs) {
|
||||
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);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0)
|
||||
.copyWithStatusUpdated(type: type, to: Status.inProgress),
|
||||
);
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
|
||||
)
|
||||
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
|
||||
.onDone(() => add(StoryLoadingCompleted(type: type)));
|
||||
} else {
|
||||
final List<int> ids =
|
||||
await _hackerNewsRepository.fetchStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0)
|
||||
.copyWithStatusUpdated(type: type, to: Status.inProgress),
|
||||
);
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
await _hackerNewsRepository
|
||||
.fetchStoriesStream(
|
||||
ids: ids.sublist(0, state.currentPageSize),
|
||||
sequential: _preferenceCubit.state.isComplexStoryTileEnabled ||
|
||||
_preferenceCubit.state.isFaviconEnabled,
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
}).asFuture<void>();
|
||||
add(StoryLoadingCompleted(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,11 +168,13 @@ 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, isRefreshing: true));
|
||||
}
|
||||
}
|
||||
|
||||
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
|
||||
if (state.statusByType[event.type] == Status.inProgress) return;
|
||||
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
type: event.type,
|
||||
@ -182,39 +199,27 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
if (state.isOfflineReading) {
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(
|
||||
StoryLoaded(
|
||||
story: story,
|
||||
type: event.type,
|
||||
),
|
||||
);
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
(Story story) => add(StoryLoaded(story: story, type: event.type)),
|
||||
)
|
||||
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
|
||||
} else {
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(
|
||||
StoryLoaded(
|
||||
story: story,
|
||||
type: event.type,
|
||||
),
|
||||
);
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
(Story story) => add(StoryLoaded(story: story, type: event.type)),
|
||||
)
|
||||
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
|
||||
}
|
||||
} else {
|
||||
emit(
|
||||
@ -230,7 +235,18 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoryLoaded event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
if (event is StoryLoadingCompleted) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(type: event.type, to: Status.success),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final Story story = event.story;
|
||||
if (state.storiesByType[event.type]?.contains(story) ?? false) {
|
||||
_logger.d('story already exists.');
|
||||
return;
|
||||
}
|
||||
final bool hasRead = await _preferenceRepository.hasRead(story.id);
|
||||
final bool hidden = _filterCubit.state.keywords.any((String keyword) {
|
||||
// Match word only.
|
||||
@ -238,6 +254,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
return regExp.hasMatch(story.title.toLowerCase()) ||
|
||||
regExp.hasMatch(story.text.toLowerCase());
|
||||
});
|
||||
|
||||
emit(
|
||||
state.copyWithStoryAdded(
|
||||
type: event.type,
|
||||
@ -247,12 +264,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
}
|
||||
|
||||
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(type: event.type, to: Status.success),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onDownload(
|
||||
StoriesDownload event,
|
||||
Emitter<StoriesState> emit,
|
||||
@ -266,6 +277,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllWebPages();
|
||||
|
||||
final Set<int> prioritizedIds = <int>{};
|
||||
|
||||
@ -447,18 +459,22 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
Future<void> onExitOffline(
|
||||
StoriesExitOffline event,
|
||||
Future<void> onExitOfflineMode(
|
||||
StoriesExitOfflineMode event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllWebPages();
|
||||
emit(state.copyWith(isOfflineReading: false));
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
Future<void> onEnterOfflineMode(
|
||||
StoriesEnterOfflineMode event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isOfflineReading: true));
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
Future<void> onStoryRead(
|
||||
StoryRead event,
|
||||
Emitter<StoriesState> emit,
|
||||
@ -512,7 +528,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _streamSubscription?.cancel();
|
||||
await _preferenceSubscription?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
@ -5,20 +5,24 @@ abstract class StoriesEvent extends Equatable {
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class LoadStories extends StoriesEvent {
|
||||
LoadStories({required this.type, this.isRefreshing = false});
|
||||
|
||||
final StoryType type;
|
||||
final bool isRefreshing;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
type,
|
||||
isRefreshing,
|
||||
];
|
||||
}
|
||||
|
||||
class StoriesInitialize extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class StoriesLoaded extends StoriesEvent {
|
||||
StoriesLoaded({required this.type});
|
||||
|
||||
final StoryType type;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[type];
|
||||
}
|
||||
|
||||
class StoriesRefresh extends StoriesEvent {
|
||||
StoriesRefresh({required this.type});
|
||||
|
||||
@ -62,7 +66,12 @@ class StoryDownloaded extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[skipped];
|
||||
}
|
||||
|
||||
class StoriesExitOffline extends StoriesEvent {
|
||||
class StoriesExitOfflineMode extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class StoriesEnterOfflineMode extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
@ -86,6 +95,10 @@ class StoryLoaded extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[story, type];
|
||||
}
|
||||
|
||||
class StoryLoadingCompleted extends StoryLoaded {
|
||||
StoryLoadingCompleted({required super.type}) : super(story: Story.empty());
|
||||
}
|
||||
|
||||
class StoryRead extends StoriesEvent {
|
||||
StoryRead({required this.story});
|
||||
|
||||
|
@ -66,6 +66,12 @@ abstract class Constants {
|
||||
static final String errorMessage = 'Something went wrong...$sadFace';
|
||||
static final String loginErrorMessage =
|
||||
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
|
||||
|
||||
static String favicon(String url, {int size = 32}) {
|
||||
final Uri uri = Uri.parse(url);
|
||||
final String host = uri.host;
|
||||
return 'https://www.google.com/s2/favicons?domain=$host&sz=$size';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class RegExpConstants {
|
||||
@ -73,7 +79,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);
|
||||
@ -83,4 +89,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);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hacki/config/custom_log_filter.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
@ -24,7 +25,10 @@ Future<void> setUpLocator() async {
|
||||
),
|
||||
)
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<RemoteConfigRepository>(RemoteConfigRepository())
|
||||
..registerSingleton<RemoteConfigCubit>(RemoteConfigCubit())
|
||||
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
|
||||
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
..registerSingleton<AuthRepository>(AuthRepository())
|
||||
|
@ -3,6 +3,7 @@ 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';
|
||||
@ -25,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,
|
||||
@ -32,15 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
@ -52,10 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
|
||||
final FilterCubit _filterCubit;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
@ -71,6 +83,31 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
|
||||
<int, StreamSubscription<Comment>>{};
|
||||
|
||||
static const int _webFetchingCmtCountLowerLimit = 5;
|
||||
|
||||
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 List<ConnectivityResult> status =
|
||||
await Connectivity().checkConnectivity();
|
||||
return status.contains(ConnectivityResult.wifi);
|
||||
}
|
||||
|
||||
@override
|
||||
void emit(CommentsState state) {
|
||||
if (!isClosed) {
|
||||
@ -82,6 +119,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(
|
||||
@ -139,11 +178,49 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
case FetchMode.eager:
|
||||
commentStream =
|
||||
_hackerNewsRepository.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.isDevModeEnabled) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,7 +231,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,
|
||||
@ -191,14 +271,47 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
commentStream = _hackerNewsRepository.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.isDevModeEnabled) {
|
||||
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
|
||||
@ -369,7 +482,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
}
|
||||
|
||||
@ -394,7 +507,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -421,7 +534,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -461,7 +574,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -538,6 +651,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.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),
|
||||
|
@ -11,6 +11,7 @@ export 'poll/poll_cubit.dart';
|
||||
export 'post/post_cubit.dart';
|
||||
export 'preference/preference_cubit.dart';
|
||||
export 'reminder/reminder_cubit.dart';
|
||||
export 'remote_config/remote_config_cubit.dart';
|
||||
export 'search/search_cubit.dart';
|
||||
export 'split_view/split_view_cubit.dart';
|
||||
export 'submit/submit_cubit.dart';
|
||||
|
@ -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';
|
||||
|
||||
@ -13,12 +18,17 @@ class FavCubit extends Cubit<FavState> {
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -27,43 +37,42 @@ class FavCubit extends Cubit<FavState> {
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
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: LinkedHashSet<int>.from(favIds).toList(),
|
||||
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,
|
||||
),
|
||||
);
|
||||
_hackerNewsRepository
|
||||
.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);
|
||||
|
||||
@ -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(
|
||||
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.inProgress,
|
||||
@ -148,7 +155,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
);
|
||||
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(state.copyWith(favIds: favIds));
|
||||
emit(state.copyWith(favIds: LinkedHashSet<int>.from(favIds).toList()));
|
||||
_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,
|
||||
|
@ -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';
|
||||
|
||||
@ -19,6 +20,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_hackerNewsRepository =
|
||||
@ -27,26 +29,28 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
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);
|
||||
if (_preferenceCubit.state.isNotificationEnabled) {
|
||||
Future<void>.delayed(AppDurations.twoSeconds, init);
|
||||
}
|
||||
|
||||
// Listen for setting changes in the future.
|
||||
_preferenceCubit.stream.listen((PreferenceState prefState) {
|
||||
final bool isActive = _timer?.isActive ?? false;
|
||||
if (prefState.notificationEnabled && !isActive) {
|
||||
if (prefState.isNotificationEnabled && !isActive) {
|
||||
init();
|
||||
} else if (!prefState.notificationEnabled) {
|
||||
} else if (!prefState.isNotificationEnabled) {
|
||||
_timer?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
_username = authState.username;
|
||||
} else if (!authState.isLoggedIn) {
|
||||
} else {
|
||||
emit(NotificationState.init());
|
||||
}
|
||||
});
|
||||
@ -57,7 +61,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
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));
|
||||
});
|
||||
|
||||
@ -128,7 +133,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (_authBloc.state.isLoggedIn &&
|
||||
_preferenceCubit.state.notificationEnabled) {
|
||||
_preferenceCubit.state.isNotificationEnabled) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.inProgress,
|
||||
@ -243,4 +248,30 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onCommentTapped(
|
||||
Comment comment, {
|
||||
void Function((Story, List<Comment>)? res)? then,
|
||||
}) {
|
||||
if (state.commentFetchingStatus == Status.inProgress) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
commentFetchingStatus: Status.inProgress,
|
||||
tappedCommentId: comment.id,
|
||||
),
|
||||
);
|
||||
|
||||
locator
|
||||
.get<HackerNewsRepository>()
|
||||
.fetchParentStoryWithComments(id: comment.parent)
|
||||
.then(((Story, List<Comment>)? res) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
commentFetchingStatus: Status.success,
|
||||
),
|
||||
);
|
||||
then?.call(res);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,8 @@ class NotificationState extends Equatable {
|
||||
required this.currentPage,
|
||||
required this.offset,
|
||||
required this.status,
|
||||
required this.commentFetchingStatus,
|
||||
required this.tappedCommentId,
|
||||
});
|
||||
|
||||
NotificationState.init()
|
||||
@ -16,7 +18,9 @@ class NotificationState extends Equatable {
|
||||
allCommentsIds = <int>[],
|
||||
currentPage = 0,
|
||||
offset = 0,
|
||||
status = Status.idle;
|
||||
status = Status.idle,
|
||||
commentFetchingStatus = Status.idle,
|
||||
tappedCommentId = null;
|
||||
|
||||
final List<Comment> comments;
|
||||
final List<int> allCommentsIds;
|
||||
@ -24,6 +28,8 @@ class NotificationState extends Equatable {
|
||||
final int currentPage;
|
||||
final int offset;
|
||||
final Status status;
|
||||
final Status commentFetchingStatus;
|
||||
final int? tappedCommentId;
|
||||
|
||||
NotificationState copyWith({
|
||||
List<Comment>? comments,
|
||||
@ -32,6 +38,8 @@ class NotificationState extends Equatable {
|
||||
int? currentPage,
|
||||
int? offset,
|
||||
Status? status,
|
||||
Status? commentFetchingStatus,
|
||||
int? tappedCommentId,
|
||||
}) {
|
||||
return NotificationState(
|
||||
comments: comments ?? this.comments,
|
||||
@ -40,6 +48,9 @@ class NotificationState extends Equatable {
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
offset: offset ?? this.offset,
|
||||
status: status ?? this.status,
|
||||
commentFetchingStatus:
|
||||
commentFetchingStatus ?? this.commentFetchingStatus,
|
||||
tappedCommentId: tappedCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,6 +62,8 @@ class NotificationState extends Equatable {
|
||||
currentPage: currentPage,
|
||||
offset: offset,
|
||||
status: status,
|
||||
commentFetchingStatus: commentFetchingStatus,
|
||||
tappedCommentId: tappedCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,6 +78,8 @@ class NotificationState extends Equatable {
|
||||
currentPage: currentPage,
|
||||
offset: offset + 1,
|
||||
status: status,
|
||||
commentFetchingStatus: commentFetchingStatus,
|
||||
tappedCommentId: tappedCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,6 +88,7 @@ class NotificationState extends Equatable {
|
||||
currentPage,
|
||||
offset,
|
||||
status,
|
||||
commentFetchingStatus,
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
|
@ -48,35 +48,37 @@ class PreferenceState extends Equatable {
|
||||
.val;
|
||||
}
|
||||
|
||||
bool get notificationEnabled => _isOn<NotificationModePreference>();
|
||||
bool get isNotificationEnabled => _isOn<NotificationModePreference>();
|
||||
|
||||
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
|
||||
bool get isComplexStoryTileEnabled => _isOn<DisplayModePreference>();
|
||||
|
||||
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
bool get isFaviconEnabled => _isOn<FaviconModePreference>();
|
||||
|
||||
bool get readerEnabled => _isOn<ReaderModePreference>();
|
||||
bool get isEyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
|
||||
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
|
||||
bool get isReaderEnabled => _isOn<ReaderModePreference>();
|
||||
|
||||
bool get metadataEnabled => _isOn<MetadataModePreference>();
|
||||
bool get isMarkReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
|
||||
|
||||
bool get urlEnabled => _isOn<StoryUrlModePreference>();
|
||||
bool get isMetadataEnabled => _isOn<MetadataModePreference>();
|
||||
|
||||
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
|
||||
bool get isUrlEnabled => _isOn<StoryUrlModePreference>();
|
||||
|
||||
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
|
||||
bool get isTapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
|
||||
|
||||
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
|
||||
bool get isSwipeGestureEnabled => _isOn<SwipeGesturePreference>();
|
||||
|
||||
bool get customTabEnabled => _isOn<CustomTabPreference>();
|
||||
bool get isAutoScrollEnabled => _isOn<AutoScrollModePreference>();
|
||||
|
||||
bool get material3Enabled => _isOn<Material3Preference>();
|
||||
bool get isCustomTabEnabled => _isOn<CustomTabPreference>();
|
||||
|
||||
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
bool get isManualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
|
||||
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
|
||||
bool get isTrueDarkModeEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
bool get isHapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
|
||||
bool get isDevModeEnabled => _isOn<DevMode>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
@ -119,6 +121,9 @@ class PreferenceState extends Equatable {
|
||||
Font get font =>
|
||||
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||
|
||||
DateDisplayFormat get displayDateFormat => DateDisplayFormat.values
|
||||
.elementAt(preferences.singleWhereType<DateFormatPreference>().val);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||
|
@ -9,7 +9,9 @@ class ReminderCubit extends Cubit<ReminderState> {
|
||||
ReminderCubit({PreferenceRepository? preferenceRepository})
|
||||
: _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(const ReminderState.init());
|
||||
super(const ReminderState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
|
38
lib/cubits/remote_config/remote_config_cubit.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/repositories/remote_config_repository.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
part 'remote_config_state.dart';
|
||||
|
||||
class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> {
|
||||
RemoteConfigCubit({RemoteConfigRepository? remoteConfigRepository})
|
||||
: _remoteConfigRepository =
|
||||
remoteConfigRepository ?? locator.get<RemoteConfigRepository>(),
|
||||
super(RemoteConfigState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final RemoteConfigRepository _remoteConfigRepository;
|
||||
|
||||
void init() {
|
||||
_remoteConfigRepository
|
||||
.fetchRemoteConfig()
|
||||
.then((Map<String, dynamic> data) {
|
||||
if (data.isNotEmpty) {
|
||||
emit(state.copyWith(data: data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
RemoteConfigState? fromJson(Map<String, dynamic> json) {
|
||||
return RemoteConfigState(data: json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toJson(RemoteConfigState state) {
|
||||
return state.data;
|
||||
}
|
||||
}
|
59
lib/cubits/remote_config/remote_config_state.dart
Normal file
@ -0,0 +1,59 @@
|
||||
part of 'remote_config_cubit.dart';
|
||||
|
||||
final class RemoteConfigState extends Equatable {
|
||||
const RemoteConfigState({
|
||||
required this.data,
|
||||
});
|
||||
|
||||
RemoteConfigState.init() : data = <String, dynamic>{};
|
||||
|
||||
@protected
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
String get athingComtrSelector => getString(
|
||||
key: 'athingComtrSelector',
|
||||
fallback:
|
||||
'''#hnmain > tbody > tr > td > table > tbody > .athing.comtr''',
|
||||
);
|
||||
|
||||
String get commentTextSelector => getString(
|
||||
key: 'commentTextSelector',
|
||||
fallback:
|
||||
'''td > table > tbody > tr > td.default > div.comment > div.commtext''',
|
||||
);
|
||||
|
||||
String get commentHeadSelector => getString(
|
||||
key: 'commentHeadSelector',
|
||||
fallback: '''td > table > tbody > tr > td.default > div > span > a''',
|
||||
);
|
||||
|
||||
String get commentAgeSelector => getString(
|
||||
key: 'commentAgeSelector',
|
||||
fallback:
|
||||
'''td > table > tbody > tr > td.default > div > span > span.age''',
|
||||
);
|
||||
|
||||
String get commentIndentSelector => getString(
|
||||
key: 'commentIndentSelector',
|
||||
fallback: '''td > table > tbody > tr > td.ind''',
|
||||
);
|
||||
|
||||
String getString({required String key, String fallback = ''}) {
|
||||
return data[key] as String? ?? fallback;
|
||||
}
|
||||
|
||||
bool getBool({required String key, bool fallback = false}) {
|
||||
return data[key] as bool? ?? fallback;
|
||||
}
|
||||
|
||||
int getInt({required String key, int fallback = 0}) {
|
||||
return data[key] as int? ?? fallback;
|
||||
}
|
||||
|
||||
RemoteConfigState copyWith({Map<String, dynamic>? data}) {
|
||||
return RemoteConfigState(data: data ?? this.data);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[data];
|
||||
}
|
@ -25,16 +25,12 @@ class SubmitCubit extends Cubit<SubmitState> {
|
||||
emit(state.copyWith(text: text));
|
||||
}
|
||||
|
||||
void onSubmitTapped() {
|
||||
void submit() {
|
||||
emit(state.copyWith(status: Status.inProgress));
|
||||
|
||||
if (state.title?.isNotEmpty ?? false) {
|
||||
_postRepository
|
||||
.submit(
|
||||
title: state.title!,
|
||||
url: state.url,
|
||||
text: state.text,
|
||||
)
|
||||
.submit(title: state.title!, url: state.url, text: state.text)
|
||||
.then((bool successful) {
|
||||
emit(state.copyWith(status: Status.success));
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
|
@ -13,7 +13,9 @@ class TabCubit extends Cubit<TabState> {
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(TabState.init());
|
||||
super(TabState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final Logger _logger;
|
||||
|
@ -20,7 +20,7 @@ extension ContextExtension on BuildContext {
|
||||
}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Theme.of(this).primaryColor,
|
||||
backgroundColor: Theme.of(this).colorScheme.primary,
|
||||
content: Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
|
||||
);
|
||||
}
|
||||
|
||||
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?;
|
||||
|
@ -27,7 +27,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => context.showErrorSnackBar();
|
||||
void showErrorSnackBar([String? message]) =>
|
||||
context.showErrorSnackBar(message);
|
||||
|
||||
Future<void>? goToItemScreen({
|
||||
required ItemScreenArgs args,
|
||||
|
177
lib/main.dart
@ -23,6 +23,7 @@ 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;
|
||||
@ -45,12 +46,21 @@ void notificationReceiver(NotificationResponse details) =>
|
||||
Future<void> main({bool testing = false}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await initializeDateFormatting(Platform.localeName);
|
||||
|
||||
isTesting = testing;
|
||||
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final String tempPath = tempDir.path;
|
||||
Hive.init(tempPath);
|
||||
|
||||
final HydratedStorage storage = await HydratedStorage.build(
|
||||
storageDirectory: kIsWeb
|
||||
? HydratedStorage.webStorageDirectory
|
||||
: await getTemporaryDirectory(),
|
||||
);
|
||||
HydratedBloc.storage = storage;
|
||||
|
||||
await setUpLocator();
|
||||
|
||||
EquatableConfig.stringify = true;
|
||||
@ -63,12 +73,6 @@ Future<void> main({bool testing = false}) async {
|
||||
);
|
||||
};
|
||||
|
||||
final HydratedStorage storage = await HydratedStorage.build(
|
||||
storageDirectory: kIsWeb
|
||||
? HydratedStorage.webStorageDirectory
|
||||
: await getTemporaryDirectory(),
|
||||
);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
unawaited(
|
||||
Workmanager().initialize(
|
||||
@ -136,9 +140,7 @@ Future<void> main({bool testing = false}) async {
|
||||
// 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(
|
||||
@ -159,6 +161,9 @@ class HackiApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<RemoteConfigCubit>.value(
|
||||
value: locator.get<RemoteConfigCubit>(),
|
||||
),
|
||||
BlocProvider<PreferenceCubit>(
|
||||
lazy: false,
|
||||
create: (BuildContext context) => PreferenceCubit(),
|
||||
@ -214,7 +219,7 @@ class HackiApp extends StatelessWidget {
|
||||
),
|
||||
BlocProvider<ReminderCubit>(
|
||||
lazy: false,
|
||||
create: (BuildContext context) => ReminderCubit()..init(),
|
||||
create: (BuildContext context) => ReminderCubit(),
|
||||
),
|
||||
BlocProvider<PostCubit>(
|
||||
lazy: false,
|
||||
@ -227,25 +232,24 @@ class HackiApp extends StatelessWidget {
|
||||
BlocProvider<TabCubit>(
|
||||
create: (BuildContext context) => TabCubit(
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
)..init(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: BlocConsumer<PreferenceCubit, PreferenceState>(
|
||||
listenWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
|
||||
previous.isHapticFeedbackEnabled != current.isHapticFeedbackEnabled,
|
||||
listener: (_, PreferenceState state) {
|
||||
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
|
||||
HapticFeedbackUtil.enabled = state.isHapticFeedbackEnabled;
|
||||
},
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.appColor != current.appColor ||
|
||||
previous.font != current.font ||
|
||||
previous.textScaleFactor != current.textScaleFactor ||
|
||||
previous.material3Enabled != current.material3Enabled ||
|
||||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
|
||||
previous.isTrueDarkModeEnabled != current.isTrueDarkModeEnabled,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
|
||||
'''${state.appColor}${state.font}${state.isTrueDarkModeEnabled}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
@ -261,7 +265,6 @@ class HackiApp extends StatelessWidget {
|
||||
primarySwatch: state.appColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
|
||||
fontFamily: state.font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
@ -278,80 +281,92 @@ class HackiApp extends StatelessWidget {
|
||||
.instance.platformDispatcher.platformBrightness,
|
||||
mode,
|
||||
);
|
||||
final bool isDarkModeEnabled =
|
||||
mode == AdaptiveThemeMode.dark ||
|
||||
final bool isDarkModeEnabled = () {
|
||||
if (mode == null) {
|
||||
return View.of(context)
|
||||
.platformDispatcher
|
||||
.platformBrightness ==
|
||||
Brightness.dark;
|
||||
} else {
|
||||
return mode == AdaptiveThemeMode.dark ||
|
||||
(mode == AdaptiveThemeMode.system &&
|
||||
View.of(context)
|
||||
.platformDispatcher
|
||||
.platformBrightness ==
|
||||
Brightness.dark);
|
||||
}
|
||||
}();
|
||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
||||
brightness:
|
||||
isDarkModeEnabled ? Brightness.dark : Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
);
|
||||
return FeatureDiscovery(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaleFactor: state.textScaleFactor == 1
|
||||
? null
|
||||
: state.textScaleFactor,
|
||||
),
|
||||
data: state.textScaleFactor == 1
|
||||
? MediaQuery.of(context)
|
||||
: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
state.textScaleFactor,
|
||||
),
|
||||
),
|
||||
child: MaterialApp.router(
|
||||
key: Key(state.appColor.hashCode.toString()),
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
|
||||
useMaterial3: state.material3Enabled,
|
||||
dividerTheme: state.material3Enabled
|
||||
? DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
)
|
||||
: null,
|
||||
switchTheme: state.material3Enabled
|
||||
? SwitchThemeData(
|
||||
trackColor: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> states) {
|
||||
if (states
|
||||
.contains(MaterialState.selected)) {
|
||||
return null;
|
||||
} else {
|
||||
return Palette.grey.withOpacity(0.2);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
bottomSheetTheme: state.material3Enabled
|
||||
? const BottomSheetThemeData(
|
||||
modalElevation: 8,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shadowColor: Palette.black,
|
||||
)
|
||||
: null,
|
||||
inputDecorationTheme: state.material3Enabled
|
||||
? InputDecorationTheme(
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: isDarkModeEnabled
|
||||
? Palette.white
|
||||
: Palette.black,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
sliderTheme: state.material3Enabled
|
||||
? SliderThemeData(
|
||||
inactiveTrackColor:
|
||||
state.appColor.shade200.withOpacity(0.5),
|
||||
)
|
||||
: null,
|
||||
outlinedButtonTheme: state.material3Enabled
|
||||
? OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
theme: ThemeData(
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: state.font.name,
|
||||
canvasColor:
|
||||
isDarkModeEnabled && state.isTrueDarkModeEnabled
|
||||
? Palette.black
|
||||
: null,
|
||||
scaffoldBackgroundColor:
|
||||
isDarkModeEnabled && state.isTrueDarkModeEnabled
|
||||
? Palette.black
|
||||
: null,
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.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: WidgetStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
54
lib/models/date_display_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 DateDisplayFormat {
|
||||
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) {
|
||||
final bool isTimeAgo = this == timeAgo;
|
||||
|
||||
if (!isTimeAgo && _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 (isTimeAgo) {
|
||||
return date.toTimeAgoString();
|
||||
} 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>{};
|
||||
}
|
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',
|
||||
|
@ -5,4 +5,7 @@ enum FetchMode {
|
||||
const FetchMode(this.description);
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
String toString() => description;
|
||||
}
|
||||
|
@ -3,26 +3,12 @@ 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'),
|
||||
atkinsonHyperlegible('AtkinsonHyperlegible');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -90,8 +90,13 @@ class Item extends Equatable {
|
||||
final List<int> kids;
|
||||
final List<int> parts;
|
||||
|
||||
String get timeAgo =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
|
||||
String get timeAgo {
|
||||
int time = this.time;
|
||||
if (time < 9999999999) {
|
||||
time = time * 1000;
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
|
||||
}
|
||||
|
||||
bool get isPoll => type == 'poll';
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
export 'app_exception.dart';
|
||||
export 'comments_order.dart';
|
||||
export 'date_display_format.dart';
|
||||
export 'discoverable_feature.dart';
|
||||
export 'export_destination.dart';
|
||||
export 'fetch_mode.dart';
|
||||
|
@ -7,7 +7,7 @@ import 'package:hacki/models/displayable.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/styles/palette.dart';
|
||||
|
||||
abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
abstract final class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const Preference({required this.val});
|
||||
|
||||
final T val;
|
||||
@ -19,7 +19,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
static final List<Preference<dynamic>> allPreferences =
|
||||
UnmodifiableListView<Preference<dynamic>>(
|
||||
<Preference<dynamic>>[
|
||||
// Order of these preferences does not matter.
|
||||
/// Order of these preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontPreference(),
|
||||
@ -27,15 +27,20 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
TabOrderPreference(),
|
||||
StoryMarkingModePreference(),
|
||||
AppColorPreference(),
|
||||
DateFormatPreference(),
|
||||
const TextScaleFactorPreference(),
|
||||
// Order of items below matters and
|
||||
// reflects the order on settings screen.
|
||||
|
||||
/// Order of items below matters and
|
||||
/// reflects the order on settings screen.
|
||||
const DisplayModePreference(),
|
||||
const FaviconModePreference(),
|
||||
const MetadataModePreference(),
|
||||
const StoryUrlModePreference(),
|
||||
// Divider.
|
||||
|
||||
/// Divider.
|
||||
const MarkReadStoriesModePreference(),
|
||||
// Divider.
|
||||
|
||||
/// Divider.
|
||||
const NotificationModePreference(),
|
||||
const AutoScrollModePreference(),
|
||||
const CollapseModePreference(),
|
||||
@ -46,7 +51,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const HapticFeedbackPreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
const Material3Preference(),
|
||||
const DevMode(),
|
||||
],
|
||||
);
|
||||
|
||||
@ -54,48 +59,47 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
List<Object?> get props => <Object?>[key];
|
||||
}
|
||||
|
||||
abstract class BooleanPreference extends Preference<bool> {
|
||||
abstract final class BooleanPreference extends Preference<bool> {
|
||||
const BooleanPreference({required super.val});
|
||||
}
|
||||
|
||||
abstract class IntPreference extends Preference<int> {
|
||||
abstract final class IntPreference extends Preference<int> {
|
||||
const IntPreference({required super.val});
|
||||
}
|
||||
|
||||
abstract class DoublePreference extends Preference<double> {
|
||||
abstract final class DoublePreference extends Preference<double> {
|
||||
const DoublePreference({required super.val});
|
||||
}
|
||||
|
||||
const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
const bool _hapticFeedbackModeDefaultValue = true;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
const bool _markReadStoriesModeDefaultValue = true;
|
||||
const bool _metadataModeDefaultValue = true;
|
||||
const bool _storyUrlModeDefaultValue = true;
|
||||
const bool _collapseModeDefaultValue = true;
|
||||
const bool _autoScrollModeDefaultValue = false;
|
||||
const bool _customTabModeDefaultValue = false;
|
||||
const bool _material3ModeDefaultValue = false;
|
||||
const bool _paginationModeDefaultValue = false;
|
||||
const double _textScaleFactorDefaultValue = 1;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange);
|
||||
final int _fontDefaultValue = Font.roboto.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
final int _markStoriesAsReadWhenPreferenceDefaultValue =
|
||||
StoryMarkingMode.tap.index;
|
||||
final class DevMode extends BooleanPreference {
|
||||
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
|
||||
|
||||
class SwipeGesturePreference extends BooleanPreference {
|
||||
static const bool _devModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
DevMode copyWith({required bool? val}) {
|
||||
return DevMode(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'devMode';
|
||||
|
||||
@override
|
||||
String get title => 'Dev Mode';
|
||||
|
||||
@override
|
||||
String get subtitle => '';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => false;
|
||||
}
|
||||
|
||||
final class SwipeGesturePreference extends BooleanPreference {
|
||||
const SwipeGesturePreference({bool? val})
|
||||
: super(val: val ?? _swipeGestureModeDefaultValue);
|
||||
|
||||
static const bool _swipeGestureModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
SwipeGesturePreference copyWith({required bool? val}) {
|
||||
return SwipeGesturePreference(val: val);
|
||||
@ -112,10 +116,12 @@ class SwipeGesturePreference extends BooleanPreference {
|
||||
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
|
||||
}
|
||||
|
||||
class NotificationModePreference extends BooleanPreference {
|
||||
final class NotificationModePreference extends BooleanPreference {
|
||||
const NotificationModePreference({bool? val})
|
||||
: super(val: val ?? _notificationModeDefaultValue);
|
||||
|
||||
static const bool _notificationModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
NotificationModePreference copyWith({required bool? val}) {
|
||||
return NotificationModePreference(val: val);
|
||||
@ -132,10 +138,12 @@ class NotificationModePreference extends BooleanPreference {
|
||||
'''Hacki scans for new replies to your 15 most recent comments or stories every 5 minutes while the app is running in the foreground.''';
|
||||
}
|
||||
|
||||
class CollapseModePreference extends BooleanPreference {
|
||||
final class CollapseModePreference extends BooleanPreference {
|
||||
const CollapseModePreference({bool? val})
|
||||
: super(val: val ?? _collapseModeDefaultValue);
|
||||
|
||||
static const bool _collapseModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
CollapseModePreference copyWith({required bool? val}) {
|
||||
return CollapseModePreference(val: val);
|
||||
@ -152,10 +160,12 @@ class CollapseModePreference extends BooleanPreference {
|
||||
'''if disabled, tap on the top of comment tile to collapse.''';
|
||||
}
|
||||
|
||||
class AutoScrollModePreference extends BooleanPreference {
|
||||
final class AutoScrollModePreference extends BooleanPreference {
|
||||
const AutoScrollModePreference({bool? val})
|
||||
: super(val: val ?? _autoScrollModeDefaultValue);
|
||||
|
||||
static const bool _autoScrollModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
AutoScrollModePreference copyWith({required bool? val}) {
|
||||
return AutoScrollModePreference(val: val);
|
||||
@ -165,7 +175,7 @@ class AutoScrollModePreference extends BooleanPreference {
|
||||
String get key => 'autoScrollMode';
|
||||
|
||||
@override
|
||||
String get title => 'Auto-scroll on collapsing';
|
||||
String get title => 'Auto-scroll on Collapsing';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
@ -174,10 +184,12 @@ class AutoScrollModePreference extends BooleanPreference {
|
||||
|
||||
/// The value deciding whether or not the story
|
||||
/// tile should display link preview. Defaults to true.
|
||||
class DisplayModePreference extends BooleanPreference {
|
||||
final class DisplayModePreference extends BooleanPreference {
|
||||
const DisplayModePreference({bool? val})
|
||||
: super(val: val ?? _displayModeDefaultValue);
|
||||
|
||||
static const bool _displayModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
DisplayModePreference copyWith({required bool? val}) {
|
||||
return DisplayModePreference(val: val);
|
||||
@ -193,10 +205,33 @@ class DisplayModePreference extends BooleanPreference {
|
||||
String get subtitle => 'show web preview in story tile.';
|
||||
}
|
||||
|
||||
class MetadataModePreference extends BooleanPreference {
|
||||
final class FaviconModePreference extends BooleanPreference {
|
||||
const FaviconModePreference({bool? val})
|
||||
: super(val: val ?? _faviconModePreferenceDefaultValue);
|
||||
|
||||
static const bool _faviconModePreferenceDefaultValue = true;
|
||||
|
||||
@override
|
||||
FaviconModePreference copyWith({required bool? val}) {
|
||||
return FaviconModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'faviconMode';
|
||||
|
||||
@override
|
||||
String get title => 'Show Favicon';
|
||||
|
||||
@override
|
||||
String get subtitle => 'show favicon in story tile.';
|
||||
}
|
||||
|
||||
final class MetadataModePreference extends BooleanPreference {
|
||||
const MetadataModePreference({bool? val})
|
||||
: super(val: val ?? _metadataModeDefaultValue);
|
||||
|
||||
static const bool _metadataModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
MetadataModePreference copyWith({required bool? val}) {
|
||||
return MetadataModePreference(val: val);
|
||||
@ -213,10 +248,12 @@ class MetadataModePreference extends BooleanPreference {
|
||||
'''show number of comments and post date in story tile.''';
|
||||
}
|
||||
|
||||
class StoryUrlModePreference extends BooleanPreference {
|
||||
final class StoryUrlModePreference extends BooleanPreference {
|
||||
const StoryUrlModePreference({bool? val})
|
||||
: super(val: val ?? _storyUrlModeDefaultValue);
|
||||
|
||||
static const bool _storyUrlModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
StoryUrlModePreference copyWith({required bool? val}) {
|
||||
return StoryUrlModePreference(val: val);
|
||||
@ -232,10 +269,12 @@ class StoryUrlModePreference extends BooleanPreference {
|
||||
String get subtitle => '''show url in story tile.''';
|
||||
}
|
||||
|
||||
class ReaderModePreference extends BooleanPreference {
|
||||
final class ReaderModePreference extends BooleanPreference {
|
||||
const ReaderModePreference({bool? val})
|
||||
: super(val: val ?? _readerModeDefaultValue);
|
||||
|
||||
static const bool _readerModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
ReaderModePreference copyWith({required bool? val}) {
|
||||
return ReaderModePreference(val: val);
|
||||
@ -255,10 +294,12 @@ class ReaderModePreference extends BooleanPreference {
|
||||
bool get isDisplayable => Platform.isIOS;
|
||||
}
|
||||
|
||||
class MarkReadStoriesModePreference extends BooleanPreference {
|
||||
final class MarkReadStoriesModePreference extends BooleanPreference {
|
||||
const MarkReadStoriesModePreference({bool? val})
|
||||
: super(val: val ?? _markReadStoriesModeDefaultValue);
|
||||
|
||||
static const bool _markReadStoriesModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
MarkReadStoriesModePreference copyWith({required bool? val}) {
|
||||
return MarkReadStoriesModePreference(val: val);
|
||||
@ -274,10 +315,12 @@ class MarkReadStoriesModePreference extends BooleanPreference {
|
||||
String get subtitle => 'grey out stories you have read.';
|
||||
}
|
||||
|
||||
class EyeCandyModePreference extends BooleanPreference {
|
||||
final class EyeCandyModePreference extends BooleanPreference {
|
||||
const EyeCandyModePreference({bool? val})
|
||||
: super(val: val ?? _eyeCandyModeDefaultValue);
|
||||
|
||||
static const bool _eyeCandyModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
EyeCandyModePreference copyWith({required bool? val}) {
|
||||
return EyeCandyModePreference(val: val);
|
||||
@ -293,10 +336,12 @@ class EyeCandyModePreference extends BooleanPreference {
|
||||
String get subtitle => 'some sort of magic.';
|
||||
}
|
||||
|
||||
class ManualPaginationPreference extends BooleanPreference {
|
||||
final class ManualPaginationPreference extends BooleanPreference {
|
||||
const ManualPaginationPreference({bool? val})
|
||||
: super(val: val ?? _paginationModeDefaultValue);
|
||||
|
||||
static const bool _paginationModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
ManualPaginationPreference copyWith({required bool? val}) {
|
||||
return ManualPaginationPreference(val: val);
|
||||
@ -312,34 +357,16 @@ class ManualPaginationPreference extends BooleanPreference {
|
||||
String get subtitle => '''so you can get stuff done.''';
|
||||
}
|
||||
|
||||
class Material3Preference extends BooleanPreference {
|
||||
const Material3Preference({bool? val})
|
||||
: super(val: val ?? _material3ModeDefaultValue);
|
||||
|
||||
@override
|
||||
Material3Preference copyWith({required bool? val}) {
|
||||
return Material3Preference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'material3Mode';
|
||||
|
||||
@override
|
||||
String get title => 'Material 3';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
|
||||
}
|
||||
|
||||
/// Whether or not to use Custom Tabs for launching URLs.
|
||||
/// If false, default browser will be used.
|
||||
///
|
||||
/// https://developer.chrome.com/docs/android/custom-tabs/
|
||||
class CustomTabPreference extends BooleanPreference {
|
||||
final class CustomTabPreference extends BooleanPreference {
|
||||
const CustomTabPreference({bool? val})
|
||||
: super(val: val ?? _customTabModeDefaultValue);
|
||||
|
||||
static const bool _customTabModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
CustomTabPreference copyWith({required bool? val}) {
|
||||
return CustomTabPreference(val: val);
|
||||
@ -359,10 +386,12 @@ class CustomTabPreference extends BooleanPreference {
|
||||
bool get isDisplayable => Platform.isAndroid;
|
||||
}
|
||||
|
||||
class TrueDarkModePreference extends BooleanPreference {
|
||||
final class TrueDarkModePreference extends BooleanPreference {
|
||||
const TrueDarkModePreference({bool? val})
|
||||
: super(val: val ?? _trueDarkModeDefaultValue);
|
||||
|
||||
static const bool _trueDarkModeDefaultValue = false;
|
||||
|
||||
@override
|
||||
TrueDarkModePreference copyWith({required bool? val}) {
|
||||
return TrueDarkModePreference(val: val);
|
||||
@ -378,10 +407,12 @@ class TrueDarkModePreference extends BooleanPreference {
|
||||
String get subtitle => 'real dark.';
|
||||
}
|
||||
|
||||
class HapticFeedbackPreference extends BooleanPreference {
|
||||
final class HapticFeedbackPreference extends BooleanPreference {
|
||||
const HapticFeedbackPreference({bool? val})
|
||||
: super(val: val ?? _hapticFeedbackModeDefaultValue);
|
||||
|
||||
static const bool _hapticFeedbackModeDefaultValue = true;
|
||||
|
||||
@override
|
||||
HapticFeedbackPreference copyWith({required bool? val}) {
|
||||
return HapticFeedbackPreference(val: val);
|
||||
@ -395,14 +426,13 @@ class HapticFeedbackPreference extends BooleanPreference {
|
||||
|
||||
@override
|
||||
String get subtitle => '';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => Platform.isIOS;
|
||||
}
|
||||
|
||||
class FetchModePreference extends IntPreference {
|
||||
final class FetchModePreference extends IntPreference {
|
||||
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
|
||||
|
||||
static final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
|
||||
@override
|
||||
FetchModePreference copyWith({required int? val}) {
|
||||
return FetchModePreference(val: val);
|
||||
@ -415,10 +445,12 @@ class FetchModePreference extends IntPreference {
|
||||
String get title => 'Default fetch mode';
|
||||
}
|
||||
|
||||
class CommentsOrderPreference extends IntPreference {
|
||||
final class CommentsOrderPreference extends IntPreference {
|
||||
CommentsOrderPreference({int? val})
|
||||
: super(val: val ?? _commentsOrderDefaultValue);
|
||||
|
||||
static final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
|
||||
@override
|
||||
CommentsOrderPreference copyWith({required int? val}) {
|
||||
return CommentsOrderPreference(val: val);
|
||||
@ -431,9 +463,11 @@ class CommentsOrderPreference extends IntPreference {
|
||||
String get title => 'Default comments order';
|
||||
}
|
||||
|
||||
class FontPreference extends IntPreference {
|
||||
final class FontPreference extends IntPreference {
|
||||
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
|
||||
|
||||
static final int _fontDefaultValue = Font.robotoSlab.index;
|
||||
|
||||
@override
|
||||
FontPreference copyWith({required int? val}) {
|
||||
return FontPreference(val: val);
|
||||
@ -446,9 +480,11 @@ class FontPreference extends IntPreference {
|
||||
String get title => 'Default font';
|
||||
}
|
||||
|
||||
class FontSizePreference extends IntPreference {
|
||||
final class FontSizePreference extends IntPreference {
|
||||
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
||||
|
||||
static final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
|
||||
@override
|
||||
FontSizePreference copyWith({required int? val}) {
|
||||
return FontSizePreference(val: val);
|
||||
@ -461,9 +497,12 @@ class FontSizePreference extends IntPreference {
|
||||
String get title => 'Default font size';
|
||||
}
|
||||
|
||||
class TabOrderPreference extends IntPreference {
|
||||
final class TabOrderPreference extends IntPreference {
|
||||
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
|
||||
|
||||
static final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
|
||||
@override
|
||||
TabOrderPreference copyWith({required int? val}) {
|
||||
return TabOrderPreference(val: val);
|
||||
@ -476,10 +515,13 @@ class TabOrderPreference extends IntPreference {
|
||||
String get title => 'Tab order';
|
||||
}
|
||||
|
||||
class StoryMarkingModePreference extends IntPreference {
|
||||
final class StoryMarkingModePreference extends IntPreference {
|
||||
StoryMarkingModePreference({int? val})
|
||||
: super(val: val ?? _markStoriesAsReadWhenPreferenceDefaultValue);
|
||||
|
||||
static final int _markStoriesAsReadWhenPreferenceDefaultValue =
|
||||
StoryMarkingMode.tap.index;
|
||||
|
||||
@override
|
||||
StoryMarkingModePreference copyWith({required int? val}) {
|
||||
return StoryMarkingModePreference(val: val);
|
||||
@ -492,9 +534,12 @@ class StoryMarkingModePreference extends IntPreference {
|
||||
String get title => 'Mark as Read on';
|
||||
}
|
||||
|
||||
class AppColorPreference extends IntPreference {
|
||||
final class AppColorPreference extends IntPreference {
|
||||
AppColorPreference({int? val}) : super(val: val ?? _appColorDefaultValue);
|
||||
|
||||
static final int _appColorDefaultValue =
|
||||
materialColors.indexOf(Palette.deepOrange);
|
||||
|
||||
@override
|
||||
AppColorPreference copyWith({required int? val}) {
|
||||
return AppColorPreference(val: val);
|
||||
@ -507,10 +552,12 @@ class AppColorPreference extends IntPreference {
|
||||
String get title => 'Accent Color';
|
||||
}
|
||||
|
||||
class TextScaleFactorPreference extends DoublePreference {
|
||||
final class TextScaleFactorPreference extends DoublePreference {
|
||||
const TextScaleFactorPreference({double? val})
|
||||
: super(val: val ?? _textScaleFactorDefaultValue);
|
||||
|
||||
static const double _textScaleFactorDefaultValue = 1;
|
||||
|
||||
@override
|
||||
TextScaleFactorPreference copyWith({required double? val}) {
|
||||
return TextScaleFactorPreference(val: val);
|
||||
@ -522,3 +569,20 @@ class TextScaleFactorPreference extends DoublePreference {
|
||||
@override
|
||||
String get title => 'Default text scale factor';
|
||||
}
|
||||
|
||||
final class DateFormatPreference extends IntPreference {
|
||||
DateFormatPreference({int? val}) : super(val: val ?? _dateFormatDefaultValue);
|
||||
|
||||
static final int _dateFormatDefaultValue = DateDisplayFormat.timeAgo.index;
|
||||
|
||||
@override
|
||||
DateFormatPreference copyWith({required int? val}) {
|
||||
return DateFormatPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'dateFormat';
|
||||
|
||||
@override
|
||||
String get title => 'Date Format';
|
||||
}
|
||||
|
@ -223,6 +223,9 @@ class HackerNewsRepository {
|
||||
|
||||
/// Fetch a list of [Comment] based on ids and return results
|
||||
/// using a stream.
|
||||
///
|
||||
/// this function caches every comment fetched to [SembastRepository] so that
|
||||
/// we don't need to parse the text again later.
|
||||
Stream<Comment> fetchCommentsStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -258,6 +261,9 @@ class HackerNewsRepository {
|
||||
|
||||
/// Fetch a list of [Comment] based on ids recursively and
|
||||
/// return results using a stream.
|
||||
///
|
||||
/// this function caches every comment fetched to [SembastRepository] so that
|
||||
/// we don't need to parse the text again later.
|
||||
Stream<Comment> fetchAllCommentsRecursivelyStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -323,16 +329,32 @@ class HackerNewsRepository {
|
||||
|
||||
/// Fetch a list of [Story] based on ids and return results
|
||||
/// using a stream.
|
||||
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
|
||||
for (final int id in ids) {
|
||||
final Story? story =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
});
|
||||
Stream<Story> fetchStoriesStream({
|
||||
required List<int> ids,
|
||||
bool sequential = false,
|
||||
}) async* {
|
||||
if (sequential) {
|
||||
for (final int id in ids) {
|
||||
final Story? story =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
});
|
||||
|
||||
if (story != null) {
|
||||
if (story != null) {
|
||||
yield story;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final List<Map<String, dynamic>?> responses = await Future.wait(
|
||||
<Future<Map<String, dynamic>?>>[
|
||||
...ids.map(_fetchItemJson),
|
||||
],
|
||||
);
|
||||
for (final Map<String, dynamic>? json in responses) {
|
||||
if (json == null) continue;
|
||||
final Story story = Story.fromJson(json);
|
||||
yield story;
|
||||
}
|
||||
}
|
||||
|
298
lib/repositories/hacker_news_web_repository.dart
Normal file
@ -0,0 +1,298 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:html/dom.dart' hide Comment;
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
|
||||
/// For fetching anything that cannot be fetched through Hacker News API.
|
||||
class HackerNewsWebRepository {
|
||||
HackerNewsWebRepository({
|
||||
RemoteConfigCubit? remoteConfigCubit,
|
||||
Dio? dioWithCache,
|
||||
Dio? dio,
|
||||
}) : _dio = dio ?? Dio(),
|
||||
_dioWithCache = dioWithCache ?? Dio()
|
||||
..interceptors.addAll(
|
||||
<Interceptor>[
|
||||
if (kDebugMode) LoggerInterceptor(),
|
||||
CacheInterceptor(),
|
||||
],
|
||||
),
|
||||
_remoteConfigCubit =
|
||||
remoteConfigCubit ?? locator.get<RemoteConfigCubit>();
|
||||
|
||||
final Dio _dioWithCache;
|
||||
final Dio _dio;
|
||||
final RemoteConfigCubit _remoteConfigCubit;
|
||||
|
||||
static const Map<String, String> _headers = <String, String>{
|
||||
'accept': '*/*',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
|
||||
};
|
||||
|
||||
static const String _favoritesBaseUrl =
|
||||
'https://news.ycombinator.com/favorites?id=';
|
||||
static const String _aThingSelector =
|
||||
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing';
|
||||
|
||||
Future<Iterable<int>> fetchFavorites({required String of}) async {
|
||||
final bool isOnWifi = await _isOnWifi;
|
||||
final String username = of;
|
||||
final List<int> allIds = <int>[];
|
||||
int page = 1;
|
||||
const int maxPage = 2;
|
||||
|
||||
Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
|
||||
try {
|
||||
final Uri url = Uri.parse(
|
||||
'''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
|
||||
);
|
||||
final Response<String> response =
|
||||
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(url);
|
||||
|
||||
/// Due to rate limiting, we have a short break here.
|
||||
await Future<void>.delayed(AppDurations.twoSeconds);
|
||||
|
||||
final Document document = parse(response.data);
|
||||
final List<Element> elements =
|
||||
document.querySelectorAll(_aThingSelector);
|
||||
final Iterable<int> parsedIds =
|
||||
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
|
||||
return parsedIds;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == HttpStatus.forbidden) {
|
||||
throw RateLimitedException();
|
||||
}
|
||||
throw GenericException();
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<int> ids;
|
||||
while (page <= maxPage) {
|
||||
ids = await fetchIds(page);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
}
|
||||
allIds.addAll(ids);
|
||||
page++;
|
||||
}
|
||||
|
||||
page = 1;
|
||||
while (page <= maxPage) {
|
||||
ids = await fetchIds(page, isComment: true);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
}
|
||||
allIds.addAll(ids);
|
||||
page++;
|
||||
}
|
||||
|
||||
return allIds;
|
||||
}
|
||||
|
||||
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
|
||||
|
||||
String get _athingComtrSelector =>
|
||||
_remoteConfigCubit.state.athingComtrSelector;
|
||||
|
||||
String get _commentTextSelector =>
|
||||
_remoteConfigCubit.state.commentTextSelector;
|
||||
|
||||
String get _commentHeadSelector =>
|
||||
_remoteConfigCubit.state.commentHeadSelector;
|
||||
|
||||
String get _commentAgeSelector => _remoteConfigCubit.state.commentAgeSelector;
|
||||
|
||||
String get _commentIndentSelector =>
|
||||
_remoteConfigCubit.state.commentIndentSelector;
|
||||
|
||||
Stream<Comment> fetchCommentsStream(Item item) async* {
|
||||
final bool isOnWifi = await _isOnWifi;
|
||||
final int itemId = item.id;
|
||||
final int? descendants = item is Story ? item.descendants : null;
|
||||
int parentTextCount = 0;
|
||||
|
||||
Future<Iterable<Element>> fetchElements(int page) async {
|
||||
try {
|
||||
final Uri url = Uri.parse('$_itemBaseUrl$itemId&p=$page');
|
||||
final Options option = Options(
|
||||
headers: _headers,
|
||||
persistentConnection: true,
|
||||
);
|
||||
|
||||
/// Be more conservative while user is on wifi.
|
||||
final Response<String> response =
|
||||
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(
|
||||
url,
|
||||
options: option,
|
||||
);
|
||||
|
||||
final String data = response.data ?? '';
|
||||
|
||||
if (page == 1) {
|
||||
parentTextCount = 'parent'.allMatches(data).length;
|
||||
}
|
||||
|
||||
final Document document = parse(data);
|
||||
final List<Element> elements =
|
||||
document.querySelectorAll(_athingComtrSelector);
|
||||
return elements;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == HttpStatus.forbidden) {
|
||||
throw RateLimitedWithFallbackException();
|
||||
}
|
||||
throw GenericException();
|
||||
}
|
||||
}
|
||||
|
||||
if (descendants == 0 || item.kids.isEmpty) return;
|
||||
|
||||
final Set<int> fetchedCommentIds = <int>{};
|
||||
int page = 1;
|
||||
Iterable<Element> elements = await fetchElements(page);
|
||||
final Map<int, int> indentToParentId = <int, int>{};
|
||||
|
||||
if (item is Story && item.descendants > 0 && elements.isEmpty) {
|
||||
throw PossibleParsingException(itemId: itemId);
|
||||
}
|
||||
|
||||
while (elements.isNotEmpty) {
|
||||
for (final Element element in elements) {
|
||||
/// Get comment id.
|
||||
final String cmtIdString = element.attributes['id'] ?? '';
|
||||
final int? cmtId = int.tryParse(cmtIdString);
|
||||
|
||||
/// Get comment text.
|
||||
final Element? cmtTextElement =
|
||||
element.querySelector(_commentTextSelector);
|
||||
final String parsedText = await compute(
|
||||
_parseCommentTextHtml,
|
||||
cmtTextElement?.innerHtml ?? '',
|
||||
);
|
||||
|
||||
/// Get comment author.
|
||||
final Element? cmtHeadElement =
|
||||
element.querySelector(_commentHeadSelector);
|
||||
final String? cmtAuthor = cmtHeadElement?.text;
|
||||
|
||||
/// Get comment age.
|
||||
final Element? cmtAgeElement =
|
||||
element.querySelector(_commentAgeSelector);
|
||||
final String? ageString = cmtAgeElement?.attributes['title'];
|
||||
|
||||
final int? timestamp = ageString == null
|
||||
? null
|
||||
: DateTime.parse(ageString)
|
||||
.copyWith(isUtc: true)
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
/// Get comment indent.
|
||||
final Element? cmtIndentElement =
|
||||
element.querySelector(_commentIndentSelector);
|
||||
final String? indentString = cmtIndentElement?.attributes['indent'];
|
||||
final int indent =
|
||||
indentString == null ? 0 : (int.tryParse(indentString) ?? 0);
|
||||
|
||||
indentToParentId[indent] = cmtId ?? 0;
|
||||
final int parentId = indentToParentId[indent - 1] ?? -1;
|
||||
|
||||
final Comment cmt = Comment(
|
||||
id: cmtId ?? 0,
|
||||
time: timestamp ?? 0,
|
||||
parent: parentId,
|
||||
score: 0,
|
||||
by: cmtAuthor ?? '',
|
||||
text: parsedText,
|
||||
kids: const <int>[],
|
||||
dead: false,
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
level: indent,
|
||||
isFromCache: false,
|
||||
);
|
||||
|
||||
/// Skip any comment with no valid id or timestamp.
|
||||
if (cmt.id == 0 || timestamp == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/// Duplicate comment means we are done fetching all the comments.
|
||||
if (fetchedCommentIds.contains(cmt.id)) return;
|
||||
|
||||
fetchedCommentIds.add(cmt.id);
|
||||
yield cmt;
|
||||
}
|
||||
|
||||
/// If we didn't successfully got any comment on first page,
|
||||
/// and we are sure there are comments there based on the count of
|
||||
/// 'parent' text, then this might be a parsing error and possibly is
|
||||
/// caused by HN changing their HTML structure, therefore here we
|
||||
/// throw an error so that we can fallback to use API instead.
|
||||
if (page == 1 && parentTextCount > 0 && fetchedCommentIds.isEmpty) {
|
||||
throw PossibleParsingException(itemId: itemId);
|
||||
}
|
||||
|
||||
if (descendants != null && fetchedCommentIds.length >= descendants) {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Due to rate limiting, we have a short break here.
|
||||
await Future<void>.delayed(AppDurations.twoSeconds);
|
||||
|
||||
page++;
|
||||
elements = await fetchElements(page);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> get _isOnWifi async {
|
||||
final List<ConnectivityResult> status =
|
||||
await Connectivity().checkConnectivity();
|
||||
return status.contains(ConnectivityResult.wifi);
|
||||
}
|
||||
|
||||
static Future<String> _parseCommentTextHtml(String text) async {
|
||||
return HtmlUnescape()
|
||||
.convert(text)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<div class="reply"\>(.*?)\<\/div\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '${match[2]}',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<p\>(.*?)\<\/p\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '\n\n${match[1]}',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
|
||||
(Match match) => match[1] ?? '',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'\<i\>(.*?)\<\/i\>'),
|
||||
(Match match) => '*${match[1]}*',
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
}
|