Compare commits

...

40 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
73 changed files with 1871 additions and 853 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,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

@ -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> {
@ -35,7 +36,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: concurrent(),
transformer: sequential(),
);
on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh);
@ -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 {

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

@ -83,7 +83,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
static const int _webFetchingCmtCountLowerLimit = 5;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
@ -103,8 +103,9 @@ class CommentsCubit extends Cubit<CommentsState> {
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
final List<ConnectivityResult> status =
await Connectivity().checkConnectivity();
return status.contains(ConnectivityResult.wifi);
}
@override
@ -193,7 +194,7 @@ class CommentsCubit extends Cubit<CommentsState> {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
if (_preferenceCubit.state.isDevModeEnabled) {
onError?.call(e as AppException);
}
@ -289,7 +290,7 @@ class CommentsCubit extends Cubit<CommentsState> {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
if (_preferenceCubit.state.isDevModeEnabled) {
onError?.call(e as AppException);
}

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

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

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,35 +48,37 @@ class PreferenceState extends Equatable {
.val;
}
bool get notificationEnabled => _isOn<NotificationModePreference>();
bool get isNotificationEnabled => _isOn<NotificationModePreference>();
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get isComplexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get isFaviconEnabled => _isOn<FaviconModePreference>();
bool get readerEnabled => _isOn<ReaderModePreference>();
bool get isEyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get isReaderEnabled => _isOn<ReaderModePreference>();
bool get metadataEnabled => _isOn<MetadataModePreference>();
bool get isMarkReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get urlEnabled => _isOn<StoryUrlModePreference>();
bool get isMetadataEnabled => _isOn<MetadataModePreference>();
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get isUrlEnabled => _isOn<StoryUrlModePreference>();
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
bool get isTapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
bool get isSwipeGestureEnabled => _isOn<SwipeGesturePreference>();
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get isAutoScrollEnabled => _isOn<AutoScrollModePreference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get isCustomTabEnabled => _isOn<CustomTabPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get isManualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get isTrueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get devModeEnabled => _isOn<DevMode>();
bool get isHapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get isDevModeEnabled => _isOn<DevMode>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;
@ -119,6 +121,9 @@ class PreferenceState extends Equatable {
Font get font =>
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
DateDisplayFormat get displayDateFormat => DateDisplayFormat.values
.elementAt(preferences.singleWhereType<DateFormatPreference>().val);
@override
List<Object?> get props => <Object?>[
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),

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

@ -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,24 +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.trueDarkModeEnabled != current.trueDarkModeEnabled,
previous.isTrueDarkModeEnabled != current.isTrueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
'''${state.appColor}${state.font}${state.isTrueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -276,20 +281,25 @@ class HackiApp extends StatelessWidget {
.instance.platformDispatcher.platformBrightness,
mode,
);
final bool isDarkModeEnabled =
mode == AdaptiveThemeMode.dark ||
final bool isDarkModeEnabled = () {
if (mode == null) {
return View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark;
} else {
return mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark);
}
}();
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
);
return FeatureDiscovery(
child: MediaQuery(
@ -306,13 +316,21 @@ class HackiApp extends StatelessWidget {
theme: ThemeData(
colorScheme: colorScheme,
fontFamily: state.font.name,
canvasColor:
isDarkModeEnabled && state.isTrueDarkModeEnabled
? Palette.black
: null,
scaffoldBackgroundColor:
isDarkModeEnabled && state.isTrueDarkModeEnabled
? Palette.black
: null,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
),
switchTheme: SwitchThemeData(
trackColor: 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);
@ -342,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,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

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

@ -1,5 +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(),
@ -54,47 +59,23 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
List<Object?> get props => <Object?>[key];
}
abstract class BooleanPreference extends Preference<bool> {
abstract final class BooleanPreference extends Preference<bool> {
const BooleanPreference({required super.val});
}
abstract class IntPreference extends Preference<int> {
abstract final class IntPreference extends Preference<int> {
const IntPreference({required super.val});
}
abstract class DoublePreference extends Preference<double> {
abstract final class DoublePreference extends Preference<double> {
const DoublePreference({required super.val});
}
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _hapticFeedbackModeDefaultValue = true;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false;
const bool _paginationModeDefaultValue = false;
const bool _devModeDefaultValue = 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;
class DevMode extends BooleanPreference {
final class DevMode extends BooleanPreference {
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
static const bool _devModeDefaultValue = false;
@override
DevMode copyWith({required bool? val}) {
return DevMode(val: val);
@ -113,10 +94,12 @@ class DevMode extends BooleanPreference {
bool get isDisplayable => false;
}
class SwipeGesturePreference extends BooleanPreference {
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);
@ -133,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);
@ -153,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);
@ -173,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);
@ -186,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 =>
@ -195,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);
@ -214,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);
@ -234,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);
@ -253,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);
@ -276,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);
@ -295,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);
@ -314,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);
@ -337,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);
@ -360,10 +386,12 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid;
}
class TrueDarkModePreference extends BooleanPreference {
final class TrueDarkModePreference extends BooleanPreference {
const TrueDarkModePreference({bool? val})
: super(val: val ?? _trueDarkModeDefaultValue);
static const bool _trueDarkModeDefaultValue = false;
@override
TrueDarkModePreference copyWith({required bool? val}) {
return TrueDarkModePreference(val: val);
@ -379,10 +407,12 @@ class TrueDarkModePreference extends BooleanPreference {
String get subtitle => 'real dark.';
}
class HapticFeedbackPreference extends BooleanPreference {
final class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue);
static const bool _hapticFeedbackModeDefaultValue = true;
@override
HapticFeedbackPreference copyWith({required bool? val}) {
return HapticFeedbackPreference(val: val);
@ -398,9 +428,11 @@ class HapticFeedbackPreference extends BooleanPreference {
String get subtitle => '';
}
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);
@ -413,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);
@ -429,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);
@ -444,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);
@ -459,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);
@ -474,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);
@ -490,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);
@ -505,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);
@ -520,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

@ -6,6 +6,8 @@ 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;
@ -15,6 +17,7 @@ import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
HackerNewsWebRepository({
RemoteConfigCubit? remoteConfigCubit,
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
@ -24,10 +27,13 @@ class HackerNewsWebRepository {
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': '*/*',
@ -96,16 +102,20 @@ class HackerNewsWebRepository {
}
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
static const String _athingComtrSelector =
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing.comtr';
static const String _commentTextSelector =
'''td > table > tbody > tr > td.default > div.comment''';
static const String _commentHeadSelector =
'''td > table > tbody > tr > td.default > div > span > a''';
static const String _commentAgeSelector =
'''td > table > tbody > tr > td.default > div > span > span.age''';
static const String _commentIndentSelector =
'''td > table > tbody > tr > td.ind''';
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;
@ -153,6 +163,10 @@ class HackerNewsWebRepository {
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.
@ -242,8 +256,9 @@ class HackerNewsWebRepository {
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
final List<ConnectivityResult> status =
await Connectivity().checkConnectivity();
return status.contains(ConnectivityResult.wifi);
}
static Future<String> _parseCommentTextHtml(String text) async {

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

@ -51,9 +51,9 @@ class SembastRepository {
}
Future<Database> initializeCache() async {
final Directory dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki_cache.db');
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;
@ -209,7 +209,6 @@ class SembastRepository {
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());
});
@ -233,17 +232,23 @@ class SembastRepository {
//#endregion
Future<FileSystemEntity> deleteCachedComments() async {
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<FileSystemEntity> deleteCachedMetadata() async {
Future<void> deleteCachedMetadata() async {
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String cachePath = join(tempDir.path, 'hacki_cache.db');
return File(cachePath).delete();
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

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

@ -150,7 +150,7 @@ class MainView extends StatelessWidget {
},
),
),
if (context.read<PreferenceCubit>().state.devModeEnabled)
if (context.read<PreferenceCubit>().state.isDevModeEnabled)
Positioned(
height: Dimens.pt4,
bottom: Dimens.zero,
@ -255,7 +255,11 @@ class _ParentItemSection extends StatelessWidget {
),
const Spacer(),
Text(
item.timeAgo,
context
.read<PreferenceCubit>()
.state
.displayDateFormat
.convertToString(item.time),
style: TextStyle(
color: Theme.of(context).metadataColor,
),
@ -285,7 +289,7 @@ class _ParentItemSection extends StatelessWidget {
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
.isReaderEnabled,
offlineReading: context
.read<StoriesBloc>()
.state

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,
@ -192,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,
@ -417,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: TextStyle(
color: Theme.of(context).metadataColor,
),
),
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) {
@ -303,7 +347,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
onTap: showAboutHackiDialog,
onLongPress: () {
final DevMode updatedDevMode =
DevMode(val: !preferenceState.devModeEnabled);
DevMode(val: !preferenceState.isDevModeEnabled);
context.read<PreferenceCubit>().update(updatedDevMode);
HapticFeedbackUtil.heavy();
if (updatedDevMode.val) {

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

@ -200,7 +200,8 @@ class CommentTile extends StatelessWidget {
// ),
const Spacer(),
Text(
comment.timeAgo,
prefState.displayDateFormat
.convertToString(comment.time),
style: TextStyle(
color: Theme.of(context).metadataColor,
),
@ -307,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 &&
@ -403,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);
}
}
@ -413,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(
@ -140,6 +142,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
showWebPreview: showWebPreviewOnStoryTile,
showMetadata: showMetadataOnStoryTile,
showUrl: showUrl,
showFavicon: showFavicon,
hasRead: markReadStories && hasRead,
),
),

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,24 +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,
cacheKey: imageUri,
errorWidget: (_, __, ___) => Center(
child: Text(
r'¯\_(ツ)_/¯',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
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,
),
);
},
),
);
},
),
),
),
)
@ -156,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

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

@ -120,7 +120,7 @@ class WebAnalyzer {
if (info != null) {
locator.get<Logger>().d('''
Fetched mem cached metadata using key $key for $story:
fetched mem cached metadata using key $key for $story:
${info.toJson()}
''');
return info;
@ -168,7 +168,7 @@ ${info.toJson()}
/// [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:
fetched file cached metadata using key $key for $story:
${info.toJson()}
''');
cacheMap[key] = info;
@ -189,7 +189,7 @@ ${info.toJson()}
if (info is WebInfo) {
locator
.get<Logger>()
.d('Caching metadata using key $key for $story.');
.d('caching metadata using key $key for $story.');
unawaited(
locator.get<SembastRepository>().cacheMetadata(
key: key,
@ -232,23 +232,20 @@ ${info.toJson()}
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 {
@ -279,7 +276,6 @@ ${info.toJson()}
description: fallbackDescription,
).._shouldRetry = shouldRetry;
}
return info;
}
@ -291,7 +287,6 @@ ${info.toJson()}
final bool multimedia = message[1] as bool;
final InfoBase? info = await _getInfo(url, multimedia);
if (info is WebInfo) {
return <dynamic>[
'0',
@ -419,14 +414,12 @@ ${info.toJson()}
} catch (e) {
try {
html = gbk.decode(response.bodyBytes);
} catch (e) {
locator
.get<Logger>()
.e('''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);
@ -443,7 +436,7 @@ ${info.toJson()}
final WebInfo info = WebInfo(
title: _analyzeTitle(document),
icon: _analyzeIcon(document, uri),
icon: await _analyzeIcon(document, uri),
description: _analyzeDescription(document, html),
image: _analyzeImage(document, uri),
);
@ -530,7 +523,7 @@ ${info.toJson()}
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
@ -559,7 +552,7 @@ ${info.toJson()}
if (metaIcon != null) {
icon = metaIcon.attributes['href'];
} else {
return '${uri.origin}/favicon.ico';
return null;
}
return _handleUrl(uri, icon);

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

File diff suppressed because it is too large Load Diff

View File

@ -1,62 +1,65 @@
name: hacki
description: A Hacker News reader.
version: 2.6.1+136
version: 2.8.0+145
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: "3.16.3"
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:
@ -64,23 +67,23 @@ dependencies:
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:
@ -130,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