Compare commits

..

48 Commits

Author SHA1 Message Date
575ba8c2ef deprecated imperative apply of Flutter's Gradle plugins (#384) 2024-03-24 01:36:22 -07:00
e82998bb32 update pubspec.lock file. (#383) 2024-03-21 22:51:02 -07:00
3389e98861 bump Flutter version. (#382) 2024-03-21 16:49:40 -07:00
92a743f2f0 update date_display_format.dart (#379) 2024-02-20 22:21:25 -08:00
4148240daf update display_date_format.dart (#378) 2024-02-20 21:50:35 -08:00
90ec117b4b bump version. (#377) 2024-02-18 19:41:38 -08:00
9580e9b3e5 fix date time display. (#376) 2024-02-18 18:51:51 -08:00
0afaa5a0aa add date time display customization. (#375) 2024-02-17 02:04:00 -08:00
fbce1dff73 revert. (#374) 2024-02-13 16:52:14 -08:00
b0d6561486 update publish_ios.yml (#372) 2024-02-13 00:47:16 -08:00
11639118c5 update publish_ios.yml (#371) 2024-02-13 00:21:13 -08:00
e54c893e6c update publish_ios.yml (#370) 2024-02-13 00:07:31 -08:00
8c57e5e323 update publish_ios action. (#369) 2024-02-12 23:41:00 -08:00
b4ec7ec44e update publish_ios action. (#368) 2024-02-12 23:03:35 -08:00
8b65256294 update deploy target. (#367) 2024-02-12 22:30:23 -08:00
58f7bf14d7 bump fastlane version. (#366) 2024-02-12 21:55:58 -08:00
d9aad3d34e bump flutter version. (#365) 2024-02-12 20:54:33 -08:00
ed48d95375 fix comments repo. (#364) 2024-01-03 14:31:03 -08:00
1eaded5694 fix uncaught error. (#359) 2023-12-11 01:01:26 -08:00
70bb78afcb use InterceptorsWrapper for caching. (#358) 2023-12-10 15:29:40 -08:00
df2d2478d5 improve comment fetching. (#357) 2023-12-09 18:20:28 -08:00
d5ae60327d change fetch method based on network condition. (#356) 2023-12-09 10:03:22 -08:00
615a092c1e update fetching strategy. (#355) 2023-12-09 02:05:03 -08:00
5a7699d866 update hacker_news_web_repository.dart (#354) 2023-12-09 00:00:20 -08:00
56a9bab3f2 improve error handling. (#353) 2023-12-08 22:37:57 -08:00
e9bbf46b4f fix comment fetching. (#352) 2023-12-08 21:25:02 -08:00
10f503a6c0 add cache for story metadata. (#350) 2023-12-08 20:09:05 -08:00
582f3156b2 add dev option. (#349) 2023-12-08 17:21:30 -08:00
90fb45146f fix story repository. (#348) 2023-12-08 14:25:32 -08:00
c19c54e762 optimize comment fetching. (#347) 2023-12-08 13:35:52 -08:00
70e5a84b63 improve comment fetching. (#346) 2023-12-08 10:18:03 -08:00
3a51fa83f2 update story tile padding. (#344) 2023-12-08 01:12:49 -08:00
cb90751330 fix flickering image. (#343) 2023-12-07 23:24:43 -08:00
835ed7e841 use different comment fetching strategy. (#342) 2023-12-07 21:46:13 -08:00
125ccd2dd1 use isolate to fetch comments. (#341) 2023-12-05 21:04:40 -08:00
5b991c4287 update theme. (#340) 2023-12-03 17:30:34 -08:00
7dc3618afe update color. (#339) 2023-12-02 23:31:45 -08:00
eef4691814 update Info.plist (#338) 2023-12-02 20:58:39 -08:00
9f71701845 update story tile. (#336) 2023-12-02 04:46:06 -08:00
d27203b041 update Info.plist (#335) 2023-12-02 04:21:58 -08:00
4f280ec4c9 add ability to sync favorites from Hacker News. (#334) 2023-12-01 21:53:48 -08:00
72cb2737ca fix story tile. (#333) 2023-12-01 12:09:14 -08:00
215203bd16 remove error placeholder. (#332) 2023-12-01 11:27:16 -08:00
3e320faece update story title. (#331) 2023-12-01 09:56:19 -08:00
1049568246 bump Flutter version to 3.16.2 (#330) 2023-12-01 01:11:30 -08:00
71aa42118d fix web analyzer (#327) 2023-11-26 09:43:23 +09:00
4f21d3e6bd update pubspec.yaml (#325) 2023-11-15 10:50:00 -08:00
96d0fe9e5e fix new comment indicator. (#324) 2023-11-15 01:15:10 -08:00
152 changed files with 2432 additions and 1361 deletions

View File

@ -10,7 +10,7 @@ on:
jobs:
build_and_publish:
runs-on: macos-latest
runs-on: macos-13
timeout-minutes: 30
env:
@ -19,6 +19,11 @@ jobs:
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
steps:
- name: Set XCode version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
- name: Check out from git
uses: actions/checkout@v3
with:

View File

@ -35,22 +35,20 @@ Features:
<p align="center">
<img width="200" alt="01" src="assets/screenshots/01.png">
<img width="200" alt="02" src="assets/screenshots/02.png">
<img width="200" alt="03" src="assets/screenshots/03.png">
<img width="200" alt="04" src="assets/screenshots/04.png">
<img width="200" alt="05" src="assets/screenshots/05.png">
<img width="200" alt="06" src="assets/screenshots/06.png">
<img width="200" alt="07" src="assets/screenshots/07.png">
<img width="200" alt="08" src="assets/screenshots/08.png">
<img width="200" alt="09" src="assets/screenshots/09.png">
<img width="200" alt="10" src="assets/screenshots/10.png">
<img width="200" alt="11" src="assets/screenshots/11.png">
<img width="200" alt="12" src="assets/screenshots/12.png">
<img width="400" alt="01" src="assets/screenshots/light-1.png">
<img width="400" alt="06" src="assets/screenshots/dark-1.png">
<img width="400" alt="02" src="assets/screenshots/light-2.png">
<img width="400" alt="07" src="assets/screenshots/dark-2.png">
<img width="400" alt="03" src="assets/screenshots/light-3.png">
<img width="400" alt="08" src="assets/screenshots/dark-3.png">
<img width="400" alt="04" src="assets/screenshots/light-4.png">
<img width="400" alt="09" src="assets/screenshots/dark-4.png">
<img width="400" alt="05" src="assets/screenshots/light-5.png">
<img width="400" alt="10" src="assets/screenshots/dark-5.png">
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png">
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png">
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png">
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png">
<img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
<img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
<img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
<img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
</p>

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

@ -23,7 +23,8 @@
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"

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"

Binary file not shown.

Binary file not shown.

BIN
assets/hacki-github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/tablet-hacki.xcf Normal file

Binary file not shown.

View File

@ -0,0 +1,4 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

@ -0,0 +1,4 @@
- RobotoSlab as default font.
- Material 3 design.
- Ability to sync favorites from your Hacker News account.
- Support for predictive back gesture.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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

@ -7,11 +7,11 @@ PODS:
- Flutter (1.0.0)
- flutter_email_sender (0.0.1):
- Flutter
- flutter_inappwebview (0.0.1):
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview/Core (= 0.0.1)
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- flutter_inappwebview/Core (0.0.1):
- flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
@ -20,9 +20,6 @@ PODS:
- Flutter
- flutter_siri_suggestions (0.0.1):
- Flutter
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1):
@ -38,7 +35,7 @@ PODS:
- Flutter
- MTBBarcodeScanner
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1):
- receive_sharing_intent (1.5.3):
- Flutter
- share_plus (0.0.1):
- Flutter
@ -47,7 +44,7 @@ PODS:
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- FlutterMacOS
- synced_shared_preferences (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
@ -64,7 +61,7 @@ DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
@ -76,7 +73,7 @@ DEPENDENCIES:
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
@ -85,7 +82,6 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- MTBBarcodeScanner
- OrderedSet
- ReachabilitySwift
@ -99,8 +95,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_email_sender:
:path: ".symlinks/plugins/flutter_email_sender/ios"
flutter_inappwebview:
:path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_inappwebview_ios:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage:
@ -124,7 +120,7 @@ EXTERNAL SOURCES:
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
:path: ".symlinks/plugins/sqflite/darwin"
synced_shared_preferences:
:path: ".symlinks/plugins/synced_shared_preferences/ios"
url_launcher_ios:
@ -139,31 +135,30 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: 3d32228f1304635e7c028b0d4252937730bbc6cf
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 13825b8a9334a850581300559b8839134b124670
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
receive_sharing_intent: 753f808c6be5550247f6a20f2a14972466a5f33c
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
PODFILE CHECKSUM: 0957b955069bb512c22bae4cadad9f4c34161dbe
COCOAPODS: 1.13.0

View File

@ -230,13 +230,13 @@
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
@ -291,7 +291,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@ -548,7 +548,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -636,7 +636,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -685,7 +685,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
@ -32,10 +33,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: concurrent(),
);
on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh);
on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded);
on<StoryLoaded>(
onStoryLoaded,
transformer: sequential(),
);
on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded);
@ -88,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
),
);
for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit);
add(LoadStories(type: type));
}
}
Future<void> loadStories({
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
Future<void> onLoadStories(
LoadStories event,
Emitter<StoriesState> emit,
) async {
final StoryType type = event.type;
if (state.isOfflineReading) {
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
@ -121,13 +130,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
);
_hackerNewsRepository
await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: type));
});
}).asFuture<void>();
add(StoriesLoaded(type: type));
}
}
@ -153,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
} else {
emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit);
add(LoadStories(type: event.type));
}
}

View File

@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
final StoryType type;
@override
List<Object?> get props => <Object?>[type];
}
class StoriesInitialize extends StoriesEvent {
@override
List<Object?> get props => <Object?>[];

View File

@ -73,7 +73,7 @@ abstract class RegExpConstants {
static const String number = '[0-9]+';
}
abstract class Durations {
abstract class AppDurations {
static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300);
@ -83,4 +83,7 @@ abstract class Durations {
static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2);
static const Duration tenSeconds = Duration(seconds: 10);
static const Duration sec30 = Duration(seconds: 30);
static const Duration oneMinute = Duration(minutes: 1);
static const Duration twoMinutes = Duration(minutes: 2);
}

View File

@ -25,6 +25,7 @@ Future<void> setUpLocator() async {
)
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository())
..registerSingleton<AuthRepository>(AuthRepository())

View File

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
@ -25,6 +26,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
@ -32,15 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
@ -52,10 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
);
final FilterCubit _filterCubit;
final PreferenceCubit _preferenceCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
@ -71,6 +83,30 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
if (isOnWifi) {
return switch (state.item) {
Story(descendants: final int descendants)
when descendants > _webFetchingCmtCountLowerLimit =>
true,
Comment(kids: final List<int> kids)
when kids.length > _webFetchingCmtCountLowerLimit =>
true,
_ => false,
};
} else {
return true;
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
@override
void emit(CommentsState state) {
if (!isClosed) {
@ -82,6 +118,8 @@ class CommentsCubit extends Cubit<CommentsState> {
bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetAncestors,
AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit(
@ -139,11 +177,49 @@ class CommentsCubit extends Cubit<CommentsState> {
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
case FetchMode.eager:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache:
useCommentCache ? _commentCache.getComment : null,
);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
}
}
}
@ -154,7 +230,10 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone);
}
Future<void> refresh() async {
Future<void> refresh({
required AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
emit(
state.copyWith(
status: CommentsStatus.inProgress,
@ -191,14 +270,47 @@ class CommentsCubit extends Cubit<CommentsState> {
final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids,
);
} else {
commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
switch (state.fetchMode) {
case FetchMode.lazy:
commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
case FetchMode.eager:
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
}
_streamSubscription = commentStream
@ -369,7 +481,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
}
@ -394,7 +506,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -421,7 +533,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -461,7 +573,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -538,6 +650,10 @@ class CommentsCubit extends Cubit<CommentsState> {
_collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment);
if (state.isOfflineReading) {
_sembastRepository.cacheComment(comment);
}
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),

View File

@ -12,7 +12,7 @@ part 'edit_state.dart';
class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: Durations.oneSecond),
_debouncer = Debouncer(delay: AppDurations.oneSecond),
super(const EditState.init());
final DraftCache _draftCache;

View File

@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'fav_state.dart';
@ -13,12 +18,17 @@ class FavCubit extends Cubit<FavState> {
AuthRepository? authRepository,
PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(FavState.init()) {
init();
}
@ -27,43 +37,42 @@ class FavCubit extends Cubit<FavState> {
final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20;
String? _username;
Future<void> init() async {
_authBloc.stream.listen((AuthState authState) {
if (authState.username != _username) {
_preferenceRepository
.favList(of: authState.username)
.then((List<int> favIds) {
_usernameSubscription = _authBloc.stream
.map((AuthState event) => event.username)
.distinct()
.listen((String username) {
_preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
status: Status.success,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
status: Status.success,
),
);
});
});
_username = authState.username;
}
});
});
}
Future<void> addFav(int id) async {
final String username = _authBloc.state.username;
if (state.favIds.contains(id)) return;
await _preferenceRepository.addFav(username: username, id: id);
@ -89,9 +98,9 @@ class FavCubit extends Cubit<FavState> {
}
void removeFav(int id) {
final String username = _authBloc.state.username;
_preferenceRepository.removeFav(username: username, id: id);
_preferenceRepository
..removeFav(username: username, id: id)
..removeFav(username: '', id: id);
emit(
state.copyWith(
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
}
void refresh() {
final String username = _authBloc.state.username;
emit(
state.copyWith(
status: Status.inProgress,
@ -167,6 +174,34 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init());
}
Future<void> merge({
required AppExceptionHandler onError,
required VoidCallback onSuccess,
}) async {
if (_authBloc.state.isLoggedIn) {
emit(state.copyWith(mergeStatus: Status.inProgress));
try {
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
of: _authBloc.state.username,
);
_logger.d('fetched ${ids.length} favorite items from HN.');
final List<int> combinedIds = <int>[...ids, ...state.favIds];
final LinkedHashSet<int> mergedIds =
LinkedHashSet<int>.from(combinedIds);
await _preferenceRepository.overwriteFav(
username: username,
ids: mergedIds,
);
emit(state.copyWith(mergeStatus: Status.success));
onSuccess();
refresh();
} on RateLimitedException catch (e) {
onError(e);
emit(state.copyWith(mergeStatus: Status.failure));
}
}
}
void _onItemLoaded(Item item) {
emit(
state.copyWith(
@ -174,4 +209,14 @@ class FavCubit extends Cubit<FavState> {
),
);
}
@override
Future<void> close() {
_usernameSubscription?.cancel();
return super.close();
}
}
extension on FavCubit {
String get username => _authBloc.state.username;
}

View File

@ -5,6 +5,7 @@ class FavState extends Equatable {
required this.favIds,
required this.favItems,
required this.status,
required this.mergeStatus,
required this.currentPage,
});
@ -12,23 +13,27 @@ class FavState extends Equatable {
: favIds = <int>[],
favItems = <Item>[],
status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0;
final List<int> favIds;
final List<Item> favItems;
final Status status;
final Status mergeStatus;
final int currentPage;
FavState copyWith({
List<int>? favIds,
List<Item>? favItems,
Status? status,
Status? mergeStatus,
int? currentPage,
}) {
return FavState(
favIds: favIds ?? this.favIds,
favItems: favItems ?? this.favItems,
status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage,
);
}
@ -36,6 +41,7 @@ class FavState extends Equatable {
@override
List<Object?> get props => <Object?>[
status,
mergeStatus,
currentPage,
favIds,
favItems,

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,12 +29,16 @@ class NotificationCubit extends Cubit<NotificationState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(NotificationState.init()) {
_authBloc.stream.listen((AuthState authState) {
if (authState.isLoggedIn && authState.username != _username) {
_authBloc.stream
.map((AuthState event) => event.username)
.distinct()
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting.
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(Durations.twoSeconds, init);
Future<void>.delayed(AppDurations.twoSeconds, init);
}
// Listen for setting changes in the future.
@ -44,9 +50,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_timer?.cancel();
}
});
_username = authState.username;
} else if (!authState.isLoggedIn) {
} else {
emit(NotificationState.init());
}
});
@ -57,7 +61,7 @@ class NotificationCubit extends Cubit<NotificationState> {
final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository;
String? _username;
final Logger _logger;
Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5);
@ -74,6 +78,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});

View File

@ -70,14 +70,14 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get devModeEnabled => _isOn<DevMode>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;
@ -119,6 +119,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

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

@ -20,7 +20,7 @@ extension ContextExtension on BuildContext {
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Theme.of(this).primaryColor,
backgroundColor: Theme.of(this).colorScheme.primary,
content: Text(
content,
style: TextStyle(
@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
);
}
void showErrorSnackBar() => showSnackBar(
content: Constants.errorMessage,
);
void showErrorSnackBar([String? message]) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Theme.of(this).colorScheme.errorContainer,
content: Text(
message ?? Constants.errorMessage,
style: TextStyle(
color: Theme.of(this).colorScheme.onErrorContainer,
),
),
),
);
}
Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?;

View File

@ -27,7 +27,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
);
}
void showErrorSnackBar() => context.showErrorSnackBar();
void showErrorSnackBar([String? message]) =>
context.showErrorSnackBar(message);
Future<void>? goToItemScreen({
required ItemScreenArgs args,

View File

@ -23,6 +23,7 @@ import 'package:hacki/utils/haptic_feedback_util.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
@ -45,6 +46,8 @@ void notificationReceiver(NotificationResponse details) =>
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting(Platform.localeName);
isTesting = testing;
final Directory tempDir = await getTemporaryDirectory();
@ -138,7 +141,7 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
runApp(
HackiApp(
@ -240,12 +243,11 @@ class HackiApp extends StatelessWidget {
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -261,7 +263,6 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor,
brightness: Brightness.dark,
),
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
fontFamily: state.font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
@ -285,73 +286,72 @@ class HackiApp extends StatelessWidget {
.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(
data: MediaQuery.of(context).copyWith(
textScaleFactor: state.textScaleFactor == 1
? null
: state.textScaleFactor,
),
data: state.textScaleFactor == 1
? MediaQuery.of(context)
: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
state.textScaleFactor,
),
),
child: MaterialApp.router(
key: Key(state.appColor.hashCode.toString()),
title: 'Hacki',
debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
useMaterial3: state.material3Enabled,
dividerTheme: state.material3Enabled
? DividerThemeData(
color: Palette.grey.withOpacity(0.2),
)
: null,
switchTheme: state.material3Enabled
? SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states
.contains(MaterialState.selected)) {
return null;
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
)
: null,
bottomSheetTheme: state.material3Enabled
? const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
)
: null,
inputDecorationTheme: state.material3Enabled
? InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
)
: null,
sliderTheme: state.material3Enabled
? SliderThemeData(
inactiveTrackColor:
state.appColor.shade200.withOpacity(0.5),
)
: null,
outlinedButtonTheme: state.material3Enabled
? OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
)
: null,
theme: ThemeData(
colorScheme: colorScheme,
fontFamily: state.font.name,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
),
switchTheme: SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return colorScheme.primary.withOpacity(0.6);
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
),
bottomSheetTheme: const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
),
inputDecorationTheme: InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
),
sliderTheme: SliderThemeData(
inactiveTrackColor:
colorScheme.primary.withOpacity(0.5),
activeTrackColor: colorScheme.primary,
thumbColor: colorScheme.primary,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
),
),
routerConfig: router,
),

View File

@ -0,0 +1,32 @@
typedef AppExceptionHandler = void Function(AppException);
class AppException implements Exception {
AppException({
required this.message,
this.stackTrace,
});
final String? message;
final StackTrace? stackTrace;
}
class RateLimitedException extends AppException {
RateLimitedException() : super(message: 'Rate limited...');
}
class RateLimitedWithFallbackException extends AppException {
RateLimitedWithFallbackException()
: super(message: 'Rate limited, fetching from API instead...');
}
class PossibleParsingException extends AppException {
PossibleParsingException({
required this.itemId,
}) : super(message: 'Possible parsing failure...');
final int itemId;
}
class GenericException extends AppException {
GenericException() : super(message: 'Something went wrong...');
}

View File

@ -6,4 +6,7 @@ enum CommentsOrder {
const CommentsOrder(this.description);
final String description;
@override
String toString() => description;
}

View File

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:intl/intl.dart';
enum DateDisplayFormat {
timeAgo,
yMd,
yMEd,
yMMMd,
yMMMEd;
String get description {
final DateTime exampleDate =
DateTime.now().subtract(const Duration(days: 5));
return switch (this) {
timeAgo => exampleDate.toTimeAgoString(),
yMd || yMEd || yMMMd || yMMMEd => () {
final String defaultLocale = Platform.localeName;
final DateFormat formatter = DateFormat(name, defaultLocale).add_Hm();
return formatter.format(exampleDate);
}(),
};
}
String convertToString(int timestamp) {
final bool isTimeAgo = this == timeAgo;
if (!isTimeAgo && _cache.containsKey(timestamp)) {
return _cache[timestamp] ?? 'This is wrong';
}
int updatedTimeStamp = timestamp;
if (updatedTimeStamp < 9999999999) {
updatedTimeStamp = updatedTimeStamp * 1000;
}
final DateTime date = DateTime.fromMillisecondsSinceEpoch(updatedTimeStamp);
if (isTimeAgo) {
return date.toTimeAgoString();
} else {
final String defaultLocale = Platform.localeName;
final DateFormat formatter = DateFormat(name, defaultLocale).add_Hm();
final String dateString = formatter.format(date);
_cache[timestamp] = dateString;
return dateString;
}
}
static void clearCache() => _cache.clear();
static Map<int, String> _cache = <int, String>{};
}

View File

@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
class CachedResponse<T> extends Response<T> {
CachedResponse({
required super.requestOptions,
super.data,
super.statusCode,
}) : setDateTime = DateTime.now();
factory CachedResponse.fromResponse(Response<T> response) {
return CachedResponse<T>(
requestOptions: response.requestOptions,
data: response.data,
statusCode: response.statusCode,
);
}
final DateTime setDateTime;
}

View File

@ -2,7 +2,7 @@ enum DiscoverableFeature {
addStoryToFavList(
featureId: 'add_story_to_fav_list',
title: 'Fav a Story',
description: '''Add it to your favorites''',
description: '''Add it to your favorites.''',
),
openStoryInWebView(
featureId: 'open_story_in_web_view',

View File

@ -5,4 +5,7 @@ enum FetchMode {
const FetchMode(this.description);
final String description;
@override
String toString() => description;
}

View File

@ -3,26 +3,12 @@ enum Font {
robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true);
notoSerif('Noto Serif', isSerif: true),
exo2('Exo 2'),
atkinsonHyperlegible('AtkinsonHyperlegible');
const Font(this.uiLabel, {this.isSerif = false});
final String uiLabel;
final bool isSerif;
static Font fromString(String? val) {
switch (val) {
case 'robotoSlab':
return Font.robotoSlab;
case 'ubuntu':
return Font.ubuntu;
case 'ubuntuMono':
return Font.ubuntuMono;
case 'notoSerif':
return Font.notoSerif;
case 'roboto':
default:
return Font.roboto;
}
}
}

View File

@ -90,8 +90,13 @@ class Item extends Equatable {
final List<int> kids;
final List<int> parts;
String get timeAgo =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
String get timeAgo {
int time = this.time;
if (time < 9999999999) {
time = time * 1000;
}
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
}
bool get isPoll => type == 'poll';

View File

@ -1,4 +1,6 @@
export 'app_exception.dart';
export 'comments_order.dart';
export 'date_display_format.dart';
export 'discoverable_feature.dart';
export 'export_destination.dart';
export 'fetch_mode.dart';

View File

@ -7,7 +7,7 @@ import 'package:hacki/models/displayable.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/palette.dart';
abstract class Preference<T> extends Equatable with SettingsDisplayable {
abstract final class Preference<T> extends Equatable with SettingsDisplayable {
const Preference({required this.val});
final T val;
@ -19,7 +19,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these preferences does not matter.
// Order of these preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
@ -27,15 +27,16 @@ 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 MetadataModePreference(),
const StoryUrlModePreference(),
// Divider.
// Divider.
const MarkReadStoriesModePreference(),
// Divider.
// Divider.
const NotificationModePreference(),
const AutoScrollModePreference(),
const CollapseModePreference(),
@ -46,7 +47,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const HapticFeedbackPreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
const Material3Preference(),
const DevMode(),
],
);
@ -54,48 +55,47 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
List<Object?> get props => <Object?>[key];
}
abstract class BooleanPreference extends Preference<bool> {
abstract final class BooleanPreference extends Preference<bool> {
const BooleanPreference({required super.val});
}
abstract class IntPreference extends Preference<int> {
abstract final class IntPreference extends Preference<int> {
const IntPreference({required super.val});
}
abstract class DoublePreference extends Preference<double> {
abstract final class DoublePreference extends Preference<double> {
const DoublePreference({required super.val});
}
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _hapticFeedbackModeDefaultValue = true;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false;
const bool _material3ModeDefaultValue = false;
const bool _paginationModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange);
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index;
final class DevMode extends BooleanPreference {
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
class SwipeGesturePreference extends BooleanPreference {
static const bool _devModeDefaultValue = false;
@override
DevMode copyWith({required bool? val}) {
return DevMode(val: val);
}
@override
String get key => 'devMode';
@override
String get title => 'Dev Mode';
@override
String get subtitle => '';
@override
bool get isDisplayable => false;
}
final class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue);
static const bool _swipeGestureModeDefaultValue = false;
@override
SwipeGesturePreference copyWith({required bool? val}) {
return SwipeGesturePreference(val: val);
@ -112,10 +112,12 @@ class SwipeGesturePreference extends BooleanPreference {
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
}
class NotificationModePreference extends BooleanPreference {
final class NotificationModePreference extends BooleanPreference {
const NotificationModePreference({bool? val})
: super(val: val ?? _notificationModeDefaultValue);
static const bool _notificationModeDefaultValue = true;
@override
NotificationModePreference copyWith({required bool? val}) {
return NotificationModePreference(val: val);
@ -132,10 +134,12 @@ class NotificationModePreference extends BooleanPreference {
'''Hacki scans for new replies to your 15 most recent comments or stories every 5 minutes while the app is running in the foreground.''';
}
class CollapseModePreference extends BooleanPreference {
final class CollapseModePreference extends BooleanPreference {
const CollapseModePreference({bool? val})
: super(val: val ?? _collapseModeDefaultValue);
static const bool _collapseModeDefaultValue = true;
@override
CollapseModePreference copyWith({required bool? val}) {
return CollapseModePreference(val: val);
@ -152,10 +156,12 @@ class CollapseModePreference extends BooleanPreference {
'''if disabled, tap on the top of comment tile to collapse.''';
}
class AutoScrollModePreference extends BooleanPreference {
final class AutoScrollModePreference extends BooleanPreference {
const AutoScrollModePreference({bool? val})
: super(val: val ?? _autoScrollModeDefaultValue);
static const bool _autoScrollModeDefaultValue = false;
@override
AutoScrollModePreference copyWith({required bool? val}) {
return AutoScrollModePreference(val: val);
@ -165,7 +171,7 @@ class AutoScrollModePreference extends BooleanPreference {
String get key => 'autoScrollMode';
@override
String get title => 'Auto-scroll on collapsing';
String get title => 'Auto-scroll on Collapsing';
@override
String get subtitle =>
@ -174,10 +180,12 @@ class AutoScrollModePreference extends BooleanPreference {
/// The value deciding whether or not the story
/// tile should display link preview. Defaults to true.
class DisplayModePreference extends BooleanPreference {
final class DisplayModePreference extends BooleanPreference {
const DisplayModePreference({bool? val})
: super(val: val ?? _displayModeDefaultValue);
static const bool _displayModeDefaultValue = true;
@override
DisplayModePreference copyWith({required bool? val}) {
return DisplayModePreference(val: val);
@ -193,10 +201,12 @@ class DisplayModePreference extends BooleanPreference {
String get subtitle => 'show web preview in story tile.';
}
class MetadataModePreference extends BooleanPreference {
final class MetadataModePreference extends BooleanPreference {
const MetadataModePreference({bool? val})
: super(val: val ?? _metadataModeDefaultValue);
static const bool _metadataModeDefaultValue = true;
@override
MetadataModePreference copyWith({required bool? val}) {
return MetadataModePreference(val: val);
@ -213,10 +223,12 @@ class MetadataModePreference extends BooleanPreference {
'''show number of comments and post date in story tile.''';
}
class StoryUrlModePreference extends BooleanPreference {
final class StoryUrlModePreference extends BooleanPreference {
const StoryUrlModePreference({bool? val})
: super(val: val ?? _storyUrlModeDefaultValue);
static const bool _storyUrlModeDefaultValue = true;
@override
StoryUrlModePreference copyWith({required bool? val}) {
return StoryUrlModePreference(val: val);
@ -232,10 +244,12 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.''';
}
class ReaderModePreference extends BooleanPreference {
final class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue);
static const bool _readerModeDefaultValue = true;
@override
ReaderModePreference copyWith({required bool? val}) {
return ReaderModePreference(val: val);
@ -255,10 +269,12 @@ class ReaderModePreference extends BooleanPreference {
bool get isDisplayable => Platform.isIOS;
}
class MarkReadStoriesModePreference extends BooleanPreference {
final class MarkReadStoriesModePreference extends BooleanPreference {
const MarkReadStoriesModePreference({bool? val})
: super(val: val ?? _markReadStoriesModeDefaultValue);
static const bool _markReadStoriesModeDefaultValue = true;
@override
MarkReadStoriesModePreference copyWith({required bool? val}) {
return MarkReadStoriesModePreference(val: val);
@ -274,10 +290,12 @@ class MarkReadStoriesModePreference extends BooleanPreference {
String get subtitle => 'grey out stories you have read.';
}
class EyeCandyModePreference extends BooleanPreference {
final class EyeCandyModePreference extends BooleanPreference {
const EyeCandyModePreference({bool? val})
: super(val: val ?? _eyeCandyModeDefaultValue);
static const bool _eyeCandyModeDefaultValue = false;
@override
EyeCandyModePreference copyWith({required bool? val}) {
return EyeCandyModePreference(val: val);
@ -293,10 +311,12 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.';
}
class ManualPaginationPreference extends BooleanPreference {
final class ManualPaginationPreference extends BooleanPreference {
const ManualPaginationPreference({bool? val})
: super(val: val ?? _paginationModeDefaultValue);
static const bool _paginationModeDefaultValue = false;
@override
ManualPaginationPreference copyWith({required bool? val}) {
return ManualPaginationPreference(val: val);
@ -312,34 +332,16 @@ class ManualPaginationPreference extends BooleanPreference {
String get subtitle => '''so you can get stuff done.''';
}
class Material3Preference extends BooleanPreference {
const Material3Preference({bool? val})
: super(val: val ?? _material3ModeDefaultValue);
@override
Material3Preference copyWith({required bool? val}) {
return Material3Preference(val: val);
}
@override
String get key => 'material3Mode';
@override
String get title => 'Material 3';
@override
String get subtitle =>
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
}
/// Whether or not to use Custom Tabs for launching URLs.
/// If false, default browser will be used.
///
/// https://developer.chrome.com/docs/android/custom-tabs/
class CustomTabPreference extends BooleanPreference {
final class CustomTabPreference extends BooleanPreference {
const CustomTabPreference({bool? val})
: super(val: val ?? _customTabModeDefaultValue);
static const bool _customTabModeDefaultValue = false;
@override
CustomTabPreference copyWith({required bool? val}) {
return CustomTabPreference(val: val);
@ -359,10 +361,12 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid;
}
class TrueDarkModePreference extends BooleanPreference {
final class TrueDarkModePreference extends BooleanPreference {
const TrueDarkModePreference({bool? val})
: super(val: val ?? _trueDarkModeDefaultValue);
static const bool _trueDarkModeDefaultValue = false;
@override
TrueDarkModePreference copyWith({required bool? val}) {
return TrueDarkModePreference(val: val);
@ -378,10 +382,12 @@ class TrueDarkModePreference extends BooleanPreference {
String get subtitle => 'real dark.';
}
class HapticFeedbackPreference extends BooleanPreference {
final class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue);
static const bool _hapticFeedbackModeDefaultValue = true;
@override
HapticFeedbackPreference copyWith({required bool? val}) {
return HapticFeedbackPreference(val: val);
@ -395,14 +401,13 @@ class HapticFeedbackPreference extends BooleanPreference {
@override
String get subtitle => '';
@override
bool get isDisplayable => Platform.isIOS;
}
class FetchModePreference extends IntPreference {
final class FetchModePreference extends IntPreference {
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
static final int _fetchModeDefaultValue = FetchMode.eager.index;
@override
FetchModePreference copyWith({required int? val}) {
return FetchModePreference(val: val);
@ -415,10 +420,12 @@ class FetchModePreference extends IntPreference {
String get title => 'Default fetch mode';
}
class CommentsOrderPreference extends IntPreference {
final class CommentsOrderPreference extends IntPreference {
CommentsOrderPreference({int? val})
: super(val: val ?? _commentsOrderDefaultValue);
static final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@override
CommentsOrderPreference copyWith({required int? val}) {
return CommentsOrderPreference(val: val);
@ -431,9 +438,11 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order';
}
class FontPreference extends IntPreference {
final class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
static final int _fontDefaultValue = Font.robotoSlab.index;
@override
FontPreference copyWith({required int? val}) {
return FontPreference(val: val);
@ -446,9 +455,11 @@ class FontPreference extends IntPreference {
String get title => 'Default font';
}
class FontSizePreference extends IntPreference {
final class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
static final int _fontSizeDefaultValue = FontSize.regular.index;
@override
FontSizePreference copyWith({required int? val}) {
return FontSizePreference(val: val);
@ -461,9 +472,12 @@ class FontSizePreference extends IntPreference {
String get title => 'Default font size';
}
class TabOrderPreference extends IntPreference {
final class TabOrderPreference extends IntPreference {
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
static final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
@override
TabOrderPreference copyWith({required int? val}) {
return TabOrderPreference(val: val);
@ -476,10 +490,13 @@ class TabOrderPreference extends IntPreference {
String get title => 'Tab order';
}
class StoryMarkingModePreference extends IntPreference {
final class StoryMarkingModePreference extends IntPreference {
StoryMarkingModePreference({int? val})
: super(val: val ?? _markStoriesAsReadWhenPreferenceDefaultValue);
static final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index;
@override
StoryMarkingModePreference copyWith({required int? val}) {
return StoryMarkingModePreference(val: val);
@ -492,9 +509,12 @@ class StoryMarkingModePreference extends IntPreference {
String get title => 'Mark as Read on';
}
class AppColorPreference extends IntPreference {
final class AppColorPreference extends IntPreference {
AppColorPreference({int? val}) : super(val: val ?? _appColorDefaultValue);
static final int _appColorDefaultValue =
materialColors.indexOf(Palette.deepOrange);
@override
AppColorPreference copyWith({required int? val}) {
return AppColorPreference(val: val);
@ -507,10 +527,12 @@ class AppColorPreference extends IntPreference {
String get title => 'Accent Color';
}
class TextScaleFactorPreference extends DoublePreference {
final class TextScaleFactorPreference extends DoublePreference {
const TextScaleFactorPreference({double? val})
: super(val: val ?? _textScaleFactorDefaultValue);
static const double _textScaleFactorDefaultValue = 1;
@override
TextScaleFactorPreference copyWith({required double? val}) {
return TextScaleFactorPreference(val: val);
@ -522,3 +544,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

@ -223,6 +223,9 @@ class HackerNewsRepository {
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
@ -258,6 +261,9 @@ class HackerNewsRepository {
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,

View File

@ -0,0 +1,287 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
import 'package:html/dom.dart' hide Comment;
import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
HackerNewsWebRepository({
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
_dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
CacheInterceptor(),
],
);
final Dio _dioWithCache;
final Dio _dio;
static const Map<String, String> _headers = <String, String>{
'accept': '*/*',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
};
static const String _favoritesBaseUrl =
'https://news.ycombinator.com/favorites?id=';
static const String _aThingSelector =
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing';
Future<Iterable<int>> fetchFavorites({required String of}) async {
final bool isOnWifi = await _isOnWifi;
final String username = of;
final List<int> allIds = <int>[];
int page = 1;
const int maxPage = 2;
Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
try {
final Uri url = Uri.parse(
'''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
);
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(url);
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
final Document document = parse(response.data);
final List<Element> elements =
document.querySelectorAll(_aThingSelector);
final Iterable<int> parsedIds =
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
return parsedIds;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedException();
}
throw GenericException();
}
}
Iterable<int> ids;
while (page <= maxPage) {
ids = await fetchIds(page);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
page = 1;
while (page <= maxPage) {
ids = await fetchIds(page, isComment: true);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
return allIds;
}
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
static const String _athingComtrSelector =
'#hnmain > tbody > tr > 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''';
Stream<Comment> fetchCommentsStream(Item item) async* {
final bool isOnWifi = await _isOnWifi;
final int itemId = item.id;
final int? descendants = item is Story ? item.descendants : null;
int parentTextCount = 0;
Future<Iterable<Element>> fetchElements(int page) async {
try {
final Uri url = Uri.parse('$_itemBaseUrl$itemId&p=$page');
final Options option = Options(
headers: _headers,
persistentConnection: true,
);
/// Be more conservative while user is on wifi.
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(
url,
options: option,
);
final String data = response.data ?? '';
if (page == 1) {
parentTextCount = 'parent'.allMatches(data).length;
}
final Document document = parse(data);
final List<Element> elements =
document.querySelectorAll(_athingComtrSelector);
return elements;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
}
throw GenericException();
}
}
if (descendants == 0 || item.kids.isEmpty) return;
final Set<int> fetchedCommentIds = <int>{};
int page = 1;
Iterable<Element> elements = await fetchElements(page);
final Map<int, int> indentToParentId = <int, int>{};
if (item is Story && item.descendants > 0 && elements.isEmpty) {
throw PossibleParsingException(itemId: itemId);
}
while (elements.isNotEmpty) {
for (final Element element in elements) {
/// Get comment id.
final String cmtIdString = element.attributes['id'] ?? '';
final int? cmtId = int.tryParse(cmtIdString);
/// Get comment text.
final Element? cmtTextElement =
element.querySelector(_commentTextSelector);
final String parsedText = await compute(
_parseCommentTextHtml,
cmtTextElement?.innerHtml ?? '',
);
/// Get comment author.
final Element? cmtHeadElement =
element.querySelector(_commentHeadSelector);
final String? cmtAuthor = cmtHeadElement?.text;
/// Get comment age.
final Element? cmtAgeElement =
element.querySelector(_commentAgeSelector);
final String? ageString = cmtAgeElement?.attributes['title'];
final int? timestamp = ageString == null
? null
: DateTime.parse(ageString)
.copyWith(isUtc: true)
.millisecondsSinceEpoch;
/// Get comment indent.
final Element? cmtIndentElement =
element.querySelector(_commentIndentSelector);
final String? indentString = cmtIndentElement?.attributes['indent'];
final int indent =
indentString == null ? 0 : (int.tryParse(indentString) ?? 0);
indentToParentId[indent] = cmtId ?? 0;
final int parentId = indentToParentId[indent - 1] ?? -1;
final Comment cmt = Comment(
id: cmtId ?? 0,
time: timestamp ?? 0,
parent: parentId,
score: 0,
by: cmtAuthor ?? '',
text: parsedText,
kids: const <int>[],
dead: false,
deleted: false,
hidden: false,
level: indent,
isFromCache: false,
);
/// Skip any comment with no valid id or timestamp.
if (cmt.id == 0 || timestamp == 0) {
continue;
}
/// Duplicate comment means we are done fetching all the comments.
if (fetchedCommentIds.contains(cmt.id)) return;
fetchedCommentIds.add(cmt.id);
yield cmt;
}
/// If we didn't successfully got any comment on first page,
/// and we are sure there are comments there based on the count of
/// 'parent' text, then this might be a parsing error and possibly is
/// caused by HN changing their HTML structure, therefore here we
/// throw an error so that we can fallback to use API instead.
if (page == 1 && parentTextCount > 0 && fetchedCommentIds.isEmpty) {
throw PossibleParsingException(itemId: itemId);
}
if (descendants != null && fetchedCommentIds.length >= descendants) {
return;
}
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
page++;
elements = await fetchElements(page);
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
static Future<String> _parseCommentTextHtml(String text) async {
return HtmlUnescape()
.convert(text)
.replaceAllMapped(
RegExp(
r'\<div class="reply"\>(.*?)\<\/div\>',
dotAll: true,
),
(Match match) => '',
)
.replaceAllMapped(
RegExp(
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
dotAll: true,
),
(Match match) => '${match[2]}',
)
.replaceAllMapped(
RegExp(
r'\<p\>(.*?)\<\/p\>',
dotAll: true,
),
(Match match) => '\n\n${match[1]}',
)
.replaceAllMapped(
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(Match match) => match[1] ?? '',
)
.replaceAllMapped(
RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*',
)
.trim();
}
}

View File

@ -185,7 +185,9 @@ class OfflineRepository {
if (json == null) {
return null;
}
final Comment comment = Comment.fromJson(json.cast<String, dynamic>());
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson);
return comment;
} catch (_) {
_logger.e(_);
@ -204,8 +206,9 @@ class OfflineRepository {
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) {
final Comment comment =
Comment.fromJson(json.cast<String, dynamic>(), level: level);
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson, level: level);
yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);

View File

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository].
@ -40,7 +39,7 @@ class PostableRepository {
}
return true;
} on ServiceException {
} on AppException {
return false;
}
}
@ -65,7 +64,7 @@ class PostableRepository {
),
);
} on DioException catch (e) {
throw ServiceException(e.message);
throw AppException(message: e.message);
}
}

View File

@ -157,7 +157,6 @@ class PreferenceRepository {
((prefs.getStringList(_getFavKey('')) ?? <String>[])
..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[]))
.map(int.parse)
.toSet()
.toList();
return favList;
@ -175,7 +174,7 @@ class PreferenceRepository {
await _syncedPrefs.setStringList(
key: key,
val: favList.map((int e) => e.toString()).toSet().toList(),
val: favList.map((int e) => e.toString()).toList(),
);
} else {
final SharedPreferences prefs = await _prefs;
@ -186,7 +185,30 @@ class PreferenceRepository {
await prefs.setStringList(
key,
favList.map((int e) => e.toString()).toSet().toList(),
favList.map((int e) => e.toString()).toList(),
);
}
}
Future<void> overwriteFav({
required String username,
required Iterable<int> ids,
}) async {
final String key = _getFavKey(username);
final List<String> favList =
ids.map((int e) => e.toString()).toList(growable: false);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: favList,
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
favList,
);
}
}

View File

@ -1,5 +1,6 @@
export 'auth_repository.dart';
export 'hacker_news_repository.dart';
export 'hacker_news_web_repository.dart';
export 'offline_repository.dart';
export 'post_repository.dart';
export 'preference_repository.dart';

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
@ -12,23 +14,34 @@ import 'package:sembast/sembast_io.dart';
/// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory].
class SembastRepository {
SembastRepository({Database? database}) {
SembastRepository({
Database? database,
Database? cache,
}) {
if (database == null) {
initializeDatabase();
} else {
_database = database;
}
if (cache == null) {
initializeCache();
} else {
_cache = cache;
}
}
Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe;
static const String _cachedCommentsKey = 'cachedComments';
static const String _commentsKey = 'comments';
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
static const String _metadataCacheKey = 'metadata';
Future<Database> initializeDatabase() async {
final Directory dir = await getApplicationDocumentsDirectory();
final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
final DatabaseFactory dbFactory = databaseFactoryIo;
@ -37,6 +50,16 @@ class SembastRepository {
return db;
}
Future<Database> initializeCache() async {
final Directory dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki_cache.db');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;
return db;
}
//#region Cached comments for time machine feature.
Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase();
@ -177,10 +200,50 @@ class SembastRepository {
//#endregion
Future<FileSystemEntity> deleteAll() async {
//#region
Future<void> cacheMetadata({
required String key,
required WebInfo info,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
return db.transaction((Transaction txn) async {
await store.record(key).put(txn, info.toJson());
});
}
Future<WebInfo?> getCachedMetadata({
required String key,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
final RecordSnapshot<String, Map<String, Object?>>? snapshot =
await store.record(key).getSnapshot(db);
if (snapshot != null) {
final WebInfo info = WebInfo.fromJson(snapshot.value);
return info;
} else {
return null;
}
}
//#endregion
Future<FileSystemEntity> deleteCachedComments() async {
final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete();
}
Future<FileSystemEntity> 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();
}
}

View File

@ -49,9 +49,9 @@ 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(
Durations.ms500,
AppDurations.ms500,
locator.get<CommentCache>().resetComments,
);
}
@ -141,15 +141,8 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
CustomTabBar(
tabController: tabController,
),
],
),

View File

@ -45,7 +45,8 @@ class PinnedStories extends StatelessWidget {
],
),
child: ColoredBox(
color: Theme.of(context).primaryColor.withOpacity(0.2),
color:
Theme.of(context).colorScheme.primary.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
@ -61,7 +62,7 @@ class PinnedStories extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Theme.of(context).primaryColor.withOpacity(0.8),
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
),
),
],

View File

@ -37,7 +37,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: Durations.ms300,
duration: AppDurations.ms300,
curve: Curves.elasticOut,
child: homeScreen,
),
@ -53,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: Durations.ms300,
duration: AppDurations.ms300,
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),

View File

@ -69,6 +69,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -79,6 +80,8 @@ class ItemScreen extends StatefulWidget {
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
),
],
@ -92,15 +95,15 @@ class ItemScreen extends StatefulWidget {
}
static Widget tablet(BuildContext context, ItemScreenArgs args) {
return WillPopScope(
onWillPop: () async {
return PopScope(
canPop: () {
if (context.read<SplitViewCubit>().state.expanded) {
context.read<SplitViewCubit>().zoom();
return false;
} else {
return true;
}
},
}(),
child: RepositoryProvider<CollapseCache>(
create: (_) => CollapseCache(),
lazy: false,
@ -110,6 +113,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -121,6 +125,8 @@ class ItemScreen extends StatefulWidget {
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
),
],
@ -160,9 +166,9 @@ class _ItemScreenState extends State<ItemScreen>
final GlobalKey fontSizeIconButtonKey = GlobalKey();
StreamSubscription<double>? scrollOffsetSubscription;
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
static const Duration _storyLinkTapThrottleDelay = AppDurations.twoSeconds;
static const Duration _featureDiscoveryDismissThrottleDelay =
Durations.oneSecond;
AppDurations.oneSecond;
@override
void didPop() {
@ -300,6 +306,7 @@ class _ItemScreenState extends State<ItemScreen>
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
context: context,
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
@ -342,6 +349,7 @@ class _ItemScreenState extends State<ItemScreen>
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
context: context,
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
foregroundColor: Theme.of(context).iconTheme.color,
@ -413,7 +421,7 @@ class _ItemScreenState extends State<ItemScreen>
fontSize: fontSize.fontSize,
color:
context.read<PreferenceCubit>().state.fontSize == fontSize
? Theme.of(context).primaryColor
? Theme.of(context).colorScheme.primary
: null,
),
),

View File

@ -7,6 +7,7 @@ import 'package:hacki/utils/utils.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
required BuildContext context,
required Item item,
required super.backgroundColor,
required super.foregroundColor,
@ -44,8 +45,9 @@ class CustomAppBar extends AppBar {
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textScaleFactor: 1,
textScaler: TextScaler.noScaling,
),
onPressed: onFontSizeTap,
),

Some files were not shown because too many files have changed in this diff Show More