Compare commits

...

43 Commits

Author SHA1 Message Date
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
146 changed files with 2340 additions and 1313 deletions

View File

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

View File

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

View File

@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
compileSdkVersion 33 compileSdkVersion 34
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
@ -51,7 +51,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.jiaqifeng.hacki" applicationId "com.jiaqifeng.hacki"
minSdkVersion 25 minSdkVersion 25
targetSdkVersion 33 targetSdkVersion 34
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }

View File

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

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> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>11.0</string> <string>12.0</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,7 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.5) CFPropertyList (3.0.6)
rexml rexml
activesupport (6.1.7) activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@ -9,28 +9,28 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.1) addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5) algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3) httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1) json (>= 1.5.1)
artifactory (3.0.15) artifactory (3.0.15)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.2.0) aws-eventstream (1.3.0)
aws-partitions (1.680.0) aws-partitions (1.889.0)
aws-sdk-core (3.168.4) aws-sdk-core (3.191.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.61.0) aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.117.2) aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.8)
aws-sigv4 (1.5.2) aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
claide (1.1.0) claide (1.1.0)
@ -77,7 +77,7 @@ GEM
highline (~> 2.0.0) highline (~> 2.0.0)
concurrent-ruby (1.1.10) concurrent-ruby (1.1.10)
declarative (0.0.20) declarative (0.0.20)
digest-crc (0.6.4) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
@ -86,8 +86,8 @@ GEM
escape (0.0.4) escape (0.0.4)
ethon (0.15.0) ethon (0.15.0)
ffi (>= 1.15.0) ffi (>= 1.15.0)
excon (0.95.0) excon (0.109.0)
faraday (1.10.2) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -115,8 +115,8 @@ GEM
faraday-retry (1.0.3) faraday-retry (1.0.3)
faraday_middleware (1.2.0) faraday_middleware (1.2.0)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.2.6) fastimage (2.3.0)
fastlane (2.211.0) fastlane (2.219.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
@ -135,20 +135,22 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0) json (< 3.0.0)
jwt (>= 2.1.0, < 3) jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0) multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2) naturally (~> 2.2)
optparse (~> 0.1.1) optparse (>= 0.1.1)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3) security (= 0.1.3)
simctl (~> 1.6.3) simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0) 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-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0) word_wrap (~> 1.0.0)
@ -159,9 +161,9 @@ GEM
fourflusher (2.3.1) fourflusher (2.3.1)
fuzzy_match (2.0.4) fuzzy_match (2.0.4)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.32.0) google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.9.1, < 2.a) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.9.2) google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@ -169,31 +171,29 @@ GEM
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick google-apis-iamcredentials_v1 (0.17.0)
google-apis-iamcredentials_v1 (0.16.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.9.1, < 2.a) google-apis-playcustomapp_v1 (0.13.0)
google-apis-playcustomapp_v1 (0.12.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.9.1, < 2.a) google-apis-storage_v1 (0.29.0)
google-apis-storage_v1 (0.19.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.9.0, < 2.a) google-cloud-core (1.6.1)
google-cloud-core (1.6.0) google-cloud-env (>= 1.0, < 3.a)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0) google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0) google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0) google-cloud-storage (1.45.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) 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) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.3.0) googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
@ -204,49 +204,48 @@ GEM
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.6.3) json (2.7.1)
jwt (2.5.0) jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.2) mini_mime (1.1.5)
minitest (5.16.3) minitest (5.16.3)
molinillo (0.8.0) molinillo (0.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.0.0) multipart-post (2.4.0)
nanaimo (0.3.0) nanaimo (0.3.0)
nap (1.1.0) nap (1.1.0)
naturally (2.2.1) naturally (2.2.1)
netrc (0.11.0) netrc (0.11.0)
optparse (0.1.1) optparse (0.4.0)
os (1.1.4) os (1.1.4)
plist (3.6.0) plist (3.7.1)
public_suffix (4.0.7) public_suffix (4.0.7)
rake (13.0.6) rake (13.1.0)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.5) rexml (3.2.6)
rouge (2.0.7) rouge (2.0.7)
ruby-macho (2.5.1) ruby-macho (2.5.1)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
security (0.1.3) security (0.1.3)
signet (0.17.0) signet (0.18.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simctl (1.6.8) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (1.8.0) terminal-table (3.0.2)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-screen (0.8.1) tty-screen (0.8.2)
tty-spinner (0.9.3) tty-spinner (0.9.3)
tty-cursor (~> 0.7) tty-cursor (~> 0.7)
typhoeus (1.4.0) typhoeus (1.4.0)
@ -256,11 +255,10 @@ GEM
uber (0.1.0) uber (0.1.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.9.1)
unicode-display_width (1.8.0) unicode-display_width (2.5.0)
webrick (1.7.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.22.0) xcodeproj (1.24.0)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3) atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0) claide (>= 1.0.2, < 2.0)
@ -275,6 +273,7 @@ GEM
PLATFORMS PLATFORMS
universal-darwin-21 universal-darwin-21
universal-darwin-23
x86_64-darwin-19 x86_64-darwin-19
DEPENDENCIES DEPENDENCIES

View File

@ -34,5 +34,8 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = 15.0
end
end end
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,7 @@ abstract class RegExpConstants {
static const String number = '[0-9]+'; static const String number = '[0-9]+';
} }
abstract class Durations { abstract class AppDurations {
static const Duration ms100 = Duration(milliseconds: 100); static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200); static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300); static const Duration ms300 = Duration(milliseconds: 300);
@ -83,4 +83,7 @@ abstract class Durations {
static const Duration oneSecond = Duration(seconds: 1); static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2); static const Duration twoSeconds = Duration(seconds: 2);
static const Duration tenSeconds = Duration(seconds: 10); 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<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository()) ..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository()) ..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository()) ..registerSingleton<SearchRepository>(SearchRepository())
..registerSingleton<AuthRepository>(AuthRepository()) ..registerSingleton<AuthRepository>(AuthRepository())

View File

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

