Compare commits

..

58 Commits

Author SHA1 Message Date
ab43d1a2c4 remove unused package. (#425) 2024-06-04 19:30:35 -07:00
cf5c0b3263 fix favicon. (#424) 2024-06-03 12:45:14 -07:00
d7295afa41 improve story tile with favicon. (#423) 2024-06-03 11:17:43 -07:00
1ecddf9d5b update stories_bloc.dart (#422) 2024-06-03 09:52:29 -07:00
479903ed77 improve loading speed. (#421) 2024-06-03 01:39:04 -07:00
1e4c10e819 improve loading performance. (#420) 2024-06-02 23:33:22 -07:00
473a65427a fix iOS build. (#419) 2024-06-02 17:25:52 -07:00
ad6ccc9376 fix favicon. (#418) 2024-06-02 17:08:42 -07:00
995dfed85d add favicon to story tile. (#417) 2024-06-02 12:13:47 -07:00
0e74f88a8d fix thread loading. (#416) 2024-06-02 00:52:00 -07:00
c2e6d7ea98 improve offline mode. (#415) 2024-06-01 22:49:41 -07:00
e46432b86c improve loading speed. (#414) 2024-06-01 19:40:37 -07:00
9763a94e1d add remote config. (#413) 2024-06-01 11:15:36 -07:00
077fcbf9da fix duplicate fav. (#411) 2024-05-31 18:59:05 -07:00
9cdb6b7383 fix true dark mode. (#412) 2024-05-31 18:55:07 -07:00
d01524020d bump flutter to 3.22.1 (#410) 2024-05-31 14:41:40 -07:00
fb2072676e fix parser verifier. (#408) 2024-05-23 00:07:51 -07:00
162c7a2689 run parser verifier only on schedule. (#407) 2024-05-22 23:51:48 -07:00
e218527953 run release flow only when lib or dependencies are changed. (#406) 2024-05-22 23:40:41 -07:00
3dddfa66cf add parser check. (#392) 2024-05-22 23:27:52 -07:00
3fd0a9a1ea bump build number. (#391) 2024-05-22 01:05:59 -07:00
7a35fe451d fix comment text parsing. (#390) 2024-05-22 00:53:58 -07:00
575ba8c2ef deprecated imperative apply of Flutter's Gradle plugins (#384) 2024-03-24 01:36:22 -07:00
e82998bb32 update pubspec.lock file. (#383) 2024-03-21 22:51:02 -07:00
3389e98861 bump Flutter version. (#382) 2024-03-21 16:49:40 -07:00
92a743f2f0 update date_display_format.dart (#379) 2024-02-20 22:21:25 -08:00
4148240daf update display_date_format.dart (#378) 2024-02-20 21:50:35 -08:00
90ec117b4b bump version. (#377) 2024-02-18 19:41:38 -08:00
9580e9b3e5 fix date time display. (#376) 2024-02-18 18:51:51 -08:00
0afaa5a0aa add date time display customization. (#375) 2024-02-17 02:04:00 -08:00
fbce1dff73 revert. (#374) 2024-02-13 16:52:14 -08:00
b0d6561486 update publish_ios.yml (#372) 2024-02-13 00:47:16 -08:00
11639118c5 update publish_ios.yml (#371) 2024-02-13 00:21:13 -08:00
e54c893e6c update publish_ios.yml (#370) 2024-02-13 00:07:31 -08:00
8c57e5e323 update publish_ios action. (#369) 2024-02-12 23:41:00 -08:00
b4ec7ec44e update publish_ios action. (#368) 2024-02-12 23:03:35 -08:00
8b65256294 update deploy target. (#367) 2024-02-12 22:30:23 -08:00
58f7bf14d7 bump fastlane version. (#366) 2024-02-12 21:55:58 -08:00
d9aad3d34e bump flutter version. (#365) 2024-02-12 20:54:33 -08:00
ed48d95375 fix comments repo. (#364) 2024-01-03 14:31:03 -08:00
1eaded5694 fix uncaught error. (#359) 2023-12-11 01:01:26 -08:00
70bb78afcb use InterceptorsWrapper for caching. (#358) 2023-12-10 15:29:40 -08:00
df2d2478d5 improve comment fetching. (#357) 2023-12-09 18:20:28 -08:00
d5ae60327d change fetch method based on network condition. (#356) 2023-12-09 10:03:22 -08:00
615a092c1e update fetching strategy. (#355) 2023-12-09 02:05:03 -08:00
5a7699d866 update hacker_news_web_repository.dart (#354) 2023-12-09 00:00:20 -08:00
56a9bab3f2 improve error handling. (#353) 2023-12-08 22:37:57 -08:00
e9bbf46b4f fix comment fetching. (#352) 2023-12-08 21:25:02 -08:00
10f503a6c0 add cache for story metadata. (#350) 2023-12-08 20:09:05 -08:00
582f3156b2 add dev option. (#349) 2023-12-08 17:21:30 -08:00
90fb45146f fix story repository. (#348) 2023-12-08 14:25:32 -08:00
c19c54e762 optimize comment fetching. (#347) 2023-12-08 13:35:52 -08:00
70e5a84b63 improve comment fetching. (#346) 2023-12-08 10:18:03 -08:00
3a51fa83f2 update story tile padding. (#344) 2023-12-08 01:12:49 -08:00
cb90751330 fix flickering image. (#343) 2023-12-07 23:24:43 -08:00
835ed7e841 use different comment fetching strategy. (#342) 2023-12-07 21:46:13 -08:00
125ccd2dd1 use isolate to fetch comments. (#341) 2023-12-05 21:04:40 -08:00
5b991c4287 update theme. (#340) 2023-12-03 17:30:34 -08:00
91 changed files with 2676 additions and 974 deletions

View File

@ -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
View 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 }}

View File

@ -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:

View File

@ -11,3 +11,4 @@ linter:
analyzer:
exclude:
- "submodules/**"
- "scripts/**"

View File

@ -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]

View File

@ -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()

View File

@ -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

View File

@ -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"

View 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"
}

View File

@ -0,0 +1,3 @@
- Return of true dark mode.
- Better comment fetching strategy.
- Minor UI fixes.

View File

@ -0,0 +1,2 @@
- Favicon in mini story tile.
- UX improvements.

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
<string>12.0</string>
</dict>
</plist>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

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

View File

@ -114,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.logout();
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
await _sembastRepository.deleteAll();
await _sembastRepository.deleteCachedComments();
}
}

View File

@ -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();
}
}

View File

@ -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});

View File

@ -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);
}

View File

@ -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())

View File

@ -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

View File

@ -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';

View File

@ -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));
}
}
}

View File

@ -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);
});
}
}

View File

@ -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,

View File

@ -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),

View File

@ -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;

View 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;
}
}

View 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];
}

View File

@ -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) {

View File

@ -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;

View File

@ -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?;

View File

@ -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,

View File

@ -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,
),

View 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...');
}

View 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>{};
}

View 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;
}

View File

@ -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});

View File

@ -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';

View File

@ -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';

View File

@ -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';
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View 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;
}
}

View File

@ -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';

View File

@ -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();
}
}
}

View File

@ -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.

View File

@ -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,
),
),
),

View File

@ -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),
),
),
],

View File

@ -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

View File

@ -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,

View File

@ -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,
),
],
],
),

View File

@ -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());
}
},
);
});
}

View File

@ -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(),
);
},
);
},
);
}
}

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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';

View File

@ -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,
),

View File

@ -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,
),
),

View File

@ -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(

View File

@ -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;

View File

@ -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,

View File

@ -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(

View File

@ -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(

View File

@ -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,
),
);
}
}

View File

@ -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();

View File

@ -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),
),
),

View File

@ -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));

View File

@ -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,
),
],
),

View File

@ -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);

View File

@ -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);
}

View 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>>{};
}

View File

@ -0,0 +1,2 @@
export 'cache_interceptor.dart';
export 'logger_interceptor.dart';

View 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,
);
}

View File

@ -14,4 +14,10 @@ abstract class HapticFeedbackUtil {
HapticFeedback.lightImpact();
}
}
static void heavy() {
if (enabled) {
HapticFeedback.heavyImpact();
}
}
}

View File

@ -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));
}

View File

@ -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;
}
}

View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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

View 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

View 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
View 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
View 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