Compare commits

...

68 Commits

Author SHA1 Message Date
7a35fe451d fix comment text parsing. (#390) 2024-05-22 00:53:58 -07:00
575ba8c2ef deprecated imperative apply of Flutter's Gradle plugins (#384) 2024-03-24 01:36:22 -07:00
e82998bb32 update pubspec.lock file. (#383) 2024-03-21 22:51:02 -07:00
3389e98861 bump Flutter version. (#382) 2024-03-21 16:49:40 -07:00
92a743f2f0 update date_display_format.dart (#379) 2024-02-20 22:21:25 -08:00
4148240daf update display_date_format.dart (#378) 2024-02-20 21:50:35 -08:00
90ec117b4b bump version. (#377) 2024-02-18 19:41:38 -08:00
9580e9b3e5 fix date time display. (#376) 2024-02-18 18:51:51 -08:00
0afaa5a0aa add date time display customization. (#375) 2024-02-17 02:04:00 -08:00
fbce1dff73 revert. (#374) 2024-02-13 16:52:14 -08:00
b0d6561486 update publish_ios.yml (#372) 2024-02-13 00:47:16 -08:00
11639118c5 update publish_ios.yml (#371) 2024-02-13 00:21:13 -08:00
e54c893e6c update publish_ios.yml (#370) 2024-02-13 00:07:31 -08:00
8c57e5e323 update publish_ios action. (#369) 2024-02-12 23:41:00 -08:00
b4ec7ec44e update publish_ios action. (#368) 2024-02-12 23:03:35 -08:00
8b65256294 update deploy target. (#367) 2024-02-12 22:30:23 -08:00
58f7bf14d7 bump fastlane version. (#366) 2024-02-12 21:55:58 -08:00
d9aad3d34e bump flutter version. (#365) 2024-02-12 20:54:33 -08:00
ed48d95375 fix comments repo. (#364) 2024-01-03 14:31:03 -08:00
1eaded5694 fix uncaught error. (#359) 2023-12-11 01:01:26 -08:00
70bb78afcb use InterceptorsWrapper for caching. (#358) 2023-12-10 15:29:40 -08:00
df2d2478d5 improve comment fetching. (#357) 2023-12-09 18:20:28 -08:00
d5ae60327d change fetch method based on network condition. (#356) 2023-12-09 10:03:22 -08:00
615a092c1e update fetching strategy. (#355) 2023-12-09 02:05:03 -08:00
5a7699d866 update hacker_news_web_repository.dart (#354) 2023-12-09 00:00:20 -08:00
56a9bab3f2 improve error handling. (#353) 2023-12-08 22:37:57 -08:00
e9bbf46b4f fix comment fetching. (#352) 2023-12-08 21:25:02 -08:00
10f503a6c0 add cache for story metadata. (#350) 2023-12-08 20:09:05 -08:00
582f3156b2 add dev option. (#349) 2023-12-08 17:21:30 -08:00
90fb45146f fix story repository. (#348) 2023-12-08 14:25:32 -08:00
c19c54e762 optimize comment fetching. (#347) 2023-12-08 13:35:52 -08:00
70e5a84b63 improve comment fetching. (#346) 2023-12-08 10:18:03 -08:00
3a51fa83f2 update story tile padding. (#344) 2023-12-08 01:12:49 -08:00
cb90751330 fix flickering image. (#343) 2023-12-07 23:24:43 -08:00
835ed7e841 use different comment fetching strategy. (#342) 2023-12-07 21:46:13 -08:00
125ccd2dd1 use isolate to fetch comments. (#341) 2023-12-05 21:04:40 -08:00
5b991c4287 update theme. (#340) 2023-12-03 17:30:34 -08:00
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
69eee3e278 fix url rendering. (#323) 2023-11-14 23:52:05 -08:00
36bcd996c0 bump Flutter version to 3.13.9 (#322) 2023-11-14 23:22:09 -08:00
5fc39d8b8b fix code block formatting. (#321) 2023-11-14 20:25:42 -08:00
5dce7787e1 improve text rendering performance. (#320) 2023-11-14 17:14:06 -08:00
8888dde792 allow marking stories as read from homepage. (#319) 2023-11-14 14:35:27 -08:00
6c8fc4cf87 fix response indicator when lazy fetching is enabled. (#317) 2023-11-13 21:10:47 -08:00
ae9cc109db revert "improve caching strategy. (#312)" (#316) 2023-11-13 19:42:20 -08:00
c8976ed17b improve caching strategy. (#312) 2023-11-11 00:31:09 -08:00
ff7e115418 fix manual pagination button. (#310) 2023-11-06 22:46:44 -08:00
0310507c96 revert html util change. (#309) 2023-11-06 19:40:53 -08:00
58c646e232 update html_util.dart (#308) 2023-11-06 17:10:10 -08:00
08328e2ca1 update url_linkifier.dart (#307) 2023-11-06 14:19:25 -08:00
86b7228ffd improve response indicator. (#306) 2023-11-06 12:45:46 -08:00
e103c88ca6 fix favorites export. (#305) 2023-11-05 22:47:45 -08:00
94323a04e0 fix response indicator. (#304) 2023-11-05 21:22:02 -08:00
4776c375a1 UX improvements on HN and in-thread search. (#303) 2023-11-05 19:48:01 -08:00
1f4e6cf41c fix pagination button. (#298) 2023-11-02 21:50:09 -07:00
be6ed35888 update version. (#297) 2023-11-02 21:09:55 -07:00
b2ea50cea6 add pagination. (#296) 2023-11-02 20:22:51 -07:00
175 changed files with 3462 additions and 1750 deletions

View File

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

View File

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

View File

@ -1,3 +1,10 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
@ -21,10 +23,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
@ -33,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 33
compileSdkVersion 34
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -51,7 +49,7 @@ android {
defaultConfig {
applicationId "com.jiaqifeng.hacki"
minSdkVersion 25
targetSdkVersion 33
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@ -80,7 +78,7 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0"
}
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]

View File

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

View File

@ -1,16 +1,3 @@
buildscript {
ext.kotlin_version = '1.7.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip

View File

@ -1,11 +1,25 @@
include ':app'
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
}
include ":app"

Binary file not shown.

Binary file not shown.

BIN
assets/hacki-github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/tablet-hacki.xcf Normal file

Binary file not shown.

View File

@ -76,6 +76,15 @@ final class SharedPrefsCore {
return true
}
fileprivate func remove(key: String?) -> Bool{
if let key = key {
let keyStore = NSUbiquitousKeyValueStore()
keyStore.removeObject(forKey: key)
}
return true
}
}
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "remove":
if let params = call.arguments as? [String: Any] {
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.remove(key: key)
result(res)
}
case "setBool":
if let params = call.arguments as? [String: Any] {
let val = params[valKey] as? Bool

View File

@ -15,6 +15,14 @@ class SyncedSharedPreferences {
const MethodChannel(channel),
);
Future<bool?> remove({
required String key,
}) async {
return _channel.invokeMethod('remove', <String, dynamic>{
'key': key,
});
}
Future<bool?> setBool({
required String key,
required bool val,

View File

@ -0,0 +1,5 @@
- Ability to use pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,5 @@
- Ability to use manual pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

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 @@
- New comment indicator.
- Ability to mark stories as read from home page.
- Text rendering improvements.
- Performance improvements.

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,13 +11,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({
AuthRepository? authRepository,
PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
SembastRepository? sembastRepository,
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
super(const AuthState.init()) {
@ -31,7 +31,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
final SembastRepository _sembastRepository;
Future<void> onInitialize(
@ -41,7 +41,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) {
final String? username = await _authRepository.username;
User? user = await _storiesRepository.fetchUser(id: username!);
User? user = await _hackerNewsRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story),
@ -89,7 +89,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
if (successful) {
final User? user = await _storiesRepository.fetchUser(id: event.username);
final User? user =
await _hackerNewsRepository.fetchUser(id: event.username);
emit(
state.copyWith(
user: user ?? User.emptyWithId(event.username),
@ -113,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.logout();
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
await _sembastRepository.deleteAll();
await _sembastRepository.deleteCachedComments();
}
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
@ -19,24 +20,32 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: concurrent(),
);
on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh);
on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded);
on<StoryLoaded>(
onStoryLoaded,
transformer: sequential(),
);
on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload);
@ -49,7 +58,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository;
final Logger _logger;
DeviceScreenType? deviceScreenType;
@ -87,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
),
);
for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit);
add(LoadStories(type: type));
}
}
Future<void> loadStories({
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
Future<void> onLoadStories(
LoadStories event,
Emitter<StoriesState> emit,
) async {
final StoryType type = event.type;
if (state.isOfflineReading) {
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
@ -113,19 +123,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: type));
});
} else {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
emit(
state
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
);
_storiesRepository
await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: type));
});
}).asFuture<void>();
add(StoriesLoaded(type: type));
}
}
@ -151,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
} else {
emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit);
add(LoadStories(type: event.type));
}
}
@ -196,7 +206,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesLoaded(type: event.type));
});
} else {
_storiesRepository
_hackerNewsRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
@ -273,7 +283,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
..remove(StoryType.latest);
for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
await _offlineRepository.cacheStoryIds(type: type, ids: ids);
prioritizedIds.addAll(ids);
}
@ -293,7 +304,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds(
final List<int> ids = await _hackerNewsRepository.fetchStoryIds(
type: StoryType.latest,
);
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
@ -347,7 +358,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
_logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStory(id: id);
final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) {
if (isPrioritized) {
@ -377,7 +388,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
/// In other words, we are prioritizing the story itself instead of
/// the comments in the story.
late final StreamSubscription<Comment>? downloadStream;
downloadStream = _storiesRepository
downloadStream = _hackerNewsRepository
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
@ -460,7 +471,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoryRead event,
Emitter<StoriesState> emit,
) async {
unawaited(_preferenceRepository.updateHasRead(event.story.id));
unawaited(_preferenceRepository.addHasRead(event.story.id));
emit(
state.copyWith(
@ -469,6 +480,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
}
Future<void> onStoryUnread(
StoryUnread event,
Emitter<StoriesState> emit,
) async {
unawaited(_preferenceRepository.removeHasRead(event.story.id));
emit(
state.copyWith(
readStoriesIds: <int>{...state.readStoriesIds}..remove(event.story.id),
),
);
}
Future<void> onClearAllReadStories(
ClearAllReadStories event,
Emitter<StoriesState> emit,

View File

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

View File

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

View File

@ -23,12 +23,13 @@ Future<void> setUpLocator() async {
output: LogUtil.logOutput(logOutputFile),
),
)
..registerSingleton<StoriesRepository>(StoriesRepository())
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository())
..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<OfflineRepository>(OfflineRepository())
..registerSingleton<DraftCache>(DraftCache())
..registerSingleton<CommentCache>(CommentCache())

View File

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
@ -25,6 +26,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
@ -32,18 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
@ -55,11 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
);
final FilterCubit _filterCubit;
final PreferenceCubit _preferenceCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
@ -75,6 +83,30 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
if (isOnWifi) {
return switch (state.item) {
Story(descendants: final int descendants)
when descendants > _webFetchingCmtCountLowerLimit =>
true,
Comment(kids: final List<int> kids)
when kids.length > _webFetchingCmtCountLowerLimit =>
true,
_ => false,
};
} else {
return true;
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
@override
void emit(CommentsState state) {
if (!isClosed) {
@ -86,6 +118,8 @@ class CommentsCubit extends Cubit<CommentsState> {
bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetAncestors,
AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit(
@ -96,7 +130,7 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
_streamSubscription = _storiesRepository
_streamSubscription = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(
ids: targetAncestors!.last.kids,
level: targetAncestors.last.level + 1,
@ -122,7 +156,10 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem = state.isOfflineReading
? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
: await _hackerNewsRepository
.fetchItem(id: item.id)
.then(_toBuildable)
.onError((_, __) => item) ??
item;
final List<int> kids = _sortKids(updatedItem.kids);
@ -135,15 +172,54 @@ class CommentsCubit extends Cubit<CommentsState> {
} else {
switch (state.fetchMode) {
case FetchMode.lazy:
commentStream = _storiesRepository.fetchCommentsStream(
commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache:
useCommentCache ? _commentCache.getComment : null,
);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
}
}
}
@ -154,7 +230,10 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone);
}
Future<void> refresh() async {
Future<void> refresh({
required AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
emit(
state.copyWith(
status: CommentsStatus.inProgress,
@ -187,18 +266,51 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem =
await _storiesRepository.fetchItem(id: item.id) ?? item;
await _hackerNewsRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
);
} else {
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
switch (state.fetchMode) {
case FetchMode.lazy:
commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
case FetchMode.eager:
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
}
_streamSubscription = commentStream
@ -245,14 +357,17 @@ class CommentsCubit extends Cubit<CommentsState> {
/// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
_hackerNewsRepository
.fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit(
state.copyWith(
@ -260,6 +375,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.comments.indexOf(comment) + offset + 1,
cmt.copyWith(level: level),
),
idToCommentMap: updatedIdToCommentMap,
),
);
offset++;
@ -289,7 +405,7 @@ class CommentsCubit extends Cubit<CommentsState> {
HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
final Item? parent =
await _storiesRepository.fetchItem(id: state.item.parent);
await _hackerNewsRepository.fetchItem(id: state.item.parent);
if (parent == null) {
return;
@ -310,7 +426,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadRootThread() async {
HapticFeedbackUtil.light();
emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
final Story? parent = await _storiesRepository
final Story? parent = await _hackerNewsRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
@ -365,12 +481,12 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
}
/// Scroll to next root level comment.
void scrollToNextRoot() {
void scrollToNextRoot({VoidCallback? onError}) {
final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
@ -390,7 +506,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -417,11 +533,15 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
}
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
}
/// Scroll to previous root level comment.
@ -453,34 +573,56 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
}
}
void search(String query) {
void search(String query, {String author = ''}) {
resetSearch();
if (query.isEmpty) return;
late final bool Function(Comment cmt) conditionSatisfied;
final String lowercaseQuery = query.toLowerCase();
if (query.isEmpty && author.isEmpty) {
return;
} else if (author.isEmpty) {
conditionSatisfied =
(Comment cmt) => cmt.text.toLowerCase().contains(lowercaseQuery);
} else if (query.isEmpty) {
conditionSatisfied = (Comment cmt) => cmt.by == author;
} else {
conditionSatisfied = (Comment cmt) =>
cmt.text.toLowerCase().contains(lowercaseQuery) && cmt.by == author;
}
emit(
state.copyWith(
inThreadSearchQuery: query,
inThreadSearchAuthor: author,
),
);
for (final int i in 0.to(state.comments.length, inclusive: false)) {
final Comment cmt = state.comments.elementAt(i);
if (cmt.text.toLowerCase().contains(lowercaseQuery)) {
if (conditionSatisfied(cmt)) {
emit(
state.copyWith(
matchedComments: <int>[...state.matchedComments, i],
inThreadSearchQuery: query,
),
);
}
}
}
void resetSearch() =>
emit(state.copyWith(matchedComments: <int>[], inThreadSearchQuery: ''));
void resetSearch() => emit(
state.copyWith(
matchedComments: <int>[],
inThreadSearchQuery: '',
inThreadSearchAuthor: '',
),
);
List<int> _sortKids(List<int> kids) {
switch (state.order) {
@ -507,8 +649,12 @@ class CommentsCubit extends Cubit<CommentsState> {
if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
if (state.isOfflineReading) {
_sembastRepository.cacheComment(comment);
}
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
@ -517,7 +663,16 @@ class CommentsCubit extends Cubit<CommentsState> {
comment.copyWith(hidden: hidden),
];
emit(state.copyWith(comments: updatedComments));
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit(
state.copyWith(
comments: updatedComments,
idToCommentMap: updatedIdToCommentMap,
),
);
}
}

View File

@ -13,6 +13,7 @@ class CommentsState extends Equatable {
required this.item,
required this.comments,
required this.matchedComments,
required this.idToCommentMap,
required this.status,
required this.fetchParentStatus,
required this.fetchRootStatus,
@ -22,6 +23,7 @@ class CommentsState extends Equatable {
required this.isOfflineReading,
required this.currentPage,
required this.inThreadSearchQuery,
required this.inThreadSearchAuthor,
});
CommentsState.init({
@ -31,15 +33,18 @@ class CommentsState extends Equatable {
required this.order,
}) : comments = <Comment>[],
matchedComments = <int>[],
idToCommentMap = <int, Comment>{},
status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false,
currentPage = 0,
inThreadSearchQuery = '';
inThreadSearchQuery = '',
inThreadSearchAuthor = '';
final Item item;
final List<Comment> comments;
final Map<int, Comment> idToCommentMap;
final CommentsStatus status;
final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus;
@ -49,6 +54,7 @@ class CommentsState extends Equatable {
final bool isOfflineReading;
final int currentPage;
final String inThreadSearchQuery;
final String inThreadSearchAuthor;
/// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments;
@ -57,6 +63,7 @@ class CommentsState extends Equatable {
Item? item,
List<Comment>? comments,
List<int>? matchedComments,
Map<int, Comment>? idToCommentMap,
CommentsStatus? status,
CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus,
@ -66,6 +73,7 @@ class CommentsState extends Equatable {
bool? isOfflineReading,
int? currentPage,
String? inThreadSearchQuery,
String? inThreadSearchAuthor,
}) {
return CommentsState(
item: item ?? this.item,
@ -81,11 +89,40 @@ class CommentsState extends Equatable {
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage,
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
);
}
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
static final Map<int, bool> _isResponseCache = <int, bool>{};
bool isResponse(Comment comment) {
if (_isResponseCache.containsKey(comment.id)) {
return _isResponseCache[comment.id]!;
}
if (comment.isRoot) {
_isResponseCache[comment.id] = false;
return false;
}
final Comment? precedingComment = idToCommentMap[comment.parent];
if (precedingComment == null) {
_isResponseCache[comment.id] = false;
return false;
} else if (item.id == precedingComment.parent && item.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else if (idToCommentMap[precedingComment.parent]?.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else {
_isResponseCache[comment.id] = false;
return false;
}
}
@override
List<Object?> get props => <Object?>[
item,
@ -100,5 +137,7 @@ class CommentsState extends Equatable {
comments,
matchedComments,
inThreadSearchQuery,
inThreadSearchAuthor,
idToCommentMap,
];
}

View File

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

View File

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

View File

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

View File

@ -10,16 +10,16 @@ part 'history_state.dart';
class HistoryCubit extends Cubit<HistoryState> {
HistoryCubit({
required AuthBloc authBloc,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
}) : _authBloc = authBloc,
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(HistoryState.init()) {
init();
}
final AuthBloc _authBloc;
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
static const int _pageSize = 20;
void init() {
@ -27,7 +27,7 @@ class HistoryCubit extends Cubit<HistoryState> {
if (authState.isLoggedIn) {
final String username = authState.username;
_storiesRepository
_hackerNewsRepository
.fetchSubmitted(userId: username)
.then((List<int>? submittedIds) {
emit(
@ -38,7 +38,7 @@ class HistoryCubit extends Cubit<HistoryState> {
),
);
if (submittedIds != null) {
_storiesRepository
_hackerNewsRepository
.fetchItemsStream(
ids: submittedIds.sublist(
0,
@ -66,7 +66,7 @@ class HistoryCubit extends Cubit<HistoryState> {
upper = len;
}
_storiesRepository
_hackerNewsRepository
.fetchItemsStream(
ids: state.submittedIds.sublist(
lower,
@ -93,12 +93,12 @@ class HistoryCubit extends Cubit<HistoryState> {
),
);
_storiesRepository
_hackerNewsRepository
.fetchSubmitted(userId: username)
.then((List<int>? submittedIds) {
emit(state.copyWith(submittedIds: submittedIds));
if (submittedIds != null) {
_storiesRepository
_hackerNewsRepository
.fetchItemsStream(
ids: submittedIds.sublist(
0,

View File

@ -9,6 +9,7 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'notification_state.dart';
@ -16,23 +17,28 @@ class NotificationCubit extends Cubit<NotificationState> {
NotificationCubit({
required AuthBloc authBloc,
required PreferenceCubit preferenceCubit,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository,
Logger? logger,
}) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit,
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(NotificationState.init()) {
_authBloc.stream.listen((AuthState authState) {
if (authState.isLoggedIn && authState.username != _username) {
_authBloc.stream
.map((AuthState event) => event.username)
.distinct()
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting.
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(Durations.twoSeconds, init);
Future<void>.delayed(AppDurations.twoSeconds, init);
}
// Listen for setting changes in the future.
@ -44,9 +50,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_timer?.cancel();
}
});
_username = authState.username;
} else if (!authState.isLoggedIn) {
} else {
emit(NotificationState.init());
}
});
@ -54,10 +58,10 @@ class NotificationCubit extends Cubit<NotificationState> {
final AuthBloc _authBloc;
final PreferenceCubit _preferenceCubit;
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository;
String? _username;
final Logger _logger;
Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5);
@ -74,6 +78,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});
@ -82,7 +87,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id);
comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) {
emit(
state.copyWith(
@ -160,7 +165,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchComment(id: id);
comment ??= await _hackerNewsRepository.fetchComment(id: id);
if (comment != null) {
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
}
@ -184,7 +189,7 @@ class NotificationCubit extends Cubit<NotificationState> {
}
Future<void> _fetchReplies() {
return _storiesRepository
return _hackerNewsRepository
.fetchSubmitted(userId: _authBloc.state.username)
.then((List<int>? submittedItems) async {
if (submittedItems != null) {
@ -194,7 +199,9 @@ class NotificationCubit extends Cubit<NotificationState> {
);
for (final int id in subscribedItems) {
await _storiesRepository.fetchItem(id: id).then((Item? item) async {
await _hackerNewsRepository
.fetchItem(id: id)
.then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids =
(await _sembastRepository.kids(of: id)) ?? <int>[];
@ -216,7 +223,7 @@ class NotificationCubit extends Cubit<NotificationState> {
...state.unreadCommentsIds,
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
);
await _storiesRepository
await _hackerNewsRepository
.fetchComment(id: newCommentId)
.then((Comment? comment) {
if (comment != null && !comment.dead && !comment.deleted) {

View File

@ -1,5 +1,6 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
@ -9,28 +10,33 @@ part 'pin_state.dart';
class PinCubit extends Cubit<PinState> {
PinCubit({
PreferenceRepository? preferenceRepository,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PinState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
void init() {
emit(PinState.init());
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
emit(state.copyWith(pinnedStoriesIds: ids));
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched);
_hackerNewsRepository
.fetchStoriesStream(ids: ids)
.listen(_onStoryFetched);
}).whenComplete(() => emit(state.copyWith(status: Status.success)));
}
void pinStory(Story story) {
void pinStory(
Story story, {
VoidCallback? onDone,
}) {
if (!state.pinnedStoriesIds.contains(story.id)) {
emit(
state.copyWith(
@ -39,10 +45,14 @@ class PinCubit extends Cubit<PinState> {
),
);
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
}
}
void unpinStory(Story story) {
void unpinStory(
Story story, {
VoidCallback? onDone,
}) {
emit(
state.copyWith(
pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id),
@ -50,6 +60,7 @@ class PinCubit extends Cubit<PinState> {
),
);
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
onDone?.call();
}
void refresh() {

View File

@ -11,13 +11,13 @@ part 'poll_state.dart';
class PollCubit extends Cubit<PollState> {
PollCubit({
required Story story,
StoriesRepository? storiesRepository,
HackerNewsRepository? hackerNewsRepository,
}) : _story = story,
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(PollState.init());
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
final Story _story;
Future<void> init({
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
if (pollOptionsIds.isEmpty || refresh) {
final Story? updatedStory =
await _storiesRepository.fetchStory(id: _story.id);
await _hackerNewsRepository.fetchStory(id: _story.id);
if (updatedStory != null) {
pollOptionsIds = updatedStory.parts;
@ -47,7 +47,7 @@ class PollCubit extends Cubit<PollState> {
}
if (pollOptionsIds.isNotEmpty) {
final List<PollOption> pollOptions = (await _storiesRepository
final List<PollOption> pollOptions = (await _hackerNewsRepository
.fetchPollOptionsStream(ids: pollOptionsIds)
.toSet())
.toList();

View File

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

View File

@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void onExactMatchToggled() {
emit(
state.copyWith(
params: state.params.copyWith(
exactMatch: !state.params.exactMatch,
),
),
);
search(state.params.query);
}
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,

View File

@ -25,16 +25,12 @@ class SubmitCubit extends Cubit<SubmitState> {
emit(state.copyWith(text: text));
}
void onSubmitTapped() {
void submit() {
emit(state.copyWith(status: Status.inProgress));
if (state.title?.isNotEmpty ?? false) {
_postRepository
.submit(
title: state.title!,
url: state.url,
text: state.text,
)
.submit(title: state.title!, url: state.url, text: state.text)
.then((bool successful) {
emit(state.copyWith(status: Status.success));
}).onError((Object? error, StackTrace stackTrace) {

View File

@ -7,16 +7,16 @@ import 'package:hacki/repositories/repositories.dart';
part 'user_state.dart';
class UserCubit extends Cubit<UserState> {
UserCubit({StoriesRepository? storiesRepository})
: _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
UserCubit({HackerNewsRepository? hackerNewsRepository})
: _hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
super(const UserState.init());
final StoriesRepository _storiesRepository;
final HackerNewsRepository _hackerNewsRepository;
void init({required String userId}) {
emit(state.copyWith(status: Status.inProgress));
_storiesRepository.fetchUser(id: userId).then((User? user) {
_hackerNewsRepository.fetchUser(id: userId).then((User? user) {
emit(
state.copyWith(
user: user ?? User.emptyWithId(userId),

View File

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

View File

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

View File

@ -19,9 +19,11 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
@ -44,6 +46,8 @@ void notificationReceiver(NotificationResponse details) =>
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting(Platform.localeName);
isTesting = testing;
final Directory tempDir = await getTemporaryDirectory();
@ -137,7 +141,7 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
runApp(
HackiApp(
@ -229,16 +233,21 @@ class HackiApp extends StatelessWidget {
)..init(),
),
],
child: BlocBuilder<PreferenceCubit, PreferenceState>(
child: BlocConsumer<PreferenceCubit, PreferenceState>(
listenWhen: (PreferenceState previous, PreferenceState current) =>
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
listener: (_, PreferenceState state) {
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
},
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled,
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}''',
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -254,7 +263,6 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor,
brightness: Brightness.dark,
),
canvasColor: Palette.black,
fontFamily: state.font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
@ -278,62 +286,72 @@ class HackiApp extends StatelessWidget {
.platformDispatcher
.platformBrightness ==
Brightness.dark);
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
);
return FeatureDiscovery(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor: state.textScaleFactor == 1
? null
: state.textScaleFactor,
),
data: state.textScaleFactor == 1
? MediaQuery.of(context)
: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
state.textScaleFactor,
),
),
child: MaterialApp.router(
key: Key(state.appColor.hashCode.toString()),
title: 'Hacki',
debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
useMaterial3: state.material3Enabled,
dividerTheme: state.material3Enabled
? DividerThemeData(
color: Palette.grey.withOpacity(0.2),
)
: null,
switchTheme: state.material3Enabled
? SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states
.contains(MaterialState.selected)) {
return null;
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
)
: null,
bottomSheetTheme: state.material3Enabled
? const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
)
: null,
inputDecorationTheme: state.material3Enabled
? InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
)
: null,
sliderTheme: state.material3Enabled
? SliderThemeData(
inactiveTrackColor:
state.appColor.shade200.withOpacity(0.5),
)
: null,
theme: ThemeData(
colorScheme: colorScheme,
fontFamily: state.font.name,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
),
switchTheme: SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return colorScheme.primary.withOpacity(0.6);
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
),
bottomSheetTheme: const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
),
inputDecorationTheme: InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
),
sliderTheme: SliderThemeData(
inactiveTrackColor:
colorScheme.primary.withOpacity(0.5),
activeTrackColor: colorScheme.primary,
thumbColor: colorScheme.primary,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
),
),
routerConfig: router,
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ class BuildableComment extends Comment with Buildable {
required super.deleted,
required super.hidden,
required super.level,
required super.isFromCache,
required this.elements,
});
@ -33,6 +34,7 @@ class BuildableComment extends Comment with Buildable {
deleted: comment.deleted,
level: comment.level,
hidden: comment.hidden,
isFromCache: comment.isFromCache,
);
@override
@ -53,6 +55,7 @@ class BuildableComment extends Comment with Buildable {
hidden: hidden ?? this.hidden,
level: level ?? this.level,
elements: elements,
isFromCache: isFromCache,
);
}

View File

@ -13,6 +13,7 @@ class Comment extends Item {
required super.deleted,
required super.hidden,
required this.level,
required this.isFromCache,
}) : super(
descendants: 0,
parts: <int>[],
@ -21,9 +22,12 @@ class Comment extends Item {
type: '',
);
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
Comment.fromJson(super.json, {this.level = 0})
: isFromCache = json['fromCache'] == true,
super.fromJson();
final int level;
final bool isFromCache;
String get metadata => '''by $by $timeAgo''';
@ -45,6 +49,7 @@ class Comment extends Item {
deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level,
isFromCache: isFromCache,
);
}

View File

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

View File

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

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