View File

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

View File

@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'fav_state.dart'; part 'fav_state.dart';
@ -13,12 +18,17 @@ class FavCubit extends Cubit<FavState> {
AuthRepository? authRepository, AuthRepository? authRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(), preferenceRepository ?? locator.get<PreferenceRepository>(),
_hackerNewsRepository = _hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -27,43 +37,42 @@ class FavCubit extends Cubit<FavState> {
final AuthRepository _authRepository; final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 20;
String? _username;
Future<void> init() async { Future<void> init() async {
_authBloc.stream.listen((AuthState authState) { _usernameSubscription = _authBloc.stream
if (authState.username != _username) { .map((AuthState event) => event.username)
_preferenceRepository .distinct()
.favList(of: authState.username) .listen((String username) {
.then((List<int> favIds) { _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( emit(
state.copyWith( state.copyWith(
favIds: favIds, status: Status.success,
favItems: <Item>[],
currentPage: 0,
), ),
); );
_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 { 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); await _preferenceRepository.addFav(username: username, id: id);
@ -89,9 +98,9 @@ class FavCubit extends Cubit<FavState> {
} }
void removeFav(int id) { 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( emit(
state.copyWith( state.copyWith(
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
} }
void refresh() { void refresh() {
final String username = _authBloc.state.username;
emit( emit(
state.copyWith( state.copyWith(
status: Status.inProgress, status: Status.inProgress,
@ -167,6 +174,34 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init()); 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) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( 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.favIds,
required this.favItems, required this.favItems,
required this.status, required this.status,
required this.mergeStatus,
required this.currentPage, required this.currentPage,
}); });
@ -12,23 +13,27 @@ class FavState extends Equatable {
: favIds = <int>[], : favIds = <int>[],
favItems = <Item>[], favItems = <Item>[],
status = Status.idle, status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0; currentPage = 0;
final List<int> favIds; final List<int> favIds;
final List<Item> favItems; final List<Item> favItems;
final Status status; final Status status;
final Status mergeStatus;
final int currentPage; final int currentPage;
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
List<Item>? favItems, List<Item>? favItems,
Status? status, Status? status,
Status? mergeStatus,
int? currentPage, int? currentPage,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
favItems: favItems ?? this.favItems, favItems: favItems ?? this.favItems,
status: status ?? this.status, status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -36,6 +41,7 @@ class FavState extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
mergeStatus,
currentPage, currentPage,
favIds, favIds,
favItems, favItems,

View File

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

View File

@ -70,14 +70,14 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>(); bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>(); bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>(); bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>(); bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get devModeEnabled => _isOn<DevMode>();
double get textScaleFactor => double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val; preferences.singleWhereType<TextScaleFactorPreference>().val;
@ -119,6 +119,9 @@ class PreferenceState extends Equatable {
Font get font => Font get font =>
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val); Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
DisplayDateFormat get displayDateFormat => DisplayDateFormat.values
.elementAt(preferences.singleWhereType<DateFormatPreference>().val);
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
...preferences.map<dynamic>((Preference<dynamic> e) => e.val), ...preferences.map<dynamic>((Preference<dynamic> e) => e.val),

View File

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

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( addStoryToFavList(
featureId: 'add_story_to_fav_list', featureId: 'add_story_to_fav_list',
title: 'Fav a Story', title: 'Fav a Story',
description: '''Add it to your favorites''', description: '''Add it to your favorites.''',
), ),
openStoryInWebView( openStoryInWebView(
featureId: 'open_story_in_web_view', featureId: 'open_story_in_web_view',

View File

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:intl/intl.dart';
enum DisplayDateFormat {
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) {
if (_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 (this == timeAgo) {
final String dateString = date.toTimeAgoString();
_cache[timestamp] = dateString;
return dateString;
} 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

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

View File

@ -3,26 +3,11 @@ enum Font {
robotoSlab('Roboto Slab', isSerif: true), robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'), ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'), ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true); notoSerif('Noto Serif', isSerif: true),
exo2('Exo 2');
const Font(this.uiLabel, {this.isSerif = false}); const Font(this.uiLabel, {this.isSerif = false});
final String uiLabel; final String uiLabel;
final bool isSerif; 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> kids;
final List<int> parts; final List<int> parts;
String get timeAgo => String get timeAgo {
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString(); int time = this.time;
if (time < 9999999999) {
time = time * 1000;
}
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
}
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';

View File

@ -1,5 +1,7 @@
export 'app_exception.dart';
export 'comments_order.dart'; export 'comments_order.dart';
export 'discoverable_feature.dart'; export 'discoverable_feature.dart';
export 'display_date_format.dart';
export 'export_destination.dart'; export 'export_destination.dart';
export 'fetch_mode.dart'; export 'fetch_mode.dart';
export 'font.dart'; export 'font.dart';

View File

@ -7,7 +7,7 @@ import 'package:hacki/models/displayable.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/styles/palette.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}); const Preference({required this.val});
final T val; final T val;
@ -19,7 +19,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences = static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>( UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[ <Preference<dynamic>>[
// Order of these preferences does not matter. // Order of these preferences does not matter.
FetchModePreference(), FetchModePreference(),
CommentsOrderPreference(), CommentsOrderPreference(),
FontPreference(), FontPreference(),
@ -27,15 +27,16 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
TabOrderPreference(), TabOrderPreference(),
StoryMarkingModePreference(), StoryMarkingModePreference(),
AppColorPreference(), AppColorPreference(),
DateFormatPreference(),
const TextScaleFactorPreference(), const TextScaleFactorPreference(),
// Order of items below matters and // Order of items below matters and
// reflects the order on settings screen. // reflects the order on settings screen.
const DisplayModePreference(), const DisplayModePreference(),
const MetadataModePreference(), const MetadataModePreference(),
const StoryUrlModePreference(), const StoryUrlModePreference(),
// Divider. // Divider.
const MarkReadStoriesModePreference(), const MarkReadStoriesModePreference(),
// Divider. // Divider.
const NotificationModePreference(), const NotificationModePreference(),
const AutoScrollModePreference(), const AutoScrollModePreference(),
const CollapseModePreference(), const CollapseModePreference(),
@ -46,7 +47,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const HapticFeedbackPreference(), const HapticFeedbackPreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
const TrueDarkModePreference(), const TrueDarkModePreference(),
const Material3Preference(), const DevMode(),
], ],
); );
@ -54,48 +55,47 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
List<Object?> get props => <Object?>[key]; List<Object?> get props => <Object?>[key];
} }
abstract class BooleanPreference extends Preference<bool> { abstract final class BooleanPreference extends Preference<bool> {
const BooleanPreference({required super.val}); const BooleanPreference({required super.val});
} }
abstract class IntPreference extends Preference<int> { abstract final class IntPreference extends Preference<int> {
const IntPreference({required super.val}); const IntPreference({required super.val});
} }
abstract class DoublePreference extends Preference<double> { abstract final class DoublePreference extends Preference<double> {
const DoublePreference({required super.val}); const DoublePreference({required super.val});
} }
const bool _notificationModeDefaultValue = true; final class DevMode extends BooleanPreference {
const bool _swipeGestureModeDefaultValue = false; const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
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;
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}) const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue); : super(val: val ?? _swipeGestureModeDefaultValue);
static const bool _swipeGestureModeDefaultValue = false;
@override @override
SwipeGesturePreference copyWith({required bool? val}) { SwipeGesturePreference copyWith({required bool? val}) {
return SwipeGesturePreference(val: 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.'''; '''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}) const NotificationModePreference({bool? val})
: super(val: val ?? _notificationModeDefaultValue); : super(val: val ?? _notificationModeDefaultValue);
static const bool _notificationModeDefaultValue = true;
@override @override
NotificationModePreference copyWith({required bool? val}) { NotificationModePreference copyWith({required bool? val}) {
return NotificationModePreference(val: 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.'''; '''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}) const CollapseModePreference({bool? val})
: super(val: val ?? _collapseModeDefaultValue); : super(val: val ?? _collapseModeDefaultValue);
static const bool _collapseModeDefaultValue = true;
@override @override
CollapseModePreference copyWith({required bool? val}) { CollapseModePreference copyWith({required bool? val}) {
return CollapseModePreference(val: val); return CollapseModePreference(val: val);
@ -152,10 +156,12 @@ class CollapseModePreference extends BooleanPreference {
'''if disabled, tap on the top of comment tile to collapse.'''; '''if disabled, tap on the top of comment tile to collapse.''';
} }
class AutoScrollModePreference extends BooleanPreference { final class AutoScrollModePreference extends BooleanPreference {
const AutoScrollModePreference({bool? val}) const AutoScrollModePreference({bool? val})
: super(val: val ?? _autoScrollModeDefaultValue); : super(val: val ?? _autoScrollModeDefaultValue);
static const bool _autoScrollModeDefaultValue = false;
@override @override
AutoScrollModePreference copyWith({required bool? val}) { AutoScrollModePreference copyWith({required bool? val}) {
return AutoScrollModePreference(val: val); return AutoScrollModePreference(val: val);
@ -165,7 +171,7 @@ class AutoScrollModePreference extends BooleanPreference {
String get key => 'autoScrollMode'; String get key => 'autoScrollMode';
@override @override
String get title => 'Auto-scroll on collapsing'; String get title => 'Auto-scroll on Collapsing';
@override @override
String get subtitle => String get subtitle =>
@ -174,10 +180,12 @@ class AutoScrollModePreference extends BooleanPreference {
/// The value deciding whether or not the story /// The value deciding whether or not the story
/// tile should display link preview. Defaults to true. /// tile should display link preview. Defaults to true.
class DisplayModePreference extends BooleanPreference { final class DisplayModePreference extends BooleanPreference {
const DisplayModePreference({bool? val}) const DisplayModePreference({bool? val})
: super(val: val ?? _displayModeDefaultValue); : super(val: val ?? _displayModeDefaultValue);
static const bool _displayModeDefaultValue = true;
@override @override
DisplayModePreference copyWith({required bool? val}) { DisplayModePreference copyWith({required bool? val}) {
return DisplayModePreference(val: val); return DisplayModePreference(val: val);
@ -193,10 +201,12 @@ class DisplayModePreference extends BooleanPreference {
String get subtitle => 'show web preview in story tile.'; String get subtitle => 'show web preview in story tile.';
} }
class MetadataModePreference extends BooleanPreference { final class MetadataModePreference extends BooleanPreference {
const MetadataModePreference({bool? val}) const MetadataModePreference({bool? val})
: super(val: val ?? _metadataModeDefaultValue); : super(val: val ?? _metadataModeDefaultValue);
static const bool _metadataModeDefaultValue = true;
@override @override
MetadataModePreference copyWith({required bool? val}) { MetadataModePreference copyWith({required bool? val}) {
return MetadataModePreference(val: val); return MetadataModePreference(val: val);
@ -213,10 +223,12 @@ class MetadataModePreference extends BooleanPreference {
'''show number of comments and post date in story tile.'''; '''show number of comments and post date in story tile.''';
} }
class StoryUrlModePreference extends BooleanPreference { final class StoryUrlModePreference extends BooleanPreference {
const StoryUrlModePreference({bool? val}) const StoryUrlModePreference({bool? val})
: super(val: val ?? _storyUrlModeDefaultValue); : super(val: val ?? _storyUrlModeDefaultValue);
static const bool _storyUrlModeDefaultValue = true;
@override @override
StoryUrlModePreference copyWith({required bool? val}) { StoryUrlModePreference copyWith({required bool? val}) {
return StoryUrlModePreference(val: val); return StoryUrlModePreference(val: val);
@ -232,10 +244,12 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.'''; String get subtitle => '''show url in story tile.''';
} }
class ReaderModePreference extends BooleanPreference { final class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val}) const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue); : super(val: val ?? _readerModeDefaultValue);
static const bool _readerModeDefaultValue = true;
@override @override
ReaderModePreference copyWith({required bool? val}) { ReaderModePreference copyWith({required bool? val}) {
return ReaderModePreference(val: val); return ReaderModePreference(val: val);
@ -255,10 +269,12 @@ class ReaderModePreference extends BooleanPreference {
bool get isDisplayable => Platform.isIOS; bool get isDisplayable => Platform.isIOS;
} }
class MarkReadStoriesModePreference extends BooleanPreference { final class MarkReadStoriesModePreference extends BooleanPreference {
const MarkReadStoriesModePreference({bool? val}) const MarkReadStoriesModePreference({bool? val})
: super(val: val ?? _markReadStoriesModeDefaultValue); : super(val: val ?? _markReadStoriesModeDefaultValue);
static const bool _markReadStoriesModeDefaultValue = true;
@override @override
MarkReadStoriesModePreference copyWith({required bool? val}) { MarkReadStoriesModePreference copyWith({required bool? val}) {
return MarkReadStoriesModePreference(val: val); return MarkReadStoriesModePreference(val: val);
@ -274,10 +290,12 @@ class MarkReadStoriesModePreference extends BooleanPreference {
String get subtitle => 'grey out stories you have read.'; String get subtitle => 'grey out stories you have read.';
} }
class EyeCandyModePreference extends BooleanPreference { final class EyeCandyModePreference extends BooleanPreference {
const EyeCandyModePreference({bool? val}) const EyeCandyModePreference({bool? val})
: super(val: val ?? _eyeCandyModeDefaultValue); : super(val: val ?? _eyeCandyModeDefaultValue);
static const bool _eyeCandyModeDefaultValue = false;
@override @override
EyeCandyModePreference copyWith({required bool? val}) { EyeCandyModePreference copyWith({required bool? val}) {
return EyeCandyModePreference(val: val); return EyeCandyModePreference(val: val);
@ -293,10 +311,12 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.'; String get subtitle => 'some sort of magic.';
} }
class ManualPaginationPreference extends BooleanPreference { final class ManualPaginationPreference extends BooleanPreference {
const ManualPaginationPreference({bool? val}) const ManualPaginationPreference({bool? val})
: super(val: val ?? _paginationModeDefaultValue); : super(val: val ?? _paginationModeDefaultValue);
static const bool _paginationModeDefaultValue = false;
@override @override
ManualPaginationPreference copyWith({required bool? val}) { ManualPaginationPreference copyWith({required bool? val}) {
return ManualPaginationPreference(val: val); return ManualPaginationPreference(val: val);
@ -312,34 +332,16 @@ class ManualPaginationPreference extends BooleanPreference {
String get subtitle => '''so you can get stuff done.'''; 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. /// Whether or not to use Custom Tabs for launching URLs.
/// If false, default browser will be used. /// If false, default browser will be used.
/// ///
/// https://developer.chrome.com/docs/android/custom-tabs/ /// https://developer.chrome.com/docs/android/custom-tabs/
class CustomTabPreference extends BooleanPreference { final class CustomTabPreference extends BooleanPreference {
const CustomTabPreference({bool? val}) const CustomTabPreference({bool? val})
: super(val: val ?? _customTabModeDefaultValue); : super(val: val ?? _customTabModeDefaultValue);
static const bool _customTabModeDefaultValue = false;
@override @override
CustomTabPreference copyWith({required bool? val}) { CustomTabPreference copyWith({required bool? val}) {
return CustomTabPreference(val: val); return CustomTabPreference(val: val);
@ -359,10 +361,12 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid; bool get isDisplayable => Platform.isAndroid;
} }
class TrueDarkModePreference extends BooleanPreference { final class TrueDarkModePreference extends BooleanPreference {
const TrueDarkModePreference({bool? val}) const TrueDarkModePreference({bool? val})
: super(val: val ?? _trueDarkModeDefaultValue); : super(val: val ?? _trueDarkModeDefaultValue);
static const bool _trueDarkModeDefaultValue = false;
@override @override
TrueDarkModePreference copyWith({required bool? val}) { TrueDarkModePreference copyWith({required bool? val}) {
return TrueDarkModePreference(val: val); return TrueDarkModePreference(val: val);
@ -378,10 +382,12 @@ class TrueDarkModePreference extends BooleanPreference {
String get subtitle => 'real dark.'; String get subtitle => 'real dark.';
} }
class HapticFeedbackPreference extends BooleanPreference { final class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val}) const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue); : super(val: val ?? _hapticFeedbackModeDefaultValue);
static const bool _hapticFeedbackModeDefaultValue = true;
@override @override
HapticFeedbackPreference copyWith({required bool? val}) { HapticFeedbackPreference copyWith({required bool? val}) {
return HapticFeedbackPreference(val: val); return HapticFeedbackPreference(val: val);
@ -395,14 +401,13 @@ class HapticFeedbackPreference extends BooleanPreference {
@override @override
String get subtitle => ''; 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); FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
static final int _fetchModeDefaultValue = FetchMode.eager.index;
@override @override
FetchModePreference copyWith({required int? val}) { FetchModePreference copyWith({required int? val}) {
return FetchModePreference(val: val); return FetchModePreference(val: val);
@ -415,10 +420,12 @@ class FetchModePreference extends IntPreference {
String get title => 'Default fetch mode'; String get title => 'Default fetch mode';
} }
class CommentsOrderPreference extends IntPreference { final class CommentsOrderPreference extends IntPreference {
CommentsOrderPreference({int? val}) CommentsOrderPreference({int? val})
: super(val: val ?? _commentsOrderDefaultValue); : super(val: val ?? _commentsOrderDefaultValue);
static final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@override @override
CommentsOrderPreference copyWith({required int? val}) { CommentsOrderPreference copyWith({required int? val}) {
return CommentsOrderPreference(val: val); return CommentsOrderPreference(val: val);
@ -431,9 +438,11 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order'; String get title => 'Default comments order';
} }
class FontPreference extends IntPreference { final class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue); FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
static final int _fontDefaultValue = Font.robotoSlab.index;
@override @override
FontPreference copyWith({required int? val}) { FontPreference copyWith({required int? val}) {
return FontPreference(val: val); return FontPreference(val: val);
@ -446,9 +455,11 @@ class FontPreference extends IntPreference {
String get title => 'Default font'; String get title => 'Default font';
} }
class FontSizePreference extends IntPreference { final class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue); FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
static final int _fontSizeDefaultValue = FontSize.regular.index;
@override @override
FontSizePreference copyWith({required int? val}) { FontSizePreference copyWith({required int? val}) {
return FontSizePreference(val: val); return FontSizePreference(val: val);
@ -461,9 +472,12 @@ class FontSizePreference extends IntPreference {
String get title => 'Default font size'; String get title => 'Default font size';
} }
class TabOrderPreference extends IntPreference { final class TabOrderPreference extends IntPreference {
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue); TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
static final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
@override @override
TabOrderPreference copyWith({required int? val}) { TabOrderPreference copyWith({required int? val}) {
return TabOrderPreference(val: val); return TabOrderPreference(val: val);
@ -476,10 +490,13 @@ class TabOrderPreference extends IntPreference {
String get title => 'Tab order'; String get title => 'Tab order';
} }
class StoryMarkingModePreference extends IntPreference { final class StoryMarkingModePreference extends IntPreference {
StoryMarkingModePreference({int? val}) StoryMarkingModePreference({int? val})
: super(val: val ?? _markStoriesAsReadWhenPreferenceDefaultValue); : super(val: val ?? _markStoriesAsReadWhenPreferenceDefaultValue);
static final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index;
@override @override
StoryMarkingModePreference copyWith({required int? val}) { StoryMarkingModePreference copyWith({required int? val}) {
return StoryMarkingModePreference(val: val); return StoryMarkingModePreference(val: val);
@ -492,9 +509,12 @@ class StoryMarkingModePreference extends IntPreference {
String get title => 'Mark as Read on'; String get title => 'Mark as Read on';
} }
class AppColorPreference extends IntPreference { final class AppColorPreference extends IntPreference {
AppColorPreference({int? val}) : super(val: val ?? _appColorDefaultValue); AppColorPreference({int? val}) : super(val: val ?? _appColorDefaultValue);
static final int _appColorDefaultValue =
materialColors.indexOf(Palette.deepOrange);
@override @override
AppColorPreference copyWith({required int? val}) { AppColorPreference copyWith({required int? val}) {
return AppColorPreference(val: val); return AppColorPreference(val: val);
@ -507,10 +527,12 @@ class AppColorPreference extends IntPreference {
String get title => 'Accent Color'; String get title => 'Accent Color';
} }
class TextScaleFactorPreference extends DoublePreference { final class TextScaleFactorPreference extends DoublePreference {
const TextScaleFactorPreference({double? val}) const TextScaleFactorPreference({double? val})
: super(val: val ?? _textScaleFactorDefaultValue); : super(val: val ?? _textScaleFactorDefaultValue);
static const double _textScaleFactorDefaultValue = 1;
@override @override
TextScaleFactorPreference copyWith({required double? val}) { TextScaleFactorPreference copyWith({required double? val}) {
return TextScaleFactorPreference(val: val); return TextScaleFactorPreference(val: val);
@ -522,3 +544,20 @@ class TextScaleFactorPreference extends DoublePreference {
@override @override
String get title => 'Default text scale factor'; String get title => 'Default text scale factor';
} }
final class DateFormatPreference extends IntPreference {
DateFormatPreference({int? val}) : super(val: val ?? _dateFormatDefaultValue);
static final int _dateFormatDefaultValue = DisplayDateFormat.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 /// Fetch a list of [Comment] based on ids and return results
/// using a stream. /// 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({ Stream<Comment> fetchCommentsStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
@ -258,6 +261,9 @@ class HackerNewsRepository {
/// Fetch a list of [Comment] based on ids recursively and /// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream. /// 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({ Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids, required List<int> ids,
int level = 0, 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) { if (json == null) {
return 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; return comment;
} catch (_) { } catch (_) {
_logger.e(_); _logger.e(_);
@ -204,8 +206,9 @@ class OfflineRepository {
final Map<dynamic, dynamic>? json = await box.get(id.toString()); final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) { if (json != null) {
final Comment comment = final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
Comment.fromJson(json.cast<String, dynamic>(), level: level); typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson, level: level);
yield comment; yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1); 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/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart'; import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart'; import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between /// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository]. /// [AuthRepository] and [PostRepository].
@ -40,7 +39,7 @@ class PostableRepository {
} }
return true; return true;
} on ServiceException { } on AppException {
return false; return false;
} }
} }
@ -65,7 +64,7 @@ class PostableRepository {
), ),
); );
} on DioException catch (e) { } 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>[]) ((prefs.getStringList(_getFavKey('')) ?? <String>[])
..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[])) ..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[]))
.map(int.parse) .map(int.parse)
.toSet()
.toList(); .toList();
return favList; return favList;
@ -175,7 +174,7 @@ class PreferenceRepository {
await _syncedPrefs.setStringList( await _syncedPrefs.setStringList(
key: key, key: key,
val: favList.map((int e) => e.toString()).toSet().toList(), val: favList.map((int e) => e.toString()).toList(),
); );
} else { } else {
final SharedPreferences prefs = await _prefs; final SharedPreferences prefs = await _prefs;
@ -186,7 +185,30 @@ class PreferenceRepository {
await prefs.setStringList( await prefs.setStringList(
key, 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 'auth_repository.dart';
export 'hacker_news_repository.dart'; export 'hacker_news_repository.dart';
export 'hacker_news_web_repository.dart';
export 'offline_repository.dart'; export 'offline_repository.dart';
export 'post_repository.dart'; export 'post_repository.dart';
export 'preference_repository.dart'; export 'preference_repository.dart';

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.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 /// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory]. /// by calling [getApplicationDocumentsDirectory].
class SembastRepository { class SembastRepository {
SembastRepository({Database? database}) { SembastRepository({
Database? database,
Database? cache,
}) {
if (database == null) { if (database == null) {
initializeDatabase(); initializeDatabase();
} else { } else {
_database = database; _database = database;
} }
if (cache == null) {
initializeCache();
} else {
_cache = cache;
}
} }
Database? _database; Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe; List<int>? _idsOfCommentsRepliedToMe;
static const String _cachedCommentsKey = 'cachedComments'; static const String _cachedCommentsKey = 'cachedComments';
static const String _commentsKey = 'comments'; static const String _commentsKey = 'comments';
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe'; static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
static const String _metadataCacheKey = 'metadata';
Future<Database> initializeDatabase() async { Future<Database> initializeDatabase() async {
final Directory dir = await getApplicationDocumentsDirectory(); final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
final DatabaseFactory dbFactory = databaseFactoryIo; final DatabaseFactory dbFactory = databaseFactoryIo;
@ -37,6 +50,16 @@ class SembastRepository {
return db; 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. //#region Cached comments for time machine feature.
Future<Map<String, Object?>> cacheComment(Comment comment) async { Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase(); final Database db = _database ?? await initializeDatabase();
@ -177,10 +200,50 @@ class SembastRepository {
//#endregion //#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(); final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete(); 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(); super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType == if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) { DeviceScreenType.mobile) {
locator.get<Logger>().i('Resetting comments in CommentCache'); locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed( Future<void>.delayed(
Durations.ms500, AppDurations.ms500,
locator.get<CommentCache>().resetComments, locator.get<CommentCache>().resetComments,
); );
} }
@ -141,15 +141,8 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox( SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8, height: MediaQuery.of(context).padding.top - Dimens.pt8,
), ),
Theme( CustomTabBar(
data: ThemeData( tabController: tabController,
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
), ),
], ],
), ),

View File

@ -45,7 +45,8 @@ class PinnedStories extends StatelessWidget {
], ],
), ),
child: ColoredBox( child: ColoredBox(
color: Theme.of(context).primaryColor.withOpacity(0.2), color:
Theme.of(context).colorScheme.primary.withOpacity(0.2),
child: StoryTile( child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'), key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story, story: story,
@ -61,7 +62,7 @@ class PinnedStories extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12), padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider( 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, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
width: homeScreenWidth, width: homeScreenWidth,
duration: Durations.ms300, duration: AppDurations.ms300,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: homeScreen, child: homeScreen,
), ),
@ -53,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth, left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: Durations.ms300, duration: AppDurations.ms300,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: const _TabletStoryView(), child: const _TabletStoryView(),
), ),

View File

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

View File

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

View File

@ -26,7 +26,7 @@ class CustomFloatingActionButton extends StatelessWidget {
bottom: Dimens.replyBoxCollapsedHeight, bottom: Dimens.replyBoxCollapsedHeight,
) )
: EdgeInsets.zero, : EdgeInsets.zero,
duration: Durations.ms200, duration: AppDurations.ms200,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[

View File

@ -30,8 +30,8 @@ class FavIconButton extends StatelessWidget {
child: Icon( child: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: isFav color: isFav
? Theme.of(context).primaryColor ? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color, : Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
onPressed: () { onPressed: () {

View File

@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart'; import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -65,9 +66,11 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
super.initState(); super.initState();
scrollController.addListener(onScroll); scrollController.addListener(onScroll);
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery; textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
if (textEditingController.text.isEmpty) { Future<void>.delayed(AppDurations.ms300, () {
focusNode.requestFocus(); if (textEditingController.text.isEmpty) {
} focusNode.requestFocus();
}
});
} }
@override @override
@ -110,14 +113,14 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
child: TextField( child: TextField(
controller: textEditingController, controller: textEditingController,
focusNode: focusNode, focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor, cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false, autocorrect: false,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search in this thread', hintText: 'Search in this thread',
suffixText: '${state.matchedComments.length} results', suffixText: '${state.matchedComments.length} results',
focusedBorder: UnderlineInputBorder( focusedBorder: UnderlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of(context).primaryColor, color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),

View File

@ -40,7 +40,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
width: Dimens.pt36, width: Dimens.pt36,
child: Center( child: Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Theme.of(context).primaryColor, color: Theme.of(context).colorScheme.primary,
), ),
), ),
) )
@ -51,13 +51,14 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
), ),
child: TextField( child: TextField(
controller: usernameController, controller: usernameController,
cursorColor: Theme.of(context).primaryColor, cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false, autocorrect: false,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Username', hintText: 'Username',
focusedBorder: UnderlineInputBorder( focusedBorder: UnderlineInputBorder(
borderSide: borderSide: BorderSide(
BorderSide(color: Theme.of(context).primaryColor), color: Theme.of(context).colorScheme.primary,
),
), ),
), ),
), ),
@ -71,14 +72,15 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
), ),
child: TextField( child: TextField(
controller: passwordController, controller: passwordController,
cursorColor: Theme.of(context).primaryColor, cursorColor: Theme.of(context).colorScheme.primary,
obscureText: true, obscureText: true,
autocorrect: false, autocorrect: false,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Password', hintText: 'Password',
focusedBorder: UnderlineInputBorder( focusedBorder: UnderlineInputBorder(
borderSide: borderSide: BorderSide(
BorderSide(color: Theme.of(context).primaryColor), color: Theme.of(context).colorScheme.primary,
),
), ),
), ),
), ),
@ -110,7 +112,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
? Icons.check_box ? Icons.check_box
: Icons.check_box_outline_blank, : Icons.check_box_outline_blank,
color: state.agreedToEULA color: state.agreedToEULA
? Theme.of(context).primaryColor ? Theme.of(context).colorScheme.primary
: Palette.grey, : Palette.grey,
), ),
onPressed: () => onPressed: () =>
@ -136,7 +138,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
child: Text( child: Text(
'End User Agreement', 'End User Agreement',
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -182,15 +184,15 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
style: ButtonStyle( style: ButtonStyle(
backgroundColor: MaterialStateProperty.all( backgroundColor: MaterialStateProperty.all(
state.agreedToEULA state.agreedToEULA
? Theme.of(context).primaryColor ? Theme.of(context).colorScheme.primary
: Palette.grey, : Palette.grey,
), ),
), ),
child: const Text( child: Text(
'Log in', 'Log in',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Palette.white, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
), ),

View File

@ -59,7 +59,12 @@ class MainView extends StatelessWidget {
if (context.read<StoriesBloc>().state.isOfflineReading == if (context.read<StoriesBloc>().state.isOfflineReading ==
false && false &&
state.onlyShowTargetComment == false) { state.onlyShowTargetComment == false) {
unawaited(context.read<CommentsCubit>().refresh()); unawaited(
context.read<CommentsCubit>().refresh(
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
);
if (state.item.isPoll) { if (state.item.isPoll) {
context.read<PollCubit>().refresh(); context.read<PollCubit>().refresh();
@ -145,27 +150,28 @@ class MainView extends StatelessWidget {
}, },
), ),
), ),
Positioned( if (context.read<PreferenceCubit>().state.devModeEnabled)
height: Dimens.pt4, Positioned(
bottom: Dimens.zero, height: Dimens.pt4,
left: Dimens.zero, bottom: Dimens.zero,
right: Dimens.zero, left: Dimens.zero,
child: BlocBuilder<CommentsCubit, CommentsState>( right: Dimens.zero,
buildWhen: (CommentsState prev, CommentsState current) => child: BlocBuilder<CommentsCubit, CommentsState>(
prev.status != current.status, buildWhen: (CommentsState prev, CommentsState current) =>
builder: (BuildContext context, CommentsState state) { prev.status != current.status,
return AnimatedOpacity( builder: (BuildContext context, CommentsState state) {
opacity: state.status == CommentsStatus.inProgress return AnimatedOpacity(
? NumSwitch.on opacity: state.status == CommentsStatus.inProgress
: NumSwitch.off, ? NumSwitch.on
duration: const Duration( : NumSwitch.off,
milliseconds: _loadingIndicatorOpacityAnimationDuration, duration: const Duration(
), milliseconds: _loadingIndicatorOpacityAnimationDuration,
child: const LinearProgressIndicator(), ),
); child: const LinearProgressIndicator(),
}, );
},
),
), ),
),
], ],
); );
} }
@ -190,9 +196,6 @@ class _ParentItemSection extends StatelessWidget {
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 85;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Item item = state.item; final Item item = state.item;
@ -221,14 +224,14 @@ class _ParentItemSection extends StatelessWidget {
} }
context.read<EditCubit>().onReplyTapped(item); context.read<EditCubit>().onReplyTapped(item);
}, },
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary, foregroundColor: Theme.of(context).colorScheme.onPrimary,
icon: Icons.message, icon: Icons.message,
), ),
SlidableAction( SlidableAction(
onPressed: (BuildContext context) => onPressed: (BuildContext context) =>
onMoreTapped(item, context.rect), onMoreTapped(item, context.rect),
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary, foregroundColor: Theme.of(context).colorScheme.onPrimary,
icon: Icons.more_horiz, icon: Icons.more_horiz,
), ),
@ -246,19 +249,21 @@ class _ParentItemSection extends StatelessWidget {
Text( Text(
item.by, item.by,
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).colorScheme.primary,
), ),
textScaleFactor: textScaler: MediaQuery.of(context).textScaler,
MediaQuery.of(context).textScaleFactor,
), ),
const Spacer(), const Spacer(),
Text( Text(
item.timeAgo, context
style: const TextStyle( .read<PreferenceCubit>()
color: Palette.grey, .state
.displayDateFormat
.convertToString(item.time),
style: TextStyle(
color: Theme.of(context).metadataColor,
), ),
textScaleFactor: textScaler: MediaQuery.of(context).textScaler,
MediaQuery.of(context).textScaleFactor,
), ),
], ],
), ),
@ -306,7 +311,7 @@ class _ParentItemSection extends StatelessWidget {
left: Dimens.pt6, left: Dimens.pt6,
right: Dimens.pt6, right: Dimens.pt6,
bottom: Dimens.pt12, bottom: Dimens.pt12,
top: Dimens.pt12, top: Dimens.pt6,
), ),
child: Text.rich( child: Text.rich(
TextSpan( TextSpan(
@ -318,7 +323,9 @@ class _ParentItemSection extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: fontSize, fontSize: fontSize,
color: item.url.isNotEmpty color: item.url.isNotEmpty
? Theme.of(context).primaryColor ? Theme.of(context)
.colorScheme
.primary
: null, : null,
), ),
), ),
@ -328,15 +335,15 @@ class _ParentItemSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: fontSize - 4, fontSize: fontSize - 4,
color: color: Theme.of(context)
Theme.of(context).primaryColor, .colorScheme
.primary,
), ),
), ),
], ],
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
textScaleFactor: textScaler: MediaQuery.of(context).textScaler,
MediaQuery.of(context).textScaleFactor,
), ),
), ),
) )
@ -354,8 +361,8 @@ class _ParentItemSection extends StatelessWidget {
), ),
child: ItemText( child: ItemText(
item: item, item: item,
textScaleFactor: textScaler:
MediaQuery.of(context).textScaleFactor, MediaQuery.of(context).textScaler,
selectable: true, selectable: true,
), ),
), ),
@ -393,29 +400,31 @@ class _ParentItemSection extends StatelessWidget {
height: Dimens.zero, height: Dimens.zero,
), ),
] else ...<Widget>[ ] else ...<Widget>[
Row( SizedBox(
children: <Widget>[ height: 48,
if (item is Story) ...<Widget>[ child: Row(
const SizedBox( children: <Widget>[
width: Dimens.pt12, if (item is Story) ...<Widget>[
), const SizedBox(
Text( width: Dimens.pt12,
'''${item.score} karma, ${item.descendants} comment${item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
), ),
textScaleFactor: 1, Text(
), '''${item.score} karma, ${item.descendants} cmt${item.descendants > 1 ? 's' : ''}''',
] else ...<Widget>[ style: Theme.of(context).textTheme.labelLarge,
const SizedBox( textScaler: MediaQuery.of(context).clampedTextScaler,
width: Dimens.pt4, ),
), ] else ...<Widget>[
SizedBox( const SizedBox(
width: _viewParentButtonWidth, width: Dimens.pt4,
child: TextButton( ),
onPressed: context.read<CommentsCubit>().loadParentThread, BlocSelector<CommentsCubit, CommentsState, CommentsStatus>(
child: selector: (CommentsState state) =>
state.fetchParentStatus == CommentsStatus.inProgress state.fetchParentStatus,
builder: (BuildContext context, CommentsStatus status) {
return TextButton(
onPressed:
context.read<CommentsCubit>().loadParentThread,
child: status == CommentsStatus.inProgress
? const SizedBox( ? const SizedBox(
height: Dimens.pt12, height: Dimens.pt12,
width: Dimens.pt12, width: Dimens.pt12,
@ -423,84 +432,73 @@ class _ParentItemSection extends StatelessWidget {
strokeWidth: Dimens.pt2, strokeWidth: Dimens.pt2,
), ),
) )
: const Text( : Text(
'View parent', 'View Parent',
style: TextStyle( style: Theme.of(context)
fontSize: TextDimens.pt13, .textTheme
), .labelLarge
textScaleFactor: 1, ?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
textScaler:
MediaQuery.of(context).clampedTextScaler,
), ),
);
},
), ),
BlocSelector<CommentsCubit, CommentsState, CommentsStatus>(
selector: (CommentsState state) => state.fetchRootStatus,
builder: (BuildContext context, CommentsStatus status) {
return TextButton(
onPressed:
context.read<CommentsCubit>().loadRootThread,
child: status == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: Text(
'View Root',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
textScaler:
MediaQuery.of(context).clampedTextScaler,
),
);
},
),
],
const Spacer(),
if (!state.isOfflineReading)
CustomDropdownMenu<FetchMode>(
menuChildren: FetchMode.values,
onSelected: context.read<CommentsCubit>().updateFetchMode,
selected: state.fetchMode,
),
const SizedBox(
width: Dimens.pt6,
), ),
SizedBox( CustomDropdownMenu<CommentsOrder>(
width: _viewRootButtonWidth, menuChildren: CommentsOrder.values,
child: TextButton( onSelected: context.read<CommentsCubit>().updateOrder,
onPressed: context.read<CommentsCubit>().loadRootThread, selected: state.order,
child: state.fetchRootStatus == CommentsStatus.inProgress ),
? const SizedBox( const SizedBox(
height: Dimens.pt12, width: Dimens.pt4,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View root',
style: TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
), ),
], ],
const Spacer(), ),
if (!state.isOfflineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().updateFetchMode,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().updateOrder,
),
const SizedBox(
width: Dimens.pt4,
),
],
), ),
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
@ -517,6 +515,9 @@ class _ParentItemSection extends StatelessWidget {
style: TextStyle(color: Palette.grey), style: TextStyle(color: Palette.grey),
), ),
), ),
const SizedBox(
height: 120,
),
], ],
], ],
), ),

View File

@ -83,7 +83,7 @@ class MorePopupMenu extends StatelessWidget {
children: <Widget>[ children: <Widget>[
AnimatedCrossFade( AnimatedCrossFade(
alignment: Alignment.center, alignment: Alignment.center,
duration: Durations.ms300, duration: AppDurations.ms300,
crossFadeState: state.status.isLoading crossFadeState: state.status.isLoading
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
@ -137,7 +137,9 @@ class MorePopupMenu extends StatelessWidget {
), ),
linkStyle: TextStyle( linkStyle: TextStyle(
fontSize: fontSize, fontSize: fontSize,
color: Theme.of(context).primaryColor, color: Theme.of(context)
.colorScheme
.primary,
), ),
onOpen: (LinkableElement link) => onOpen: (LinkableElement link) =>
LinkUtil.launch( LinkUtil.launch(
@ -182,12 +184,12 @@ class MorePopupMenu extends StatelessWidget {
ListTile( ListTile(
leading: Icon( leading: Icon(
FeatherIcons.chevronUp, FeatherIcons.chevronUp,
color: upvoted ? Theme.of(context).primaryColor : null, color: upvoted ? Theme.of(context).colorScheme.primary : null,
), ),
title: Text( title: Text(
upvoted ? 'Upvoted' : 'Upvote', upvoted ? 'Upvoted' : 'Upvote',
style: upvoted style: upvoted
? TextStyle(color: Theme.of(context).primaryColor) ? TextStyle(color: Theme.of(context).colorScheme.primary)
: null, : null,
), ),
subtitle: item is Story ? Text(item.score.toString()) : null, subtitle: item is Story ? Text(item.score.toString()) : null,
@ -196,12 +198,13 @@ class MorePopupMenu extends StatelessWidget {
ListTile( ListTile(
leading: Icon( leading: Icon(
FeatherIcons.chevronDown, FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null, color:
downvoted ? Theme.of(context).colorScheme.primary : null,
), ),
title: Text( title: Text(
downvoted ? 'Downvoted' : 'Downvote', downvoted ? 'Downvoted' : 'Downvote',
style: downvoted style: downvoted
? TextStyle(color: Theme.of(context).primaryColor) ? TextStyle(color: Theme.of(context).colorScheme.primary)
: null, : null,
), ),
onTap: context.read<VoteCubit>().downvote, onTap: context.read<VoteCubit>().downvote,
@ -212,7 +215,8 @@ class MorePopupMenu extends StatelessWidget {
return ListTile( return ListTile(
leading: Icon( leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null, color:
isFav ? Theme.of(context).colorScheme.primary : null,
), ),
title: Text( title: Text(
isFav ? 'Unfavorite' : 'Favorite', isFav ? 'Unfavorite' : 'Favorite',

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