mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
58 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 |
2
.github/workflows/commit_check.yml
vendored
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
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
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:
|
||||
|
@ -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]
|
||||
|
@ -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/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
7
assets/remote-config.json
Normal file
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"
|
||||
}
|
3
fastlane/metadata/android/en-US/changelogs/135.txt
Normal file
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
2
fastlane/metadata/android/en-US/changelogs/145.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Favicon in mini story tile.
|
||||
- UX improvements.
|
@ -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
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();
|
||||
}
|
||||
}
|
||||
|
@ -13,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> {
|
||||
@ -46,11 +47,11 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
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);
|
||||
}
|
||||
@ -62,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;
|
||||
@ -72,30 +73,33 @@ 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) {
|
||||
|
||||
for (final StoryType type in _preferenceCubit.state.tabs) {
|
||||
add(LoadStories(type: type));
|
||||
}
|
||||
}
|
||||
@ -111,31 +115,34 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
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),
|
||||
);
|
||||
await _hackerNewsRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
.fetchStoriesStream(
|
||||
ids: ids.sublist(0, state.currentPageSize),
|
||||
sequential: _preferenceCubit.state.isComplexStoryTileEnabled ||
|
||||
_preferenceCubit.state.isFaviconEnabled,
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).asFuture<void>();
|
||||
add(StoriesLoaded(type: type));
|
||||
add(StoryLoadingCompleted(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,11 +168,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(type: event.type));
|
||||
add(LoadStories(type: event.type));
|
||||
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,
|
||||
@ -190,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(
|
||||
@ -238,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.
|
||||
@ -246,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,
|
||||
@ -255,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,
|
||||
@ -274,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>{};
|
||||
|
||||
@ -455,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,
|
||||
@ -520,7 +528,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _streamSubscription?.cancel();
|
||||
await _preferenceSubscription?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
@ -6,12 +6,16 @@ abstract class StoriesEvent extends Equatable {
|
||||
}
|
||||
|
||||
class LoadStories extends StoriesEvent {
|
||||
LoadStories({required this.type});
|
||||
LoadStories({required this.type, this.isRefreshing = false});
|
||||
|
||||
final StoryType type;
|
||||
final bool isRefreshing;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[type];
|
||||
List<Object?> get props => <Object?>[
|
||||
type,
|
||||
isRefreshing,
|
||||
];
|
||||
}
|
||||
|
||||
class StoriesInitialize extends StoriesEvent {
|
||||
@ -19,15 +23,6 @@ class StoriesInitialize extends StoriesEvent {
|
||||
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});
|
||||
|
||||
@ -71,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?>[];
|
||||
}
|
||||
@ -95,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 {
|
||||
@ -83,4 +89,7 @@ abstract class AppDurations {
|
||||
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,6 +25,8 @@ Future<void> setUpLocator() async {
|
||||
),
|
||||
)
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<RemoteConfigRepository>(RemoteConfigRepository())
|
||||
..registerSingleton<RemoteConfigCubit>(RemoteConfigCubit())
|
||||
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
|
||||
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
|
@ -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,
|
||||
@ -34,8 +36,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
OfflineRepository? offlineRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
@ -44,6 +48,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
@ -55,11 +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();
|
||||
@ -75,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) {
|
||||
@ -86,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(
|
||||
@ -143,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,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,
|
||||
@ -195,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
|
||||
|
@ -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';
|
||||
|
@ -2,11 +2,13 @@ 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';
|
||||
|
||||
@ -17,6 +19,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
PreferenceRepository? preferenceRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
@ -25,6 +28,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -34,6 +38,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
late final StreamSubscription<String>? _usernameSubscription;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@ -45,7 +50,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
favIds: favIds,
|
||||
favIds: LinkedHashSet<int>.from(favIds).toList(),
|
||||
favItems: <Item>[],
|
||||
currentPage: 0,
|
||||
),
|
||||
@ -93,7 +98,9 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void removeFav(int id) {
|
||||
_preferenceRepository.removeFav(username: username, id: id);
|
||||
_preferenceRepository
|
||||
..removeFav(username: username, id: id)
|
||||
..removeFav(username: '', id: id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -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,20 +174,31 @@ class FavCubit extends Cubit<FavState> {
|
||||
emit(FavState.init());
|
||||
}
|
||||
|
||||
Future<void> merge() async {
|
||||
Future<void> merge({
|
||||
required AppExceptionHandler onError,
|
||||
required VoidCallback onSuccess,
|
||||
}) async {
|
||||
if (_authBloc.state.isLoggedIn) {
|
||||
emit(state.copyWith(mergeStatus: Status.inProgress));
|
||||
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
|
||||
of: _authBloc.state.username,
|
||||
);
|
||||
final List<int> combinedIds = <int>[...ids, ...state.favIds];
|
||||
final LinkedHashSet<int> mergedIds = LinkedHashSet<int>.from(combinedIds);
|
||||
await _preferenceRepository.overwriteFav(
|
||||
username: username,
|
||||
ids: mergedIds,
|
||||
);
|
||||
emit(state.copyWith(mergeStatus: Status.success));
|
||||
refresh();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,6 +29,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(NotificationState.init()) {
|
||||
_authBloc.stream
|
||||
.map((AuthState event) => event.username)
|
||||
@ -34,16 +37,16 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
.listen((String username) {
|
||||
if (username.isNotEmpty) {
|
||||
// Get the user setting.
|
||||
if (_preferenceCubit.state.notificationEnabled) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@ -58,6 +61,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
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,31 +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 manualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
bool get isCustomTabEnabled => _isOn<CustomTabPreference>();
|
||||
|
||||
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
bool get isManualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
|
||||
bool get isTrueDarkModeEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get isHapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
|
||||
bool get isDevModeEnabled => _isOn<DevMode>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
@ -115,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
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
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;
|
||||
|
@ -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,
|
||||
|
@ -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,8 +140,6 @@ 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 = AppDurations.ms200;
|
||||
|
||||
runApp(
|
||||
@ -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,23 +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.textScaleFactor != current.textScaleFactor ||
|
||||
previous.isTrueDarkModeEnabled != current.isTrueDarkModeEnabled,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}''',
|
||||
'''${state.appColor}${state.font}${state.isTrueDarkModeEnabled}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
@ -275,13 +281,21 @@ 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,
|
||||
@ -297,24 +311,26 @@ class HackiApp extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: MaterialApp.router(
|
||||
key: Key(state.appColor.hashCode.toString()),
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: isDarkModeEnabled
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
),
|
||||
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: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.primary.withOpacity(0.6);
|
||||
} else {
|
||||
return Palette.grey.withOpacity(0.2);
|
||||
@ -344,7 +360,7 @@ class HackiApp extends StatelessWidget {
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateBorderSide.resolveWith(
|
||||
side: WidgetStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
|
32
lib/models/app_exception.dart
Normal file
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...');
|
||||
}
|
54
lib/models/date_display_format.dart
Normal file
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
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;
|
||||
}
|
@ -4,7 +4,8 @@ enum Font {
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono'),
|
||||
notoSerif('Noto Serif', isSerif: true),
|
||||
exo2('Exo 2');
|
||||
exo2('Exo 2'),
|
||||
atkinsonHyperlegible('AtkinsonHyperlegible');
|
||||
|
||||
const Font(this.uiLabel, {this.isSerif = false});
|
||||
|
||||
|
@ -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(),
|
||||
@ -45,6 +50,8 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const SwipeGesturePreference(),
|
||||
const HapticFeedbackPreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
const DevMode(),
|
||||
],
|
||||
);
|
||||
|
||||
@ -52,46 +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 _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 _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.robotoSlab.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);
|
||||
@ -108,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);
|
||||
@ -128,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);
|
||||
@ -148,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);
|
||||
@ -161,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 =>
|
||||
@ -170,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);
|
||||
@ -189,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);
|
||||
@ -209,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);
|
||||
@ -228,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);
|
||||
@ -251,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);
|
||||
@ -270,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);
|
||||
@ -289,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,10 +361,12 @@ class ManualPaginationPreference extends BooleanPreference {
|
||||
/// 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);
|
||||
@ -335,10 +386,33 @@ class CustomTabPreference extends BooleanPreference {
|
||||
bool get isDisplayable => Platform.isAndroid;
|
||||
}
|
||||
|
||||
class HapticFeedbackPreference 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);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'trueDarkMode';
|
||||
|
||||
@override
|
||||
String get title => 'True Dark Mode';
|
||||
|
||||
@override
|
||||
String get subtitle => 'real dark.';
|
||||
}
|
||||
|
||||
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);
|
||||
@ -352,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);
|
||||
@ -372,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);
|
||||
@ -388,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);
|
||||
@ -403,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);
|
||||
@ -418,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);
|
||||
@ -433,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);
|
||||
@ -449,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);
|
||||
@ -464,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);
|
||||
@ -479,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';
|
||||
}
|
||||
|
@ -329,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;
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,45 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.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:http/http.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
|
||||
/// For fetching anything that cannot be fetched through Hacker News API.
|
||||
class HackerNewsWebRepository {
|
||||
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=';
|
||||
@ -13,25 +47,39 @@ class HackerNewsWebRepository {
|
||||
'#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 = 0;
|
||||
int page = 1;
|
||||
const int maxPage = 2;
|
||||
|
||||
Future<Iterable<int>> fetchIds(int page) async {
|
||||
final Uri url = Uri.parse('$_favoritesBaseUrl$username&p=$page');
|
||||
final Response response = await get(url);
|
||||
final Document document = parse(response.body);
|
||||
final List<Element> elements = document.querySelectorAll(_aThingSelector);
|
||||
final Iterable<int> parsedIds = elements
|
||||
.map(
|
||||
(Element e) => int.tryParse(e.id),
|
||||
)
|
||||
.whereNotNull();
|
||||
return parsedIds;
|
||||
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 (true) {
|
||||
while (page <= maxPage) {
|
||||
ids = await fetchIds(page);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
@ -40,6 +88,211 @@ class HackerNewsWebRepository {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/auth_repository.dart';
|
||||
import 'package:hacki/repositories/post_repository.dart';
|
||||
import 'package:hacki/utils/service_exception.dart';
|
||||
|
||||
/// [PostableRepository] is solely for hosting functionalities shared between
|
||||
/// [AuthRepository] and [PostRepository].
|
||||
@ -40,7 +39,7 @@ class PostableRepository {
|
||||
}
|
||||
|
||||
return true;
|
||||
} on ServiceException {
|
||||
} on AppException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -65,7 +64,7 @@ class PostableRepository {
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ServiceException(e.message);
|
||||
throw AppException(message: e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
18
lib/repositories/remote_config_repository.dart
Normal file
18
lib/repositories/remote_config_repository.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class RemoteConfigRepository {
|
||||
RemoteConfigRepository({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<Map<String, dynamic>> fetchRemoteConfig() async {
|
||||
final Response<dynamic> response = await _dio.get(
|
||||
'https://raw.githubusercontent.com/Livinglist/Hacki/master/assets/remote-config.json',
|
||||
);
|
||||
final String data = response.data as String? ?? '';
|
||||
final Map<String, dynamic> json = jsonDecode(data) as Map<String, dynamic>;
|
||||
return json;
|
||||
}
|
||||
}
|
@ -4,5 +4,6 @@ export 'hacker_news_web_repository.dart';
|
||||
export 'offline_repository.dart';
|
||||
export 'post_repository.dart';
|
||||
export 'preference_repository.dart';
|
||||
export 'remote_config_repository.dart';
|
||||
export 'search_repository.dart';
|
||||
export 'sembast_repository.dart';
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sembast/sembast.dart';
|
||||
@ -12,23 +14,34 @@ import 'package:sembast/sembast_io.dart';
|
||||
/// documents directory assigned by host system which you can retrieve
|
||||
/// by calling [getApplicationDocumentsDirectory].
|
||||
class SembastRepository {
|
||||
SembastRepository({Database? database}) {
|
||||
SembastRepository({
|
||||
Database? database,
|
||||
Database? cache,
|
||||
}) {
|
||||
if (database == null) {
|
||||
initializeDatabase();
|
||||
} else {
|
||||
_database = database;
|
||||
}
|
||||
|
||||
if (cache == null) {
|
||||
initializeCache();
|
||||
} else {
|
||||
_cache = cache;
|
||||
}
|
||||
}
|
||||
|
||||
Database? _database;
|
||||
Database? _cache;
|
||||
List<int>? _idsOfCommentsRepliedToMe;
|
||||
|
||||
static const String _cachedCommentsKey = 'cachedComments';
|
||||
static const String _commentsKey = 'comments';
|
||||
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
|
||||
static const String _metadataCacheKey = 'metadata';
|
||||
|
||||
Future<Database> initializeDatabase() async {
|
||||
final Directory dir = await getApplicationDocumentsDirectory();
|
||||
final Directory dir = await getApplicationCacheDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final String dbPath = join(dir.path, 'hacki.db');
|
||||
final DatabaseFactory dbFactory = databaseFactoryIo;
|
||||
@ -37,6 +50,16 @@ class SembastRepository {
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<Database> initializeCache() async {
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
await tempDir.create(recursive: true);
|
||||
final String dbPath = join(tempDir.path, 'hacki_cache.db');
|
||||
final DatabaseFactory dbFactory = databaseFactoryIo;
|
||||
final Database db = await dbFactory.openDatabase(dbPath);
|
||||
_cache = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
//#region Cached comments for time machine feature.
|
||||
Future<Map<String, Object?>> cacheComment(Comment comment) async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
@ -177,10 +200,55 @@ class SembastRepository {
|
||||
|
||||
//#endregion
|
||||
|
||||
Future<FileSystemEntity> deleteAll() async {
|
||||
//#region
|
||||
|
||||
Future<void> cacheMetadata({
|
||||
required String key,
|
||||
required WebInfo info,
|
||||
}) async {
|
||||
final Database db = _cache ?? await initializeCache();
|
||||
final StoreRef<String, Map<String, Object?>> store =
|
||||
stringMapStoreFactory.store(_metadataCacheKey);
|
||||
return db.transaction((Transaction txn) async {
|
||||
await store.record(key).put(txn, info.toJson());
|
||||
});
|
||||
}
|
||||
|
||||
Future<WebInfo?> getCachedMetadata({
|
||||
required String key,
|
||||
}) async {
|
||||
final Database db = _cache ?? await initializeCache();
|
||||
final StoreRef<String, Map<String, Object?>> store =
|
||||
stringMapStoreFactory.store(_metadataCacheKey);
|
||||
final RecordSnapshot<String, Map<String, Object?>>? snapshot =
|
||||
await store.record(key).getSnapshot(db);
|
||||
if (snapshot != null) {
|
||||
final WebInfo info = WebInfo.fromJson(snapshot.value);
|
||||
return info;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
Future<void> deleteCachedComments() async {
|
||||
final Directory dir = await getApplicationDocumentsDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final String dbPath = join(dir.path, 'hacki.db');
|
||||
return File(dbPath).delete();
|
||||
final File file = File(dbPath);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteCachedMetadata() async {
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
await tempDir.create(recursive: true);
|
||||
final String cachePath = join(tempDir.path, 'hacki_cache.db');
|
||||
final File file = File(cachePath);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.didPopNext();
|
||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||
DeviceScreenType.mobile) {
|
||||
locator.get<Logger>().i('Resetting comments in CommentCache');
|
||||
locator.get<Logger>().i('resetting comments in CommentCache');
|
||||
Future<void>.delayed(
|
||||
AppDurations.ms500,
|
||||
locator.get<CommentCache>().resetComments,
|
||||
@ -123,9 +123,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
|
||||
BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
|
||||
previous.metadataEnabled != current.metadataEnabled ||
|
||||
previous.swipeGestureEnabled != current.swipeGestureEnabled,
|
||||
previous.isComplexStoryTileEnabled !=
|
||||
current.isComplexStoryTileEnabled ||
|
||||
previous.isMetadataEnabled != current.isMetadataEnabled ||
|
||||
previous.isSwipeGestureEnabled != current.isSwipeGestureEnabled,
|
||||
builder: (BuildContext context, PreferenceState preferenceState) {
|
||||
return DefaultTabController(
|
||||
length: tabLength,
|
||||
@ -150,7 +151,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
body: BlocBuilder<TabCubit, TabState>(
|
||||
builder: (BuildContext context, TabState state) {
|
||||
return TabBarView(
|
||||
physics: preferenceState.swipeGestureEnabled
|
||||
physics: preferenceState.isSwipeGestureEnabled
|
||||
? const PageScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
controller: tabController,
|
||||
@ -190,12 +191,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
void onStoryTapped(Story story) {
|
||||
final PreferenceState prefState = context.read<PreferenceCubit>().state;
|
||||
final bool useReader = prefState.readerEnabled;
|
||||
final bool useReader = prefState.isReaderEnabled;
|
||||
final StoryMarkingMode storyMarkingMode = prefState.storyMarkingMode;
|
||||
final bool offlineReading =
|
||||
context.read<StoriesBloc>().state.isOfflineReading;
|
||||
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
|
||||
final bool markReadStoriesEnabled = prefState.markReadStoriesEnabled;
|
||||
final bool markReadStoriesEnabled = prefState.isMarkReadStoriesEnabled;
|
||||
|
||||
// If a story is a job story and it has a link to the job posting,
|
||||
// it would be better to just navigate to the web page.
|
||||
|
@ -37,7 +37,7 @@ class PinnedStories extends StatelessWidget {
|
||||
},
|
||||
backgroundColor: Palette.red,
|
||||
foregroundColor: Palette.white,
|
||||
icon: preferenceState.complexStoryTileEnabled
|
||||
icon: preferenceState.isComplexStoryTileEnabled
|
||||
? Icons.close
|
||||
: null,
|
||||
label: 'Unpin',
|
||||
@ -51,9 +51,10 @@ class PinnedStories extends StatelessWidget {
|
||||
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
|
||||
story: story,
|
||||
onTap: () => onStoryTapped(story),
|
||||
showWebPreview: preferenceState.complexStoryTileEnabled,
|
||||
showMetadata: preferenceState.metadataEnabled,
|
||||
showUrl: preferenceState.urlEnabled,
|
||||
showWebPreview: preferenceState.isComplexStoryTileEnabled,
|
||||
showMetadata: preferenceState.isMetadataEnabled,
|
||||
showUrl: preferenceState.isUrlEnabled,
|
||||
showFavicon: preferenceState.isFaviconEnabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -69,6 +69,7 @@ class ItemScreen extends StatefulWidget {
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -79,6 +80,8 @@ class ItemScreen extends StatefulWidget {
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
useCommentCache: args.useCommentCache,
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -110,6 +113,7 @@ class ItemScreen extends StatefulWidget {
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -121,6 +125,8 @@ class ItemScreen extends StatefulWidget {
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/comments/comments_cubit.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
@ -65,9 +66,11 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
|
||||
super.initState();
|
||||
scrollController.addListener(onScroll);
|
||||
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
|
||||
if (textEditingController.text.isEmpty) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
Future<void>.delayed(AppDurations.ms300, () {
|
||||
if (textEditingController.text.isEmpty) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -53,6 +53,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
|
||||
controller: usernameController,
|
||||
cursorColor: Theme.of(context).colorScheme.primary,
|
||||
autocorrect: false,
|
||||
autofillHints: const <String>[AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Username',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
@ -75,6 +76,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
|
||||
cursorColor: Theme.of(context).colorScheme.primary,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
autofillHints: const <String>[AutofillHints.password],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Password',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
@ -182,7 +184,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
state.agreedToEULA
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Palette.grey,
|
||||
|
@ -59,7 +59,12 @@ class MainView extends StatelessWidget {
|
||||
if (context.read<StoriesBloc>().state.isOfflineReading ==
|
||||
false &&
|
||||
state.onlyShowTargetComment == false) {
|
||||
unawaited(context.read<CommentsCubit>().refresh());
|
||||
unawaited(
|
||||
context.read<CommentsCubit>().refresh(
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
);
|
||||
|
||||
if (state.item.isPoll) {
|
||||
context.read<PollCubit>().refresh();
|
||||
@ -145,27 +150,28 @@ class MainView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
height: Dimens.pt4,
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState prev, CommentsState current) =>
|
||||
prev.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return AnimatedOpacity(
|
||||
opacity: state.status == CommentsStatus.inProgress
|
||||
? NumSwitch.on
|
||||
: NumSwitch.off,
|
||||
duration: const Duration(
|
||||
milliseconds: _loadingIndicatorOpacityAnimationDuration,
|
||||
),
|
||||
child: const LinearProgressIndicator(),
|
||||
);
|
||||
},
|
||||
if (context.read<PreferenceCubit>().state.isDevModeEnabled)
|
||||
Positioned(
|
||||
height: Dimens.pt4,
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState prev, CommentsState current) =>
|
||||
prev.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return AnimatedOpacity(
|
||||
opacity: state.status == CommentsStatus.inProgress
|
||||
? NumSwitch.on
|
||||
: NumSwitch.off,
|
||||
duration: const Duration(
|
||||
milliseconds: _loadingIndicatorOpacityAnimationDuration,
|
||||
),
|
||||
child: const LinearProgressIndicator(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -249,9 +255,13 @@ class _ParentItemSection extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
item.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.displayDateFormat
|
||||
.convertToString(item.time),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
@ -279,7 +289,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
useReader: context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.readerEnabled,
|
||||
.isReaderEnabled,
|
||||
offlineReading: context
|
||||
.read<StoriesBloc>()
|
||||
.state
|
||||
@ -505,6 +515,9 @@ class _ParentItemSection extends StatelessWidget {
|
||||
style: TextStyle(color: Palette.grey),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 120,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
@ -5,11 +5,9 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/profile/models/models.dart';
|
||||
import 'package:hacki/screens/profile/widgets/widgets.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
@ -98,6 +96,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
return ItemsListView<Item>(
|
||||
showWebPreviewOnStoryTile: false,
|
||||
showMetadataOnStoryTile: false,
|
||||
showFavicon: false,
|
||||
showUrl: false,
|
||||
showAuthor: false,
|
||||
useSimpleTileForStory: true,
|
||||
@ -147,22 +146,28 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
builder: (
|
||||
BuildContext context,
|
||||
Status status,
|
||||
) =>
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<FavCubit>().merge();
|
||||
},
|
||||
child: status == Status.inProgress
|
||||
? const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
width: Dimens.pt12,
|
||||
child:
|
||||
CustomCircularProgressIndicator(
|
||||
strokeWidth: Dimens.pt2,
|
||||
),
|
||||
)
|
||||
: const Text('Sync from Hacker News'),
|
||||
),
|
||||
) {
|
||||
return TextButton(
|
||||
onPressed: () =>
|
||||
context.read<FavCubit>().merge(
|
||||
onError: (AppException e) =>
|
||||
showErrorSnackBar(e.message),
|
||||
onSuccess: () => showSnackBar(
|
||||
content: '''Sync completed.''',
|
||||
),
|
||||
),
|
||||
child: status == Status.inProgress
|
||||
? const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
width: Dimens.pt12,
|
||||
child:
|
||||
CustomCircularProgressIndicator(
|
||||
strokeWidth: Dimens.pt2,
|
||||
),
|
||||
)
|
||||
: const Text('Sync from Hacker News'),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
@ -186,21 +191,22 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
PreferenceState previous,
|
||||
PreferenceState current,
|
||||
) =>
|
||||
previous.complexStoryTileEnabled !=
|
||||
current.complexStoryTileEnabled ||
|
||||
previous.metadataEnabled !=
|
||||
current.metadataEnabled ||
|
||||
previous.urlEnabled != current.urlEnabled,
|
||||
previous.isComplexStoryTileEnabled !=
|
||||
current.isComplexStoryTileEnabled ||
|
||||
previous.isMetadataEnabled !=
|
||||
current.isMetadataEnabled ||
|
||||
previous.isUrlEnabled != current.isUrlEnabled,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
PreferenceState prefState,
|
||||
) {
|
||||
return ItemsListView<Item>(
|
||||
showWebPreviewOnStoryTile:
|
||||
prefState.complexStoryTileEnabled,
|
||||
prefState.isComplexStoryTileEnabled,
|
||||
showMetadataOnStoryTile:
|
||||
prefState.metadataEnabled,
|
||||
showUrl: prefState.urlEnabled,
|
||||
prefState.isMetadataEnabled,
|
||||
showFavicon: prefState.isFaviconEnabled,
|
||||
showUrl: prefState.isUrlEnabled,
|
||||
useSimpleTileForStory: true,
|
||||
refreshController: refreshControllerFav,
|
||||
items: favState.favItems,
|
||||
@ -411,27 +417,27 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
|
||||
void onCommentTapped(Comment comment, {VoidCallback? then}) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
.get<HackerNewsRepository>()
|
||||
.fetchParentStoryWithComments(id: comment.parent)
|
||||
.then(((Story, List<Comment>)? res) {
|
||||
if (res != null && mounted) {
|
||||
final Story parent = res.$1;
|
||||
final List<Comment> children = res.$2;
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: parent,
|
||||
targetComments: children.isEmpty
|
||||
? <Comment>[comment]
|
||||
: <Comment>[
|
||||
...children,
|
||||
comment.copyWith(level: children.length),
|
||||
],
|
||||
onlyShowTargetComment: true,
|
||||
),
|
||||
)?.then((_) => then?.call());
|
||||
}
|
||||
});
|
||||
context.read<NotificationCubit>().onCommentTapped(
|
||||
comment,
|
||||
then: ((Story, List<Comment>)? res) {
|
||||
if (res != null && mounted) {
|
||||
final Story parent = res.$1;
|
||||
final List<Comment> children = res.$2;
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: parent,
|
||||
targetComments: children.isEmpty
|
||||
? <Comment>[comment]
|
||||
: <Comment>[
|
||||
...children,
|
||||
comment.copyWith(level: children.length),
|
||||
],
|
||||
onlyShowTargetComment: true,
|
||||
),
|
||||
)?.then((_) => then?.call());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/utils/haptic_feedback_util.dart';
|
||||
|
||||
class EnterOfflineModeListTile extends StatelessWidget {
|
||||
const EnterOfflineModeListTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StoriesBloc, StoriesState>(
|
||||
buildWhen: (StoriesState previous, StoriesState current) =>
|
||||
previous.isOfflineReading != current.isOfflineReading,
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
return SwitchListTile(
|
||||
value: state.isOfflineReading,
|
||||
title: const Text('Offline Mode'),
|
||||
onChanged: (bool value) {
|
||||
HapticFeedbackUtil.light();
|
||||
context.read<StoriesBloc>().add(
|
||||
value ? StoriesEnterOfflineMode() : StoriesExitOfflineMode(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/cubits/notification/notification_cubit.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -69,64 +71,79 @@ class InboxView extends StatelessWidget {
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
...comments.map((Comment e) {
|
||||
final NotificationState state =
|
||||
context.read<NotificationCubit>().state;
|
||||
return <Widget>[
|
||||
FadeIn(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt6,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => onCommentTapped(e),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Dimens.pt8,
|
||||
horizontal: Dimens.pt6,
|
||||
Stack(
|
||||
children: <Widget>[
|
||||
if (state.commentFetchingStatus == Status.inProgress &&
|
||||
state.tappedCommentId == e.id)
|
||||
Positioned(
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
top: Dimens.zero,
|
||||
bottom: Dimens.zero,
|
||||
child: LinearProgressIndicator(
|
||||
color: Theme.of(context)
|
||||
.primaryColor
|
||||
.withOpacity(0.1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'''${e.timeAgo} from ${e.by}:''',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt12,
|
||||
),
|
||||
],
|
||||
),
|
||||
Linkify(
|
||||
text: e.text,
|
||||
style: TextStyle(
|
||||
color: unreadCommentsIds.contains(e.id)
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
: Theme.of(context).readGrey,
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(
|
||||
unreadCommentsIds.contains(e.id)
|
||||
? 1
|
||||
: 0.6,
|
||||
),
|
||||
FadeIn(
|
||||
child: InkWell(
|
||||
onTap: () => onCommentTapped(e),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Dimens.pt8,
|
||||
horizontal: Dimens.pt12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'''${e.timeAgo} from ${e.by}:''',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt12,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 4,
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(link.url, context),
|
||||
),
|
||||
],
|
||||
Linkify(
|
||||
text: e.text,
|
||||
style: TextStyle(
|
||||
color: unreadCommentsIds.contains(e.id)
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
: Theme.of(context).readGrey,
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(
|
||||
unreadCommentsIds.contains(e.id)
|
||||
? 1
|
||||
: 0.6,
|
||||
),
|
||||
),
|
||||
maxLines: 4,
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(link.url, context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
|
@ -91,8 +91,10 @@ class OfflineListTile extends StatelessWidget {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Connectivity().checkConnectivity().then((ConnectivityResult res) {
|
||||
if (res != ConnectivityResult.none) {
|
||||
Connectivity()
|
||||
.checkConnectivity()
|
||||
.then((List<ConnectivityResult> res) {
|
||||
if (!res.contains(ConnectivityResult.none)) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
|
@ -23,6 +23,7 @@ import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/profile/models/page_type.dart';
|
||||
import 'package:hacki/screens/profile/qr_code_scanner_screen.dart';
|
||||
import 'package:hacki/screens/profile/qr_code_view_screen.dart';
|
||||
import 'package:hacki/screens/profile/widgets/enter_offline_mode_list_tile.dart';
|
||||
import 'package:hacki/screens/profile/widgets/offline_list_tile.dart';
|
||||
import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
|
||||
import 'package:hacki/screens/profile/widgets/text_scale_factor_settings.dart';
|
||||
@ -78,6 +79,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
}
|
||||
},
|
||||
),
|
||||
const EnterOfflineModeListTile(),
|
||||
const OfflineListTile(),
|
||||
const SizedBox(
|
||||
height: Dimens.pt8,
|
||||
@ -160,13 +162,55 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt16,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'Date time display of comments',
|
||||
),
|
||||
DropdownMenu<DateDisplayFormat>(
|
||||
initialSelection: preferenceState.displayDateFormat,
|
||||
dropdownMenuEntries: DateDisplayFormat.values
|
||||
.map(
|
||||
(DateDisplayFormat val) =>
|
||||
DropdownMenuEntry<DateDisplayFormat>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (DateDisplayFormat? order) {
|
||||
if (order != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
DateFormatPreference(
|
||||
val: order.index,
|
||||
),
|
||||
);
|
||||
DateDisplayFormat.clearCache();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
),
|
||||
const TabBarSettings(),
|
||||
const TextScaleFactorSettings(),
|
||||
const Divider(),
|
||||
StoryTile(
|
||||
showWebPreview: preferenceState.complexStoryTileEnabled,
|
||||
showMetadata: preferenceState.metadataEnabled,
|
||||
showUrl: preferenceState.urlEnabled,
|
||||
showWebPreview: preferenceState.isComplexStoryTileEnabled,
|
||||
showMetadata: preferenceState.isMetadataEnabled,
|
||||
showUrl: preferenceState.isUrlEnabled,
|
||||
showFavicon: preferenceState.isFaviconEnabled,
|
||||
story: Story.placeholder(),
|
||||
onTap: () => LinkUtil.launch(
|
||||
Constants.guidelineLink,
|
||||
@ -211,7 +255,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
horizontal: Dimens.pt16,
|
||||
),
|
||||
child: DropdownMenu<StoryMarkingMode>(
|
||||
enabled: preferenceState.markReadStoriesEnabled,
|
||||
enabled: preferenceState.isMarkReadStoriesEnabled,
|
||||
label: Text(StoryMarkingModePreference().title),
|
||||
initialSelection: preferenceState.storyMarkingMode,
|
||||
onSelected: (StoryMarkingMode? storyMarkingMode) {
|
||||
@ -301,6 +345,17 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('nothing interesting here.'),
|
||||
onTap: showAboutHackiDialog,
|
||||
onLongPress: () {
|
||||
final DevMode updatedDevMode =
|
||||
DevMode(val: !preferenceState.isDevModeEnabled);
|
||||
context.read<PreferenceCubit>().update(updatedDevMode);
|
||||
HapticFeedbackUtil.heavy();
|
||||
if (updatedDevMode.val) {
|
||||
showSnackBar(content: 'You are a dev now.');
|
||||
} else {
|
||||
showSnackBar(content: 'Dev mode disabled');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt48,
|
||||
@ -498,6 +553,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
.whenComplete(
|
||||
DefaultCacheManager().emptyCache,
|
||||
)
|
||||
.whenComplete(
|
||||
locator.get<SembastRepository>().deleteCachedComments,
|
||||
)
|
||||
.whenComplete(
|
||||
locator.get<SembastRepository>().deleteCachedMetadata,
|
||||
)
|
||||
.whenComplete(() {
|
||||
showSnackBar(content: 'Cache cleared!');
|
||||
});
|
||||
@ -645,6 +706,9 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
actionsPadding: const EdgeInsets.all(
|
||||
Dimens.pt16,
|
||||
),
|
||||
actions: <Widget>[
|
||||
ElevatedButton(
|
||||
onPressed: onSendEmailTapped,
|
||||
|
@ -1,4 +1,5 @@
|
||||
export 'centered_message_view.dart';
|
||||
export 'enter_offline_mode_list_tile.dart';
|
||||
export 'inbox_view.dart';
|
||||
export 'offline_list_tile.dart';
|
||||
export 'settings.dart';
|
||||
|
@ -359,9 +359,11 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
||||
FadeIn(
|
||||
child: StoryTile(
|
||||
showWebPreview:
|
||||
prefState.complexStoryTileEnabled,
|
||||
showMetadata: prefState.metadataEnabled,
|
||||
showUrl: prefState.urlEnabled,
|
||||
prefState.isComplexStoryTileEnabled,
|
||||
showMetadata:
|
||||
prefState.isMetadataEnabled,
|
||||
showUrl: prefState.isUrlEnabled,
|
||||
showFavicon: prefState.isFaviconEnabled,
|
||||
story: e,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
@ -383,7 +385,7 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!prefState.complexStoryTileEnabled)
|
||||
if (!prefState.isComplexStoryTileEnabled)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
),
|
||||
|
@ -76,7 +76,7 @@ class PostedByFilterChip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(null),
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text(
|
||||
'Clear',
|
||||
),
|
||||
@ -87,7 +87,7 @@ class PostedByFilterChip extends StatelessWidget {
|
||||
context.pop(text.isEmpty ? null : text);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
@ -56,6 +56,14 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
// Don't show confirmation dialog if content is empty.
|
||||
if (state.text.isNullOrEmpty &&
|
||||
state.url.isNullOrEmpty &&
|
||||
state.title.isNullOrEmpty) {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@ -114,7 +122,36 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
Icons.send,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: context.read<SubmitCubit>().onSubmitTapped,
|
||||
onPressed: () {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Submit?'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => context.pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
style: TextStyle(
|
||||
color: Palette.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((bool? value) {
|
||||
if (value ?? false) {
|
||||
context.read<SubmitCubit>().submit();
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
|
@ -188,20 +188,22 @@ class CommentTile extends StatelessWidget {
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
if (!comment.dead && isNew)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4),
|
||||
child: Icon(
|
||||
Icons.sunny_snowing,
|
||||
size: 16,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
// Commented out for now, maybe review later.
|
||||
// if (!comment.dead && isNew)
|
||||
// const Padding(
|
||||
// padding: EdgeInsets.only(left: 4),
|
||||
// child: Icon(
|
||||
// Icons.sunny_snowing,
|
||||
// size: 16,
|
||||
// color: Palette.grey,
|
||||
// ),
|
||||
// ),
|
||||
const Spacer(),
|
||||
Text(
|
||||
comment.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
prefState.displayDateFormat
|
||||
.convertToString(comment.time),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
@ -306,7 +308,7 @@ class CommentTile extends StatelessWidget {
|
||||
final double commentBackgroundColorOpacity =
|
||||
Theme.of(context).canvasColor != Palette.white ? 0.03 : 0.15;
|
||||
|
||||
final Color commentColor = prefState.eyeCandyEnabled
|
||||
final Color commentColor = prefState.isEyeCandyEnabled
|
||||
? color.withOpacity(commentBackgroundColorOpacity)
|
||||
: Palette.transparent;
|
||||
final bool isMyComment = comment.deleted == false &&
|
||||
@ -402,7 +404,7 @@ class CommentTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
if (context.read<PreferenceCubit>().state.isTapAnywhereToCollapseEnabled) {
|
||||
_collapse(context);
|
||||
}
|
||||
}
|
||||
@ -412,7 +414,7 @@ class CommentTile extends StatelessWidget {
|
||||
final CollapseCubit collapseCubit = context.read<CollapseCubit>()
|
||||
..collapse(onStateChanged: HapticFeedbackUtil.selection);
|
||||
if (collapseCubit.state.collapsed &&
|
||||
preferenceCubit.state.autoScrollEnabled) {
|
||||
preferenceCubit.state.isAutoScrollEnabled) {
|
||||
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
|
||||
final List<Comment> comments = commentsCubit.state.comments;
|
||||
final int indexOfNextComment = comments.indexOf(comment) + 1;
|
||||
|
@ -18,13 +18,15 @@ class CustomChip extends StatelessWidget {
|
||||
return FilterChip(
|
||||
shadowColor: Palette.transparent,
|
||||
selectedShadowColor: Palette.transparent,
|
||||
backgroundColor: Palette.transparent,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
side: selected
|
||||
? BorderSide(color: Theme.of(context).colorScheme.primary)
|
||||
: BorderSide(color: Theme.of(context).colorScheme.onSurface),
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
checkmarkColor: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
selected: selected,
|
||||
|
@ -15,6 +15,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
const ItemsListView({
|
||||
required this.showWebPreviewOnStoryTile,
|
||||
required this.showMetadataOnStoryTile,
|
||||
required this.showFavicon,
|
||||
required this.showUrl,
|
||||
required this.items,
|
||||
required this.onTap,
|
||||
@ -40,6 +41,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
final bool useSimpleTileForStory;
|
||||
final bool showWebPreviewOnStoryTile;
|
||||
final bool showMetadataOnStoryTile;
|
||||
final bool showFavicon;
|
||||
final bool showUrl;
|
||||
final bool enablePullDown;
|
||||
final bool markReadStories;
|
||||
@ -74,7 +76,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
if (e is Story) {
|
||||
final bool hasRead = context.read<StoriesBloc>().hasRead(e);
|
||||
final bool swipeGestureEnabled =
|
||||
context.read<PreferenceCubit>().state.swipeGestureEnabled;
|
||||
context.read<PreferenceCubit>().state.isSwipeGestureEnabled;
|
||||
return <Widget>[
|
||||
if (useSimpleTileForStory)
|
||||
FadeIn(
|
||||
@ -97,8 +99,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
showAuthor
|
||||
? '''${e.timeAgo} by ${e.by}'''
|
||||
: e.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
@ -140,6 +142,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
showWebPreview: showWebPreviewOnStoryTile,
|
||||
showMetadata: showMetadataOnStoryTile,
|
||||
showUrl: showUrl,
|
||||
showFavicon: showFavicon,
|
||||
hasRead: markReadStories && hasRead,
|
||||
),
|
||||
),
|
||||
@ -147,6 +150,11 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
)
|
||||
else if (context.read<SplitViewCubit>().state.enabled)
|
||||
const Divider(
|
||||
height: Dimens.pt6,
|
||||
color: Palette.transparent,
|
||||
),
|
||||
];
|
||||
} else if (e is Comment) {
|
||||
@ -186,8 +194,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
showAuthor
|
||||
? '''${e.timeAgo} by ${e.by}'''
|
||||
: e.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
|
@ -145,6 +145,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
String? title = '',
|
||||
String? desc = '',
|
||||
String? imageUri = '',
|
||||
String? iconUri = '',
|
||||
bool isIcon = false,
|
||||
}) {
|
||||
return Container(
|
||||
@ -169,6 +170,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
title: widget.story.title,
|
||||
description: desc ?? title ?? 'no comment yet.',
|
||||
imageUri: imageUri,
|
||||
iconUri: iconUri,
|
||||
imagePath: Constants.hackerNewsLogoPath,
|
||||
onTap: widget.onTap,
|
||||
hasRead: widget.hasRead,
|
||||
@ -209,6 +211,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
title: _errorTitle,
|
||||
desc: _errorBody,
|
||||
imageUri: null,
|
||||
iconUri: null,
|
||||
)
|
||||
: _buildLinkContainer(
|
||||
context.storyTileHeight,
|
||||
@ -216,14 +219,8 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
desc: WebAnalyzer.isNotEmpty(info!.description)
|
||||
? info.description
|
||||
: _errorBody,
|
||||
imageUri: widget.showMultimedia
|
||||
? (WebAnalyzer.isNotEmpty(info.image)
|
||||
? info.image
|
||||
: WebAnalyzer.isNotEmpty(info.icon)
|
||||
? info.icon
|
||||
: null)
|
||||
: null,
|
||||
isIcon: !WebAnalyzer.isNotEmpty(info.image),
|
||||
imageUri: widget.showMultimedia ? info.image : null,
|
||||
iconUri: widget.showMultimedia ? info.icon : null,
|
||||
);
|
||||
|
||||
return AnimatedCrossFade(
|
||||
|
@ -3,7 +3,9 @@ import 'dart:math';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/widgets/tap_down_wrapper.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/link_util.dart';
|
||||
@ -23,6 +25,7 @@ class LinkView extends StatelessWidget {
|
||||
required this.bodyMaxLines,
|
||||
super.key,
|
||||
this.imageUri,
|
||||
this.iconUri,
|
||||
this.imagePath,
|
||||
this.showMultiMedia = true,
|
||||
this.bodyTextOverflow,
|
||||
@ -43,6 +46,7 @@ class LinkView extends StatelessWidget {
|
||||
final String title;
|
||||
final String description;
|
||||
final String? imageUri;
|
||||
final String? iconUri;
|
||||
final String? imagePath;
|
||||
final VoidCallback onTap;
|
||||
final bool showMultiMedia;
|
||||
@ -111,19 +115,37 @@ class LinkView extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutHeight,
|
||||
child: (imageUri?.isEmpty ?? true) && imagePath != null
|
||||
? Image.asset(
|
||||
imagePath!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUri!,
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
memCacheHeight: layoutHeight.toInt() * 4,
|
||||
cacheKey: imageUri,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const SizedBox.shrink(),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUri ?? '',
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
cacheKey: imageUri,
|
||||
errorWidget: (_, __, ___) {
|
||||
if (url.isEmpty) {
|
||||
return FadeIn(
|
||||
child: Center(
|
||||
child: _HackerNewsImage(
|
||||
height: layoutHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: Constants.favicon(url),
|
||||
fit: BoxFit.scaleDown,
|
||||
cacheKey: iconUri,
|
||||
errorWidget: (_, __, ___) {
|
||||
return const FadeIn(
|
||||
child: Icon(
|
||||
Icons.public,
|
||||
size: Dimens.pt20,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -151,3 +173,23 @@ class LinkView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HackerNewsImage extends StatelessWidget {
|
||||
const _HackerNewsImage({
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeIn(
|
||||
child: Image.asset(
|
||||
Constants.hackerNewsLogoPath,
|
||||
height: height,
|
||||
width: height,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,9 @@ class OfflineBanner extends StatelessWidget {
|
||||
},
|
||||
).then((bool? value) {
|
||||
if (value ?? false) {
|
||||
context.read<StoriesBloc>().add(StoriesExitOffline());
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesExitOfflineMode());
|
||||
context.read<AuthBloc>().add(AuthInitialize());
|
||||
context.read<PinCubit>().init();
|
||||
WebAnalyzer.cacheMap.clear();
|
||||
|
@ -17,7 +17,6 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
final Throttle throttle = Throttle(delay: _throttleDelay);
|
||||
|
||||
static const Duration _throttleDelay = AppDurations.ms100;
|
||||
static const double _screenshotHeight = 600;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -44,7 +43,7 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: SizedBox(
|
||||
height: _screenshotHeight,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: PageView(
|
||||
controller: pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
@ -69,7 +68,7 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: Dimens.pt40,
|
||||
bottom: MediaQuery.of(context).viewPadding.bottom,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: ElevatedButton(
|
||||
@ -115,16 +114,14 @@ class _PageViewChild extends StatelessWidget {
|
||||
final String path;
|
||||
final String description;
|
||||
|
||||
static const double _height = 500;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 8,
|
||||
elevation: Dimens.pt8,
|
||||
child: SizedBox(
|
||||
height: _height,
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Image.asset(path),
|
||||
),
|
||||
),
|
||||
|
@ -49,9 +49,11 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
|
||||
previous.metadataEnabled != current.metadataEnabled ||
|
||||
previous.manualPaginationEnabled != current.manualPaginationEnabled,
|
||||
previous.isComplexStoryTileEnabled !=
|
||||
current.isComplexStoryTileEnabled ||
|
||||
previous.isMetadataEnabled != current.isMetadataEnabled ||
|
||||
previous.isManualPaginationEnabled !=
|
||||
current.isManualPaginationEnabled,
|
||||
builder: (BuildContext context, PreferenceState preferenceState) {
|
||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||
listenWhen: (StoriesState previous, StoriesState current) =>
|
||||
@ -75,7 +77,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
current.statusByType[widget.storyType]),
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
bool shouldShowLoadButton() {
|
||||
return preferenceState.manualPaginationEnabled &&
|
||||
return preferenceState.isManualPaginationEnabled &&
|
||||
state.statusByType[widget.storyType] == Status.success &&
|
||||
(state.storiesByType[widget.storyType]?.length ?? 0) <
|
||||
(state.storyIdsByType[widget.storyType]?.length ?? 0);
|
||||
@ -83,11 +85,12 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
|
||||
return ItemsListView<Story>(
|
||||
showOfflineBanner: true,
|
||||
markReadStories: preferenceState.markReadStoriesEnabled,
|
||||
markReadStories: preferenceState.isMarkReadStoriesEnabled,
|
||||
showWebPreviewOnStoryTile:
|
||||
preferenceState.complexStoryTileEnabled,
|
||||
showMetadataOnStoryTile: preferenceState.metadataEnabled,
|
||||
showUrl: preferenceState.urlEnabled,
|
||||
preferenceState.isComplexStoryTileEnabled,
|
||||
showMetadataOnStoryTile: preferenceState.isMetadataEnabled,
|
||||
showFavicon: preferenceState.isFaviconEnabled,
|
||||
showUrl: preferenceState.isUrlEnabled,
|
||||
refreshController: refreshController,
|
||||
scrollController: scrollController,
|
||||
items: state.storiesByType[storyType]!,
|
||||
@ -99,7 +102,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
context.read<PinCubit>().refresh();
|
||||
},
|
||||
onLoadMore: () {
|
||||
if (preferenceState.manualPaginationEnabled) {
|
||||
if (preferenceState.isManualPaginationEnabled) {
|
||||
refreshController
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
@ -128,10 +131,10 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
child: OutlinedButton(
|
||||
onPressed: loadMoreStories,
|
||||
style: ButtonStyle(
|
||||
minimumSize: MaterialStateProperty.all(
|
||||
minimumSize: WidgetStateProperty.all(
|
||||
const Size(double.infinity, Dimens.pt48),
|
||||
),
|
||||
foregroundColor: MaterialStateColor.resolveWith(
|
||||
foregroundColor: WidgetStateColor.resolveWith(
|
||||
(_) => Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@ -147,7 +150,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
itemBuilder: (Widget child, Story story) {
|
||||
return Slidable(
|
||||
key: ValueKey<Story>(story),
|
||||
enabled: !preferenceState.swipeGestureEnabled,
|
||||
enabled: !preferenceState.isSwipeGestureEnabled,
|
||||
startActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: <Widget>[
|
||||
@ -159,10 +162,10 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
icon: preferenceState.complexStoryTileEnabled
|
||||
icon: preferenceState.isComplexStoryTileEnabled
|
||||
? Icons.push_pin_outlined
|
||||
: null,
|
||||
label: preferenceState.complexStoryTileEnabled
|
||||
label: preferenceState.isComplexStoryTileEnabled
|
||||
? null
|
||||
: 'Pin',
|
||||
),
|
||||
@ -171,10 +174,10 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
icon: preferenceState.complexStoryTileEnabled
|
||||
icon: preferenceState.isComplexStoryTileEnabled
|
||||
? Icons.more_horiz
|
||||
: null,
|
||||
label: preferenceState.complexStoryTileEnabled
|
||||
label: preferenceState.isComplexStoryTileEnabled
|
||||
? null
|
||||
: 'More',
|
||||
),
|
||||
@ -193,9 +196,10 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) => mark(story),
|
||||
backgroundColor: preferenceState.markReadStoriesEnabled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Palette.grey,
|
||||
backgroundColor:
|
||||
preferenceState.isMarkReadStoriesEnabled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Palette.grey,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
icon: state.readStoriesIds.contains(story.id)
|
||||
@ -241,7 +245,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
HapticFeedbackUtil.light();
|
||||
final StoriesBloc storiesBloc = context.read<StoriesBloc>();
|
||||
final bool markReadStoriesEnabled =
|
||||
context.read<PreferenceCubit>().state.markReadStoriesEnabled;
|
||||
context.read<PreferenceCubit>().state.isMarkReadStoriesEnabled;
|
||||
if (markReadStoriesEnabled) {
|
||||
if (storiesBloc.state.readStoriesIds.contains(story.id)) {
|
||||
storiesBloc.add(StoryUnread(story: story));
|
||||
|
@ -1,18 +1,22 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class StoryTile extends StatelessWidget {
|
||||
const StoryTile({
|
||||
required this.showWebPreview,
|
||||
required this.showMetadata,
|
||||
required this.showFavicon,
|
||||
required this.showUrl,
|
||||
required this.story,
|
||||
required this.onTap,
|
||||
@ -23,6 +27,7 @@ class StoryTile extends StatelessWidget {
|
||||
|
||||
final bool showWebPreview;
|
||||
final bool showMetadata;
|
||||
final bool showFavicon;
|
||||
final bool showUrl;
|
||||
final bool hasRead;
|
||||
final Story story;
|
||||
@ -123,71 +128,125 @@ class StoryTile extends StatelessWidget {
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: () {
|
||||
if (story.url.isNotEmpty) {
|
||||
LinkUtil.launch(
|
||||
story.url,
|
||||
context,
|
||||
useReader:
|
||||
context.read<PreferenceCubit>().state.isReaderEnabled,
|
||||
offlineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: Dimens.pt12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
height: Dimens.pt8,
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: story.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: hasRead
|
||||
? Theme.of(context).readGrey
|
||||
: null,
|
||||
fontWeight:
|
||||
hasRead ? null : FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (showUrl && story.url.isNotEmpty)
|
||||
if (showFavicon) ...<Widget>[
|
||||
if (story.url.isNotEmpty)
|
||||
SizedBox(
|
||||
height: Dimens.pt20,
|
||||
width: Dimens.pt24,
|
||||
child: Center(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitHeight,
|
||||
imageUrl: Constants.favicon(story.url),
|
||||
errorWidget: (_, __, ___) {
|
||||
return const Icon(
|
||||
Icons.public,
|
||||
size: Dimens.pt20,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
height: Dimens.pt20,
|
||||
width: Dimens.pt24,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.public,
|
||||
size: Dimens.pt20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
height: Dimens.pt8,
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: ' (${story.readableUrl})',
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: story.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: hasRead
|
||||
? Theme.of(context).readGrey
|
||||
: null,
|
||||
fontWeight:
|
||||
hasRead ? null : FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (showUrl && story.url.isNotEmpty)
|
||||
TextSpan(
|
||||
text: ' (${story.readableUrl})',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: hasRead
|
||||
? Theme.of(context).readGrey
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showMetadata)
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
story.metadata,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: hasRead
|
||||
? Theme.of(context).readGrey
|
||||
: null,
|
||||
: Theme.of(context).metadataColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showMetadata)
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
story.metadata,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasRead
|
||||
? Theme.of(context).readGrey
|
||||
: Theme.of(context).metadataColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt14,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt8,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -14,9 +14,9 @@ import 'package:html/dom.dart' hide Comment, Text;
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
abstract class InfoBase {
|
||||
late DateTime _timeout;
|
||||
late bool _shouldRetry;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
@ -97,13 +97,10 @@ class WebAnalyzer {
|
||||
/// Get web information
|
||||
/// return [InfoBase]
|
||||
static InfoBase? getInfoFromCache(String? cacheKey) {
|
||||
if (cacheKey == null) return null;
|
||||
|
||||
final InfoBase? info = cacheMap[cacheKey];
|
||||
|
||||
if (info != null) {
|
||||
if (!info._timeout.isAfter(DateTime.now())) {
|
||||
cacheMap.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
@ -118,23 +115,31 @@ class WebAnalyzer {
|
||||
final String key = getKey(story);
|
||||
final String url = story.url;
|
||||
|
||||
/// [1] Try to fetch from mem cache.
|
||||
InfoBase? info = getInfoFromCache(key);
|
||||
|
||||
if (info != null) return info;
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
fetched mem cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [2] If story doesn't have a url and text is not empty,
|
||||
/// just use story title and text.
|
||||
if (story.url.isEmpty && story.text.isNotEmpty) {
|
||||
info = WebInfo(
|
||||
title: story.title,
|
||||
description: story.text,
|
||||
)
|
||||
.._timeout = DateTime.now().add(cache)
|
||||
.._shouldRetry = false;
|
||||
).._shouldRetry = false;
|
||||
|
||||
cacheMap[key] = info;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [3] If in offline mode, use comment text for description.
|
||||
if (offlineReading) {
|
||||
int index = 0;
|
||||
Comment? comment;
|
||||
@ -149,9 +154,7 @@ class WebAnalyzer {
|
||||
info = WebInfo(
|
||||
title: story.title,
|
||||
description: comment != null ? '${comment.by}: ${comment.text}' : null,
|
||||
)
|
||||
.._shouldRetry = false
|
||||
.._timeout = DateTime.now();
|
||||
).._shouldRetry = false;
|
||||
|
||||
cacheMap[key] = info;
|
||||
|
||||
@ -159,15 +162,41 @@ class WebAnalyzer {
|
||||
}
|
||||
|
||||
try {
|
||||
/// [4] Try to fetch from file cache.
|
||||
info = await locator.get<SembastRepository>().getCachedMetadata(key: key);
|
||||
|
||||
/// [5] If there is file cache, move it to mem cache for later retrieval.
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
fetched file cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
cacheMap[key] = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [6] Try to analyze the web for metadata.
|
||||
info = await _getInfoByIsolate(
|
||||
url: url,
|
||||
multimedia: multimedia,
|
||||
story: story,
|
||||
);
|
||||
|
||||
/// [7] If web analyzing was successful, cache it in both mem and file.
|
||||
if (info != null && !info._shouldRetry) {
|
||||
info._timeout = DateTime.now().add(cache);
|
||||
cacheMap[key] = info;
|
||||
|
||||
if (info is WebInfo) {
|
||||
locator
|
||||
.get<Logger>()
|
||||
.d('caching metadata using key $key for $story.');
|
||||
unawaited(
|
||||
locator.get<SembastRepository>().cacheMetadata(
|
||||
key: key,
|
||||
info: info,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
@ -175,9 +204,7 @@ class WebAnalyzer {
|
||||
return WebInfo(
|
||||
title: story.title,
|
||||
description: story.text,
|
||||
)
|
||||
.._shouldRetry = true
|
||||
.._timeout = DateTime.now();
|
||||
).._shouldRetry = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,23 +232,20 @@ class WebAnalyzer {
|
||||
required Story story,
|
||||
String? url,
|
||||
}) async {
|
||||
List<dynamic>? res;
|
||||
|
||||
if (url != null) {
|
||||
res = await compute(
|
||||
_fetchInfoFromUrl,
|
||||
<dynamic>[url, multimedia],
|
||||
);
|
||||
}
|
||||
if (url == null) return null;
|
||||
final List<dynamic>? res = await compute(
|
||||
_fetchInfoFromUrl,
|
||||
<dynamic>[url, multimedia],
|
||||
);
|
||||
|
||||
late final bool shouldRetry;
|
||||
InfoBase? info;
|
||||
String? fallbackDescription;
|
||||
|
||||
// If description is empty, use one of the comments under the story.
|
||||
if (res == null || isEmpty(res[2] as String?)) {
|
||||
final List<int> ids = <int>[story.id, ...story.kids];
|
||||
final String? commentText = await _fetchInfoFromStory(ids);
|
||||
|
||||
shouldRetry = commentText == null;
|
||||
fallbackDescription = commentText ?? 'no comment yet';
|
||||
} else {
|
||||
@ -252,7 +276,6 @@ class WebAnalyzer {
|
||||
description: fallbackDescription,
|
||||
).._shouldRetry = shouldRetry;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
@ -264,7 +287,6 @@ class WebAnalyzer {
|
||||
final bool multimedia = message[1] as bool;
|
||||
|
||||
final InfoBase? info = await _getInfo(url, multimedia);
|
||||
|
||||
if (info is WebInfo) {
|
||||
return <dynamic>[
|
||||
'0',
|
||||
@ -392,15 +414,12 @@ class WebAnalyzer {
|
||||
} catch (e) {
|
||||
try {
|
||||
html = gbk.decode(response.bodyBytes);
|
||||
} catch (e) {
|
||||
// locator.get<Logger>().log(
|
||||
// Level.error,
|
||||
// 'Web page resolution failure from:$url Error:$e',
|
||||
// );
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (html == null) return null;
|
||||
if (html == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String headHtml = _getHeadHtml(html);
|
||||
final Document document = parser.parse(headHtml);
|
||||
@ -417,7 +436,7 @@ class WebAnalyzer {
|
||||
|
||||
final WebInfo info = WebInfo(
|
||||
title: _analyzeTitle(document),
|
||||
icon: _analyzeIcon(document, uri),
|
||||
icon: await _analyzeIcon(document, uri),
|
||||
description: _analyzeDescription(document, html),
|
||||
image: _analyzeImage(document, uri),
|
||||
);
|
||||
@ -504,7 +523,7 @@ class WebAnalyzer {
|
||||
return description;
|
||||
}
|
||||
|
||||
static String? _analyzeIcon(Document document, Uri uri) {
|
||||
static Future<String?> _analyzeIcon(Document document, Uri uri) async {
|
||||
final List<Element> meta = document.head!.getElementsByTagName('link');
|
||||
String? icon = '';
|
||||
// get icon first
|
||||
@ -533,7 +552,7 @@ class WebAnalyzer {
|
||||
if (metaIcon != null) {
|
||||
icon = metaIcon.attributes['href'];
|
||||
} else {
|
||||
return '${uri.origin}/favicon.ico';
|
||||
return null;
|
||||
}
|
||||
|
||||
return _handleUrl(uri, icon);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ThemeDataExtension on ThemeData {
|
||||
Color get readGrey => colorScheme.onSurface.withOpacity(0.4);
|
||||
Color get readGrey => colorScheme.onSurface.withOpacity(0.6);
|
||||
|
||||
Color get metadataColor => colorScheme.onSurface.withOpacity(0.6);
|
||||
Color get metadataColor => colorScheme.onSurface.withOpacity(0.8);
|
||||
}
|
||||
|
46
lib/utils/dio_interceptors/cache_interceptor.dart
Normal file
46
lib/utils/dio_interceptors/cache_interceptor.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/dio/cached_response.dart';
|
||||
|
||||
class CacheInterceptor extends InterceptorsWrapper {
|
||||
CacheInterceptor()
|
||||
: super(
|
||||
onResponse: (
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
final String key = response.requestOptions.uri.toString();
|
||||
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
final CachedResponse<dynamic> cachedResponse =
|
||||
CachedResponse<dynamic>.fromResponse(response);
|
||||
_cache[key] = cachedResponse;
|
||||
}
|
||||
|
||||
return handler.next(response);
|
||||
},
|
||||
onRequest: (
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final String key = options.uri.toString();
|
||||
final CachedResponse<dynamic>? cachedResponse = _cache[key];
|
||||
|
||||
if (cachedResponse != null &&
|
||||
DateTime.now()
|
||||
.difference(cachedResponse.setDateTime)
|
||||
.inSeconds <
|
||||
_delay.inSeconds) {
|
||||
return handler.resolve(cachedResponse);
|
||||
}
|
||||
|
||||
return handler.next(options);
|
||||
},
|
||||
);
|
||||
|
||||
static const Duration _delay = AppDurations.oneMinute;
|
||||
static final Map<String, CachedResponse<dynamic>> _cache =
|
||||
<String, CachedResponse<dynamic>>{};
|
||||
}
|
2
lib/utils/dio_interceptors/interceptors.dart
Normal file
2
lib/utils/dio_interceptors/interceptors.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'cache_interceptor.dart';
|
||||
export 'logger_interceptor.dart';
|
14
lib/utils/dio_interceptors/logger_interceptor.dart
Normal file
14
lib/utils/dio_interceptors/logger_interceptor.dart
Normal file
@ -0,0 +1,14 @@
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
|
||||
class LoggerInterceptor extends PrettyDioLogger {
|
||||
LoggerInterceptor()
|
||||
: super(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: false,
|
||||
responseHeader: true,
|
||||
error: true,
|
||||
compact: true,
|
||||
maxWidth: 90,
|
||||
);
|
||||
}
|
@ -14,4 +14,10 @@ abstract class HapticFeedbackUtil {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
static void heavy() {
|
||||
if (enabled) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -73,22 +73,22 @@ abstract class LinkUtil {
|
||||
if (val) {
|
||||
if (link.contains('http')) {
|
||||
if (Platform.isAndroid &&
|
||||
context.read<PreferenceCubit>().state.customTabEnabled == false) {
|
||||
context.read<PreferenceCubit>().state.isCustomTabEnabled ==
|
||||
false) {
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
final Color primaryColor = Theme.of(context).colorScheme.primary;
|
||||
_browser
|
||||
.open(
|
||||
url: uri,
|
||||
options: ChromeSafariBrowserClassOptions(
|
||||
ios: IOSSafariOptions(
|
||||
entersReaderIfAvailable: useReader,
|
||||
preferredControlTintColor: primaryColor,
|
||||
),
|
||||
android: AndroidChromeCustomTabsOptions(
|
||||
toolbarBackgroundColor: primaryColor,
|
||||
),
|
||||
),
|
||||
url: WebUri.uri(uri),
|
||||
settings: Platform.isAndroid
|
||||
? ChromeSafariBrowserSettings(
|
||||
toolbarBackgroundColor: primaryColor,
|
||||
)
|
||||
: ChromeSafariBrowserSettings(
|
||||
entersReaderIfAvailable: useReader,
|
||||
preferredControlTintColor: primaryColor,
|
||||
),
|
||||
)
|
||||
.onError((_, __) => launchUrl(uri));
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
class ServiceException implements Exception {
|
||||
ServiceException([this.message]);
|
||||
|
||||
final String? message;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
String result = 'ServiceException';
|
||||
if (message != null) {
|
||||
result = '$result: $message';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
export 'debouncer.dart';
|
||||
export 'dio_interceptors/interceptors.dart';
|
||||
export 'haptic_feedback_util.dart';
|
||||
export 'html_util.dart';
|
||||
export 'link_util.dart';
|
||||
export 'linkifier_util.dart';
|
||||
export 'log_util.dart';
|
||||
export 'service_exception.dart';
|
||||
export 'theme_util.dart';
|
||||
export 'throttle.dart';
|
||||
|
513
pubspec.lock
513
pubspec.lock
File diff suppressed because it is too large
Load Diff
73
pubspec.yaml
73
pubspec.yaml
@ -1,85 +1,89 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 2.5.0+134
|
||||
version: 2.8.0+145
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: "3.16.2"
|
||||
flutter: "3.19.4"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.2.0
|
||||
animations: ^2.0.8
|
||||
badges: ^3.0.2
|
||||
bloc: ^8.1.1
|
||||
bloc_concurrency: ^0.2.2
|
||||
bloc_concurrency: ^0.2.5
|
||||
cached_network_image: ^3.3.0
|
||||
clipboard: ^0.1.3
|
||||
collection: ^1.17.1
|
||||
connectivity_plus: ^5.0.1
|
||||
device_info_plus: ^9.1.0
|
||||
dio: ^5.0.3
|
||||
connectivity_plus: ^6.0.3
|
||||
device_info_plus: ^10.1.0
|
||||
dio: ^5.4.3+1
|
||||
equatable: ^2.0.5
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery:
|
||||
git:
|
||||
url: https://github.com/livinglist/feature_discovery
|
||||
ref: flutter3_compatibility
|
||||
ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.2
|
||||
flutter_cache_manager: ^3.3.0
|
||||
flutter_email_sender: ^6.0.1
|
||||
flutter_bloc: ^8.1.5
|
||||
flutter_cache_manager: ^3.3.2
|
||||
flutter_email_sender: ^6.0.3
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_inappwebview: ^5.8.0
|
||||
flutter_local_notifications: ^16.1.0
|
||||
flutter_inappwebview: ^6.0.0
|
||||
flutter_local_notifications: ^17.1.2
|
||||
flutter_material_color_picker: ^1.2.0
|
||||
flutter_secure_storage: ^9.0.0
|
||||
flutter_siri_suggestions: ^2.1.0
|
||||
flutter_secure_storage: ^9.2.2
|
||||
flutter_siri_suggestions:
|
||||
git:
|
||||
url: https://github.com/Livinglist/flutter_siri_suggestions
|
||||
ref: master
|
||||
flutter_slidable: ^3.0.0
|
||||
font_awesome_flutter: ^10.3.0
|
||||
get_it: ^7.2.0
|
||||
go_router: ^12.1.1
|
||||
get_it: ^7.7.0
|
||||
go_router: ^14.1.4
|
||||
hive: ^2.2.3
|
||||
html: ^0.15.1
|
||||
html_unescape: ^2.0.0
|
||||
http: ^1.1.0
|
||||
hydrated_bloc: ^9.1.0
|
||||
hydrated_bloc: ^9.1.5
|
||||
in_app_review:
|
||||
path: components/in_app_review
|
||||
intl: ^0.18.0
|
||||
intl: ^0.19.0
|
||||
linkify: ^5.0.0
|
||||
logger: ^2.0.1
|
||||
logger: ^2.3.0
|
||||
memoize: ^3.0.0
|
||||
package_info_plus: ^4.0.0
|
||||
package_info_plus: ^8.0.0
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.12
|
||||
path_provider_android: ^2.0.22
|
||||
path_provider_foundation: ^2.1.1
|
||||
path_provider: ^2.1.3
|
||||
path_provider_android: ^2.2.5
|
||||
path_provider_foundation: ^2.4.0
|
||||
pretty_dio_logger: ^1.3.1
|
||||
pull_to_refresh:
|
||||
git:
|
||||
url: https://github.com/livinglist/flutter_pulltorefresh
|
||||
ref: master
|
||||
qr_code_scanner: ^1.0.1
|
||||
qr_flutter: ^4.1.0
|
||||
receive_sharing_intent: ^1.4.5
|
||||
receive_sharing_intent: 1.5.4
|
||||
responsive_builder: ^0.7.0
|
||||
rxdart: ^0.27.7
|
||||
scrollable_positioned_list: ^0.3.5
|
||||
sembast: ^3.5.0+1
|
||||
share_plus: ^7.2.1
|
||||
shared_preferences: ^2.2.2
|
||||
shared_preferences_android: ^2.2.1
|
||||
shared_preferences_foundation: ^2.3.4
|
||||
sembast: ^3.7.1
|
||||
share_plus: ^9.0.0
|
||||
shared_preferences: ^2.2.3
|
||||
shared_preferences_android: ^2.2.3
|
||||
shared_preferences_foundation: ^2.4.0
|
||||
shimmer: ^3.0.0
|
||||
synced_shared_preferences:
|
||||
path: components/synced_shared_preferences
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.2.1
|
||||
universal_platform: ^1.1.0
|
||||
url_launcher: ^6.2.6
|
||||
visibility_detector: ^0.4.0+2
|
||||
wakelock: ^0.6.2
|
||||
webview_flutter: ^4.4.1
|
||||
webview_flutter: ^4.8.0
|
||||
workmanager: ^0.5.1
|
||||
|
||||
dependency_overrides:
|
||||
@ -129,5 +133,10 @@ flutter:
|
||||
- asset: assets/fonts/exo_2/Exo2-Regular.ttf
|
||||
- asset: assets/fonts/exo_2/Exo2-Bold.ttf
|
||||
weight: 700
|
||||
- family: AtkinsonHyperlegible
|
||||
fonts:
|
||||
- asset: assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Regular.ttf
|
||||
- asset: assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
|
||||
|
10
scripts/analysis_options.yaml
Normal file
10
scripts/analysis_options.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
include: package:very_good_analysis/analysis_options.5.0.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
public_member_api_docs: false
|
||||
library_private_types_in_public_api: false
|
||||
omit_local_variable_types: false
|
||||
one_member_abstracts: false
|
||||
always_specify_types: true
|
||||
avoid_print: false
|
133
scripts/bin/parser_verifier.dart
Normal file
133
scripts/bin/parser_verifier.dart
Normal file
@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:args/args.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:html/dom.dart' hide Comment;
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
/// Get the GitHub token from args for so that we can create issues if
|
||||
/// anything doesn't go as expected.
|
||||
final ArgParser parser = ArgParser()
|
||||
..addFlag('github-token', negatable: false, abbr: 't');
|
||||
final ArgResults argResults = parser.parse(arguments);
|
||||
final String token = argResults.rest.first;
|
||||
|
||||
/// The expected parser result.
|
||||
const String text = '''
|
||||
What does it say about the world we live in where blogs do more basic journalism than CNN? All that one would have had to do is read the report actually provided.
|
||||
|
||||
I don't think I'm being too extreme when I say that, apart from maybe PBS, there is no reputable source of news in America. If you don't believe me, pick a random story, watch it as it gets rewritten a million times through Reuters, then check back on the facts of the story one year later. A news story gets twisted to promote some narrative that will sell papers, and when the facts of the story are finally verified (usually not by the news themselves, but lawyers or courts or whoever), the story is dropped and never reported on again.
|
||||
|
||||
Again, if the only thing a reporter had to do was read the report to find the facts of the case to verify what is and isn't true, what the fuck is even the point of a news agency?''';
|
||||
|
||||
/// Get HTML of the thread.
|
||||
const String itemBaseUrl = 'https://news.ycombinator.com/item?id=';
|
||||
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',
|
||||
};
|
||||
const int itemId = 11536543;
|
||||
final Dio dio = Dio();
|
||||
final Uri url = Uri.parse('$itemBaseUrl$itemId');
|
||||
final Options option = Options(headers: headers, persistentConnection: true);
|
||||
final Response<String> response =
|
||||
await dio.getUri<String>(url, options: option);
|
||||
|
||||
/// Parse the HTML and select all the comment elements.
|
||||
final String data = response.data ?? '';
|
||||
final Document document = parse(data);
|
||||
const String athingComtrSelector =
|
||||
'#hnmain > tbody > tr > td > table > tbody > .athing.comtr';
|
||||
final List<Element> elements = document.querySelectorAll(athingComtrSelector);
|
||||
|
||||
/// Verify comment text parser using the first comment element.
|
||||
if (elements.isNotEmpty) {
|
||||
final Element e = elements.first;
|
||||
const String commentTextSelector =
|
||||
'''td > table > tbody > tr > td.default > div.comment > div.commtext''';
|
||||
final Element? cmtTextElement = e.querySelector(commentTextSelector);
|
||||
final String parsedText =
|
||||
await parseCommentTextHtml(cmtTextElement?.innerHtml ?? '');
|
||||
|
||||
if (parsedText != text) {
|
||||
final Uri url =
|
||||
Uri.parse('https://api.github.com/repos/livinglist/hacki/issues');
|
||||
const String issueTitle = 'Parser check failed.';
|
||||
|
||||
/// Check if an issue with same title already exists.
|
||||
final Response<String> response = await dio.getUri<String>(url);
|
||||
if (response.data?.contains(issueTitle) ?? false) {
|
||||
print('Issue already exists.');
|
||||
return;
|
||||
} else {
|
||||
print('Diff detected, creating issue...');
|
||||
|
||||
/// Create the issue if one does not exist.
|
||||
final Map<String, String> githubHeaders = <String, String>{
|
||||
'Authorization': 'Bearer $token',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final Map<String, dynamic> githubIssuePayload = <String, dynamic>{
|
||||
'assignees': <String>['livinglist'],
|
||||
'title': issueTitle,
|
||||
'body': '''
|
||||
| Expected | Actual |
|
||||
| ------------- | ------------- |
|
||||
| ${text.replaceAll('\n', '<br>')} | ${parsedText.replaceAll('\n', '<br>')} |''',
|
||||
};
|
||||
await dio.postUri<String>(
|
||||
url,
|
||||
data: githubIssuePayload,
|
||||
options: Options(
|
||||
headers: githubHeaders,
|
||||
),
|
||||
);
|
||||
print('Issue created.');
|
||||
}
|
||||
} else {
|
||||
print('Expected:\n$text\n');
|
||||
print('Actual:\n$parsedText\n');
|
||||
}
|
||||
} else {
|
||||
throw Exception('No comment from Hacker News.');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
125
scripts/pubspec.lock
Normal file
125
scripts/pubspec.lock
Normal file
@ -0,0 +1,125 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: args
|
||||
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.3+1"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.4"
|
||||
html_unescape:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html_unescape
|
||||
sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
very_good_analysis:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: very_good_analysis
|
||||
sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
sdks:
|
||||
dart: ">=3.0.0 <4.0.0"
|
13
scripts/pubspec.yaml
Normal file
13
scripts/pubspec.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
name: parser_verifier
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
args: ^2.5.0
|
||||
dio: ^5.0.3
|
||||
html: ^0.15.1
|
||||
html_unescape: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
very_good_analysis: ^5.0.0
|
Submodule submodules/flutter updated: 9e1c857886...a14f74ff3a
Reference in New Issue
Block a user