Compare commits

..

31 Commits

Author SHA1 Message Date
c685f33f99 fix download progress bar. (#439) 2024-07-28 21:51:57 -07:00
518608893d auto scroll improvements. (#438) 2024-07-28 16:39:56 -07:00
856efa7c14 bump flutter version to 3.22.3. (#434) 2024-07-19 21:11:40 -07:00
d1957ffb82 update stories bloc. (#428) 2024-06-06 21:19:15 -07:00
553a37961d bump Flutter to 3.22.2 (#427) 2024-06-06 15:08:36 -07:00
bade5b4356 remove comment. (#426) 2024-06-04 22:16:22 -07:00
ab43d1a2c4 remove unused package. (#425) 2024-06-04 19:30:35 -07:00
cf5c0b3263 fix favicon. (#424) 2024-06-03 12:45:14 -07:00
d7295afa41 improve story tile with favicon. (#423) 2024-06-03 11:17:43 -07:00
1ecddf9d5b update stories_bloc.dart (#422) 2024-06-03 09:52:29 -07:00
479903ed77 improve loading speed. (#421) 2024-06-03 01:39:04 -07:00
1e4c10e819 improve loading performance. (#420) 2024-06-02 23:33:22 -07:00
473a65427a fix iOS build. (#419) 2024-06-02 17:25:52 -07:00
ad6ccc9376 fix favicon. (#418) 2024-06-02 17:08:42 -07:00
995dfed85d add favicon to story tile. (#417) 2024-06-02 12:13:47 -07:00
0e74f88a8d fix thread loading. (#416) 2024-06-02 00:52:00 -07:00
c2e6d7ea98 improve offline mode. (#415) 2024-06-01 22:49:41 -07:00
e46432b86c improve loading speed. (#414) 2024-06-01 19:40:37 -07:00
9763a94e1d add remote config. (#413) 2024-06-01 11:15:36 -07:00
077fcbf9da fix duplicate fav. (#411) 2024-05-31 18:59:05 -07:00
9cdb6b7383 fix true dark mode. (#412) 2024-05-31 18:55:07 -07:00
d01524020d bump flutter to 3.22.1 (#410) 2024-05-31 14:41:40 -07:00
fb2072676e fix parser verifier. (#408) 2024-05-23 00:07:51 -07:00
162c7a2689 run parser verifier only on schedule. (#407) 2024-05-22 23:51:48 -07:00
e218527953 run release flow only when lib or dependencies are changed. (#406) 2024-05-22 23:40:41 -07:00
3dddfa66cf add parser check. (#392) 2024-05-22 23:27:52 -07:00
3fd0a9a1ea bump build number. (#391) 2024-05-22 01:05:59 -07:00
7a35fe451d fix comment text parsing. (#390) 2024-05-22 00:53:58 -07:00
575ba8c2ef deprecated imperative apply of Flutter's Gradle plugins (#384) 2024-03-24 01:36:22 -07:00
e82998bb32 update pubspec.lock file. (#383) 2024-03-21 22:51:02 -07:00
3389e98861 bump Flutter version. (#382) 2024-03-21 16:49:40 -07:00
96 changed files with 1888 additions and 810 deletions

View File

@ -7,7 +7,7 @@ on:
- '!master'
jobs:
releases:
commit_check:
name: Check commit
runs-on: macos-latest
timeout-minutes: 30

23
.github/workflows/parser_check.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Parser Check
on:
# Allow manual builds of this workflow.
workflow_dispatch: { }
# Run this job every hour.
schedule:
- cron: "0 * * * *"
jobs:
parser_check:
runs-on: ubuntu-latest
timeout-minutes: 0.5
permissions:
issues: write
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- name: Verify comment text parser
working-directory: ./scripts/bin
run: |
dart pub get
dart parser_verifier.dart -t ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,12 +1,17 @@
name: Publish (iOS)
on:
# Allow manual builds of this workflow
# Allow manual builds of this workflow.
workflow_dispatch: {}
# Run the workflow whenever a new tag named 'v*' is pushed
push:
branches:
- master
# Only build when any of these directories has been modified.
paths:
- lib/**
- pubspec.lock
- pubspec.yaml
- submodules/**
jobs:
build_and_publish:

View File

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

View File

@ -1,3 +1,10 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
@ -21,10 +23,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
@ -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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
{
"athingComtrSelector": "#hnmain > tbody > tr > td > table > tbody > .athing.comtr",
"commentTextSelector": "td > table > tbody > tr > td.default > div.comment > div.commtext",
"commentHeadSelector": "td > table > tbody > tr > td.default > div > span > a",
"commentAgeSelector": "td > table > tbody > tr > td.default > div > span > span.age",
"commentIndentSelector": "td > table > tbody > tr > td.ind"
}

View File

@ -0,0 +1,7 @@
{
"athingComtrSelector": "#hnmain > tbody > tr > td > table > tbody > .athing.comtr",
"commentTextSelector": "td > table > tbody > tr > td.default > div.comment > div.commtext",
"commentHeadSelector": "td > table > tbody > tr > td.default > div > span > a",
"commentAgeSelector": "td > table > tbody > tr > td.default > div > span > span.age",
"commentIndentSelector": "td > table > tbody > tr > td.ind"
}

View File

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

View File

@ -1,7 +1,7 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- ReachabilitySwift
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@ -16,6 +16,8 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_siri_suggestions (0.0.1):
@ -34,7 +36,6 @@ PODS:
- qr_code_scanner (0.2.0):
- Flutter
- MTBBarcodeScanner
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (1.5.3):
- Flutter
- share_plus (0.0.1):
@ -57,12 +58,13 @@ PODS:
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
@ -84,11 +86,10 @@ SPEC REPOS:
trunk:
- MTBBarcodeScanner
- OrderedSet
- ReachabilitySwift
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
:path: ".symlinks/plugins/connectivity_plus/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
@ -99,6 +100,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions:
@ -133,28 +136,28 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 13825b8a9334a850581300559b8839134b124670
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: 753f808c6be5550247f6a20f2a14972466a5f33c
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

View File

@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@ -33,5 +39,6 @@
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@ -1,84 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>workmanager.background.task</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hacki</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hacki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>mailto</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>workmanager.background.task</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hacki</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hacki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>mailto</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
@ -13,6 +14,7 @@ import 'package:responsive_builder/responsive_builder.dart';
import 'package:rxdart/rxdart.dart';
part 'stories_event.dart';
part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
@ -46,11 +48,11 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload);
on<StoryDownloaded>(onStoryDownloaded);
on<StoriesExitOffline>(onExitOffline);
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
on<StoriesExitOfflineMode>(onExitOfflineMode);
on<StoriesPageSizeChanged>(onPageSizeChanged);
on<ClearAllReadStories>(onClearAllReadStories);
}
@ -62,40 +64,44 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final PreferenceRepository _preferenceRepository;
final Logger _logger;
DeviceScreenType? deviceScreenType;
StreamSubscription<PreferenceState>? _streamSubscription;
StreamSubscription<PreferenceState>? _preferenceSubscription;
static const int _smallPageSize = 10;
static const int _largePageSize = 20;
static const int _tabletSmallPageSize = 15;
static const int _tabletLargePageSize = 25;
static const String _logPrefix = '[StoriesBloc]';
Future<void> onInitialize(
StoriesInitialize event,
Emitter<StoriesState> emit,
) async {
_streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = getPageSize(isComplexTile: isComplexTile);
_preferenceSubscription ??= _preferenceCubit.stream
.distinct((PreferenceState previous, PreferenceState next) {
return previous.isComplexStoryTileEnabled ==
next.isComplexStoryTileEnabled;
})
.debounceTime(AppDurations.oneSecond)
.listen((PreferenceState event) {
final bool isComplexTile = event.isComplexStoryTileEnabled;
final int pageSize = getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize));
}
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize));
}
});
final bool isComplexTile = _preferenceCubit.state.isComplexStoryTileEnabled;
final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.idle,
currentPageSize: pageSize,
downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded,
storiesToBeDownloaded: state.storiesToBeDownloaded,
isOfflineReading: state.isOfflineReading,
),
);
for (final StoryType type in StoryType.values) {
for (final StoryType type in _preferenceCubit.state.tabs) {
add(LoadStories(type: type));
}
}
@ -111,31 +117,34 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit(
state
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
.copyWithCurrentPageUpdated(type: type, to: 0)
.copyWithStatusUpdated(type: type, to: Status.inProgress),
);
_offlineRepository
.getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
)
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: type));
});
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
)
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
.onDone(() => add(StoryLoadingCompleted(type: type)));
} else {
final List<int> ids =
await _hackerNewsRepository.fetchStoryIds(type: type);
emit(
state
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
.copyWithCurrentPageUpdated(type: type, to: 0)
.copyWithStatusUpdated(type: type, to: Status.inProgress),
);
await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.fetchStoriesStream(
ids: ids.sublist(0, state.currentPageSize),
sequential: _preferenceCubit.state.isComplexStoryTileEnabled ||
_preferenceCubit.state.isFaviconEnabled,
)
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).asFuture<void>();
add(StoriesLoaded(type: type));
add(StoryLoadingCompleted(type: type));
}
}
@ -161,11 +170,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
} else {
emit(state.copyWithRefreshed(type: event.type));
add(LoadStories(type: event.type));
add(LoadStories(type: event.type, isRefreshing: true));
}
}
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
if (state.statusByType[event.type] == Status.inProgress) return;
emit(
state.copyWithStatusUpdated(
type: event.type,
@ -190,39 +201,27 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
if (state.isOfflineReading) {
_offlineRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
),
)
.listen((Story story) {
add(
StoryLoaded(
story: story,
type: event.type,
),
);
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
),
)
.listen(
(Story story) => add(StoryLoaded(story: story, type: event.type)),
)
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
} else {
_hackerNewsRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
),
)
.listen((Story story) {
add(
StoryLoaded(
story: story,
type: event.type,
),
);
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
),
)
.listen(
(Story story) => add(StoryLoaded(story: story, type: event.type)),
)
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
}
} else {
emit(
@ -238,7 +237,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoryLoaded event,
Emitter<StoriesState> emit,
) async {
if (event is StoryLoadingCompleted) {
emit(
state.copyWithStatusUpdated(type: event.type, to: Status.success),
);
return;
}
final Story story = event.story;
if (state.storiesByType[event.type]?.contains(story) ?? false) {
_logger.d(
'$_logPrefix story ${story.id} for ${event.type} already exists.',
);
return;
}
final bool hasRead = await _preferenceRepository.hasRead(story.id);
final bool hidden = _filterCubit.state.keywords.any((String keyword) {
// Match word only.
@ -246,6 +258,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
return regExp.hasMatch(story.title.toLowerCase()) ||
regExp.hasMatch(story.text.toLowerCase());
});
emit(
state.copyWithStoryAdded(
type: event.type,
@ -255,12 +268,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
}
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit(
state.copyWithStatusUpdated(type: event.type, to: Status.success),
);
}
Future<void> onDownload(
StoriesDownload event,
Emitter<StoriesState> emit,
@ -274,6 +281,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
final Set<int> prioritizedIds = <int>{};
@ -344,20 +352,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
<StreamSubscription<Comment>>[];
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading');
_logger.d('$_logPrefix aborting downloading');
for (final StreamSubscription<Comment> stream in downloadStreams) {
await stream.cancel();
}
_logger.d('deleting downloaded contents');
_logger.d('$_logPrefix deleting downloaded contents');
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
break;
}
_logger.d('fetching story $id');
_logger.d('$_logPrefix fetching story $id');
final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) {
@ -377,7 +385,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.cacheStory(story: story);
if (story.url.isNotEmpty && includingWebPage) {
_logger.i('downloading ${story.url}');
_logger.i('$_logPrefix downloading ${story.url}');
await _offlineRepository.cacheUrl(url: story.url);
}
@ -394,19 +402,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.listen(
(Comment comment) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading from comments stream');
_logger.d('$_logPrefix aborting downloading from comments stream');
downloadStream?.cancel();
return;
}
_logger.d('fetched comment ${comment.id}');
_logger.d('$_logPrefix fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
)..onDone(() {
_logger.d(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
'''$_logPrefix finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
});
@ -455,18 +463,22 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesInitialize());
}
Future<void> onExitOffline(
StoriesExitOffline event,
Future<void> onExitOfflineMode(
StoriesExitOfflineMode event,
Emitter<StoriesState> emit,
) async {
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize());
}
Future<void> onEnterOfflineMode(
StoriesEnterOfflineMode event,
Emitter<StoriesState> emit,
) async {
emit(state.copyWith(isOfflineReading: true));
add(StoriesInitialize());
}
Future<void> onStoryRead(
StoryRead event,
Emitter<StoriesState> emit,
@ -520,7 +532,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
@override
Future<void> close() async {
await _streamSubscription?.cancel();
await _preferenceSubscription?.cancel();
await super.close();
}
}

View File

@ -6,12 +6,16 @@ abstract class StoriesEvent extends Equatable {
}
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
LoadStories({required this.type, this.isRefreshing = false});
final StoryType type;
final bool isRefreshing;
@override
List<Object?> get props => <Object?>[type];
List<Object?> get props => <Object?>[
type,
isRefreshing,
];
}
class StoriesInitialize extends StoriesEvent {
@ -19,15 +23,6 @@ class StoriesInitialize extends StoriesEvent {
List<Object?> get props => <Object?>[];
}
class StoriesLoaded extends StoriesEvent {
StoriesLoaded({required this.type});
final StoryType type;
@override
List<Object?> get props => <Object?>[type];
}
class StoriesRefresh extends StoriesEvent {
StoriesRefresh({required this.type});
@ -71,7 +66,12 @@ class StoryDownloaded extends StoriesEvent {
List<Object?> get props => <Object?>[skipped];
}
class StoriesExitOffline extends StoriesEvent {
class StoriesExitOfflineMode extends StoriesEvent {
@override
List<Object?> get props => <Object?>[];
}
class StoriesEnterOfflineMode extends StoriesEvent {
@override
List<Object?> get props => <Object?>[];
}
@ -95,6 +95,10 @@ class StoryLoaded extends StoriesEvent {
List<Object?> get props => <Object?>[story, type];
}
class StoryLoadingCompleted extends StoryLoaded {
StoryLoadingCompleted({required super.type}) : super(story: Story.empty());
}
class StoryRead extends StoriesEvent {
StoryRead({required this.story});

View File

@ -66,6 +66,12 @@ abstract class Constants {
static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
static String favicon(String url, {int size = 32}) {
final Uri uri = Uri.parse(url);
final String host = uri.host;
return 'https://www.google.com/s2/favicons?domain=$host&sz=$size';
}
}
abstract class RegExpConstants {

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hacki/config/custom_log_filter.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
@ -24,6 +25,8 @@ Future<void> setUpLocator() async {
),
)
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<RemoteConfigRepository>(RemoteConfigRepository())
..registerSingleton<RemoteConfigCubit>(RemoteConfigCubit())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())

View File

@ -83,7 +83,8 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
static const int _webFetchingCmtCountLowerLimit = 5;
static const String _logPrefix = '[CommentsCubit]';
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
@ -103,8 +104,9 @@ class CommentsCubit extends Cubit<CommentsState> {
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
final List<ConnectivityResult> status =
await Connectivity().checkConnectivity();
return status.contains(ConnectivityResult.wifi);
}
@override
@ -181,19 +183,20 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
_logger.e('$_logPrefix $e');
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
if (_preferenceCubit.state.isDevModeEnabled) {
onError?.call(e as AppException);
}
@ -204,7 +207,8 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
@ -279,17 +283,19 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
_logger.d(
'$_logPrefix fetching comments of ${item.id} from web.',
);
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
_logger.e('$_logPrefix $e');
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
if (_preferenceCubit.state.isDevModeEnabled) {
onError?.call(e as AppException);
}
@ -300,7 +306,8 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}

View File

@ -11,6 +11,7 @@ export 'poll/poll_cubit.dart';
export 'post/post_cubit.dart';
export 'preference/preference_cubit.dart';
export 'reminder/reminder_cubit.dart';
export 'remote_config/remote_config_cubit.dart';
export 'search/search_cubit.dart';
export 'split_view/split_view_cubit.dart';
export 'submit/submit_cubit.dart';

View File

@ -50,7 +50,7 @@ class FavCubit extends Cubit<FavState> {
_preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(
state.copyWith(
favIds: favIds,
favIds: LinkedHashSet<int>.from(favIds).toList(),
favItems: <Item>[],
currentPage: 0,
),
@ -155,7 +155,7 @@ class FavCubit extends Cubit<FavState> {
);
_preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(state.copyWith(favIds: favIds));
emit(state.copyWith(favIds: LinkedHashSet<int>.from(favIds).toList()));
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),

View File

@ -37,16 +37,16 @@ class NotificationCubit extends Cubit<NotificationState> {
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting.
if (_preferenceCubit.state.notificationEnabled) {
if (_preferenceCubit.state.isNotificationEnabled) {
Future<void>.delayed(AppDurations.twoSeconds, init);
}
// Listen for setting changes in the future.
_preferenceCubit.stream.listen((PreferenceState prefState) {
final bool isActive = _timer?.isActive ?? false;
if (prefState.notificationEnabled && !isActive) {
if (prefState.isNotificationEnabled && !isActive) {
init();
} else if (!prefState.notificationEnabled) {
} else if (!prefState.isNotificationEnabled) {
_timer?.cancel();
}
});
@ -67,6 +67,7 @@ class NotificationCubit extends Cubit<NotificationState> {
static const Duration _refreshInterval = Duration(minutes: 5);
static const int _subscriptionUpperLimit = 15;
static const int _pageSize = 20;
static const String _logPrefix = '[NotificationCubit]';
Future<void> init() async {
emit(NotificationState.init());
@ -78,7 +79,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
_logger.i('$_logPrefix ${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});
@ -104,36 +105,22 @@ class NotificationCubit extends Cubit<NotificationState> {
}
void markAsRead(int id) {
Future.doWhile(() {
if (state.status != Status.inProgress) {
if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id);
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
}
return false;
}
return true;
});
if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id);
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
}
}
void markAllAsRead() {
Future.doWhile(() {
if (state.status != Status.inProgress) {
emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
return false;
}
return true;
});
emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
}
Future<void> refresh() async {
if (_authBloc.state.isLoggedIn &&
_preferenceCubit.state.notificationEnabled) {
_preferenceCubit.state.isNotificationEnabled) {
emit(
state.copyWith(
status: Status.inProgress,
@ -248,4 +235,30 @@ class NotificationCubit extends Cubit<NotificationState> {
),
);
}
void onCommentTapped(
Comment comment, {
void Function((Story, List<Comment>)? res)? then,
}) {
if (state.commentFetchingStatus == Status.inProgress) return;
emit(
state.copyWith(
commentFetchingStatus: Status.inProgress,
tappedCommentId: comment.id,
),
);
locator
.get<HackerNewsRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then(((Story, List<Comment>)? res) {
emit(
state.copyWith(
commentFetchingStatus: Status.success,
),
);
then?.call(res);
});
}
}

View File

@ -8,6 +8,8 @@ class NotificationState extends Equatable {
required this.currentPage,
required this.offset,
required this.status,
required this.commentFetchingStatus,
required this.tappedCommentId,
});
NotificationState.init()
@ -16,7 +18,9 @@ class NotificationState extends Equatable {
allCommentsIds = <int>[],
currentPage = 0,
offset = 0,
status = Status.idle;
status = Status.idle,
commentFetchingStatus = Status.idle,
tappedCommentId = null;
final List<Comment> comments;
final List<int> allCommentsIds;
@ -24,6 +28,8 @@ class NotificationState extends Equatable {
final int currentPage;
final int offset;
final Status status;
final Status commentFetchingStatus;
final int? tappedCommentId;
NotificationState copyWith({
List<Comment>? comments,
@ -32,6 +38,8 @@ class NotificationState extends Equatable {
int? currentPage,
int? offset,
Status? status,
Status? commentFetchingStatus,
int? tappedCommentId,
}) {
return NotificationState(
comments: comments ?? this.comments,
@ -40,6 +48,9 @@ class NotificationState extends Equatable {
currentPage: currentPage ?? this.currentPage,
offset: offset ?? this.offset,
status: status ?? this.status,
commentFetchingStatus:
commentFetchingStatus ?? this.commentFetchingStatus,
tappedCommentId: tappedCommentId,
);
}
@ -51,6 +62,8 @@ class NotificationState extends Equatable {
currentPage: currentPage,
offset: offset,
status: status,
commentFetchingStatus: commentFetchingStatus,
tappedCommentId: tappedCommentId,
);
}
@ -65,6 +78,8 @@ class NotificationState extends Equatable {
currentPage: currentPage,
offset: offset + 1,
status: status,
commentFetchingStatus: commentFetchingStatus,
tappedCommentId: tappedCommentId,
);
}
@ -73,6 +88,7 @@ class NotificationState extends Equatable {
currentPage,
offset,
status,
commentFetchingStatus,
comments,
unreadCommentsIds,
allCommentsIds,

View File

@ -23,6 +23,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
final PreferenceRepository _preferenceRepository;
final Logger _logger;
static const String _logPrefix = '[PreferenceCubit]';
void init() {
for (final BooleanPreference p
@ -73,7 +74,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
}
void update<T>(Preference<T> preference) {
_logger.i('updating $preference to ${preference.val}');
_logger.i('$_logPrefix updating $preference to ${preference.val}');
emit(state.copyWithPreference(preference));

View File

@ -48,35 +48,37 @@ class PreferenceState extends Equatable {
.val;
}
bool get notificationEnabled => _isOn<NotificationModePreference>();
bool get isNotificationEnabled => _isOn<NotificationModePreference>();
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get isComplexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get isFaviconEnabled => _isOn<FaviconModePreference>();
bool get readerEnabled => _isOn<ReaderModePreference>();
bool get isEyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get isReaderEnabled => _isOn<ReaderModePreference>();
bool get metadataEnabled => _isOn<MetadataModePreference>();
bool get isMarkReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get urlEnabled => _isOn<StoryUrlModePreference>();
bool get isMetadataEnabled => _isOn<MetadataModePreference>();
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get isUrlEnabled => _isOn<StoryUrlModePreference>();
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
bool get isTapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
bool get isSwipeGestureEnabled => _isOn<SwipeGesturePreference>();
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get isAutoScrollEnabled => _isOn<AutoScrollModePreference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get isCustomTabEnabled => _isOn<CustomTabPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get isManualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get isTrueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get devModeEnabled => _isOn<DevMode>();
bool get isHapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get isDevModeEnabled => _isOn<DevMode>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -9,7 +9,9 @@ class ReminderCubit extends Cubit<ReminderState> {
ReminderCubit({PreferenceRepository? preferenceRepository})
: _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
super(const ReminderState.init());
super(const ReminderState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;

View File

@ -0,0 +1,47 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/remote_config_repository.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
part 'remote_config_state.dart';
class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> {
RemoteConfigCubit({
RemoteConfigRepository? remoteConfigRepository,
Logger? logger,
}) : _remoteConfigRepository =
remoteConfigRepository ?? locator.get<RemoteConfigRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(RemoteConfigState.init()) {
init();
}
final RemoteConfigRepository _remoteConfigRepository;
final Logger _logger;
static const String _logPrefix = '';
void init() {
_remoteConfigRepository
.fetchRemoteConfig()
.then((Map<String, dynamic> data) {
if (data.isNotEmpty) {
_logger.i('$_logPrefix remote config fetched: $data');
emit(state.copyWith(data: data));
} else {
_logger.i('$_logPrefix empty remote config.');
}
});
}
@override
RemoteConfigState? fromJson(Map<String, dynamic> json) {
return RemoteConfigState(data: json);
}
@override
Map<String, dynamic>? toJson(RemoteConfigState state) {
return state.data;
}
}

View File

@ -0,0 +1,59 @@
part of 'remote_config_cubit.dart';
final class RemoteConfigState extends Equatable {
const RemoteConfigState({
required this.data,
});
RemoteConfigState.init() : data = <String, dynamic>{};
@protected
final Map<String, dynamic> data;
String get athingComtrSelector => getString(
key: 'athingComtrSelector',
fallback:
'''#hnmain > tbody > tr > td > table > tbody > .athing.comtr''',
);
String get commentTextSelector => getString(
key: 'commentTextSelector',
fallback:
'''td > table > tbody > tr > td.default > div.comment > div.commtext''',
);
String get commentHeadSelector => getString(
key: 'commentHeadSelector',
fallback: '''td > table > tbody > tr > td.default > div > span > a''',
);
String get commentAgeSelector => getString(
key: 'commentAgeSelector',
fallback:
'''td > table > tbody > tr > td.default > div > span > span.age''',
);
String get commentIndentSelector => getString(
key: 'commentIndentSelector',
fallback: '''td > table > tbody > tr > td.ind''',
);
String getString({required String key, String fallback = ''}) {
return data[key] as String? ?? fallback;
}
bool getBool({required String key, bool fallback = false}) {
return data[key] as bool? ?? fallback;
}
int getInt({required String key, int fallback = 0}) {
return data[key] as int? ?? fallback;
}
RemoteConfigState copyWith({Map<String, dynamic>? data}) {
return RemoteConfigState(data: data ?? this.data);
}
@override
List<Object?> get props => <Object?>[data];
}

View File

@ -17,9 +17,10 @@ class SplitViewCubit extends Cubit<SplitViewState> {
final Logger _logger;
final CommentCache _commentCache;
static const String _logPrefix = '[SplitViewCubit]';
void updateItemScreenArgs(ItemScreenArgs args) {
_logger.i('resetting comments in CommentCache');
_logger.i('$_logPrefix resetting comments in CommentCache');
_commentCache.resetComments();
emit(state.copyWith(itemScreenArgs: args));
}

View File

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

View File

@ -13,21 +13,26 @@ class TabCubit extends Cubit<TabState> {
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_logger = logger ?? locator.get<Logger>(),
super(TabState.init());
super(TabState.init()) {
init();
}
final PreferenceCubit _preferenceCubit;
final Logger _logger;
static const String _logPrefix = '[TabCubit]';
void init() {
final List<StoryType> tabs = _preferenceCubit.state.tabs;
_logger.i('updating tabs to $tabs');
_logger.i('$_logPrefix updating tabs to $tabs');
emit(state.copyWith(tabs: tabs));
}
void update(int startIndex, int endIndex) {
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
_logger.d(
'$_logPrefix updating ${state.tabs} by moving $startIndex to $endIndex',
);
final StoryType tab = state.tabs.elementAt(startIndex);
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
..insert(endIndex, tab)

View File

@ -54,6 +54,13 @@ Future<void> main({bool testing = false}) async {
final String tempPath = tempDir.path;
Hive.init(tempPath);
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
: await getTemporaryDirectory(),
);
HydratedBloc.storage = storage;
await setUpLocator();
EquatableConfig.stringify = true;
@ -66,12 +73,6 @@ Future<void> main({bool testing = false}) async {
);
};
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
: await getTemporaryDirectory(),
);
if (Platform.isIOS) {
unawaited(
Workmanager().initialize(
@ -139,8 +140,6 @@ Future<void> main({bool testing = false}) async {
// Uncomment this line to log events from bloc/cubit.
// Bloc.observer = CustomBlocObserver();
HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
runApp(
@ -162,6 +161,9 @@ class HackiApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<RemoteConfigCubit>.value(
value: locator.get<RemoteConfigCubit>(),
),
BlocProvider<PreferenceCubit>(
lazy: false,
create: (BuildContext context) => PreferenceCubit(),
@ -217,7 +219,7 @@ class HackiApp extends StatelessWidget {
),
BlocProvider<ReminderCubit>(
lazy: false,
create: (BuildContext context) => ReminderCubit()..init(),
create: (BuildContext context) => ReminderCubit(),
),
BlocProvider<PostCubit>(
lazy: false,
@ -230,24 +232,24 @@ class HackiApp extends StatelessWidget {
BlocProvider<TabCubit>(
create: (BuildContext context) => TabCubit(
preferenceCubit: context.read<PreferenceCubit>(),
)..init(),
),
),
],
child: BlocConsumer<PreferenceCubit, PreferenceState>(
listenWhen: (PreferenceState previous, PreferenceState current) =>
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
previous.isHapticFeedbackEnabled != current.isHapticFeedbackEnabled,
listener: (_, PreferenceState state) {
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
HapticFeedbackUtil.enabled = state.isHapticFeedbackEnabled;
},
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
previous.isTrueDarkModeEnabled != current.isTrueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
'''${state.appColor}${state.font}${state.isTrueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -279,20 +281,25 @@ class HackiApp extends StatelessWidget {
.instance.platformDispatcher.platformBrightness,
mode,
);
final bool isDarkModeEnabled =
mode == AdaptiveThemeMode.dark ||
final bool isDarkModeEnabled = () {
if (mode == null) {
return View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark;
} else {
return mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark);
}
}();
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
);
return FeatureDiscovery(
child: MediaQuery(
@ -309,13 +316,21 @@ class HackiApp extends StatelessWidget {
theme: ThemeData(
colorScheme: colorScheme,
fontFamily: state.font.name,
canvasColor:
isDarkModeEnabled && state.isTrueDarkModeEnabled
? Palette.black
: null,
scaffoldBackgroundColor:
isDarkModeEnabled && state.isTrueDarkModeEnabled
? Palette.black
: null,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
),
switchTheme: SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
trackColor: WidgetStateProperty.resolveWith(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary.withOpacity(0.6);
} else {
return Palette.grey.withOpacity(0.2);
@ -345,7 +360,7 @@ class HackiApp extends StatelessWidget {
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
side: WidgetStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),

View File

@ -4,7 +4,8 @@ enum Font {
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true),
exo2('Exo 2');
exo2('Exo 2'),
atkinsonHyperlegible('AtkinsonHyperlegible');
const Font(this.uiLabel, {this.isSerif = false});

View File

@ -19,7 +19,7 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these preferences does not matter.
/// Order of these preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
@ -29,14 +29,18 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
AppColorPreference(),
DateFormatPreference(),
const TextScaleFactorPreference(),
// Order of items below matters and
// reflects the order on settings screen.
/// Order of items below matters and
/// reflects the order on settings screen.
const DisplayModePreference(),
const FaviconModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
// Divider.
/// Divider.
const MarkReadStoriesModePreference(),
// Divider.
/// Divider.
const NotificationModePreference(),
const AutoScrollModePreference(),
const CollapseModePreference(),
@ -160,7 +164,7 @@ final class AutoScrollModePreference extends BooleanPreference {
const AutoScrollModePreference({bool? val})
: super(val: val ?? _autoScrollModeDefaultValue);
static const bool _autoScrollModeDefaultValue = false;
static const bool _autoScrollModeDefaultValue = true;
@override
AutoScrollModePreference copyWith({required bool? val}) {
@ -201,6 +205,27 @@ final class DisplayModePreference extends BooleanPreference {
String get subtitle => 'show web preview in story tile.';
}
final class FaviconModePreference extends BooleanPreference {
const FaviconModePreference({bool? val})
: super(val: val ?? _faviconModePreferenceDefaultValue);
static const bool _faviconModePreferenceDefaultValue = true;
@override
FaviconModePreference copyWith({required bool? val}) {
return FaviconModePreference(val: val);
}
@override
String get key => 'faviconMode';
@override
String get title => 'Show Favicon';
@override
String get subtitle => 'show favicon in story tile.';
}
final class MetadataModePreference extends BooleanPreference {
const MetadataModePreference({bool? val})
: super(val: val ?? _metadataModeDefaultValue);

View File

@ -329,16 +329,32 @@ class HackerNewsRepository {
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
Stream<Story> fetchStoriesStream({
required List<int> ids,
bool sequential = false,
}) async* {
if (sequential) {
for (final int id in ids) {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
if (story != null) {
if (story != null) {
yield story;
}
}
} else {
final List<Map<String, dynamic>?> responses = await Future.wait(
<Future<Map<String, dynamic>?>>[
...ids.map(_fetchItemJson),
],
);
for (final Map<String, dynamic>? json in responses) {
if (json == null) continue;
final Story story = Story.fromJson(json);
yield story;
}
}

View File

@ -6,6 +6,8 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
import 'package:html/dom.dart' hide Comment;
@ -15,6 +17,7 @@ import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
HackerNewsWebRepository({
RemoteConfigCubit? remoteConfigCubit,
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
@ -24,10 +27,13 @@ class HackerNewsWebRepository {
if (kDebugMode) LoggerInterceptor(),
CacheInterceptor(),
],
);
),
_remoteConfigCubit =
remoteConfigCubit ?? locator.get<RemoteConfigCubit>();
final Dio _dioWithCache;
final Dio _dio;
final RemoteConfigCubit _remoteConfigCubit;
static const Map<String, String> _headers = <String, String>{
'accept': '*/*',
@ -96,16 +102,20 @@ class HackerNewsWebRepository {
}
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
static const String _athingComtrSelector =
'#hnmain > tbody > tr > td > table > tbody > .athing.comtr';
static const String _commentTextSelector =
'''td > table > tbody > tr > td.default > div.comment''';
static const String _commentHeadSelector =
'''td > table > tbody > tr > td.default > div > span > a''';
static const String _commentAgeSelector =
'''td > table > tbody > tr > td.default > div > span > span.age''';
static const String _commentIndentSelector =
'''td > table > tbody > tr > td.ind''';
String get _athingComtrSelector =>
_remoteConfigCubit.state.athingComtrSelector;
String get _commentTextSelector =>
_remoteConfigCubit.state.commentTextSelector;
String get _commentHeadSelector =>
_remoteConfigCubit.state.commentHeadSelector;
String get _commentAgeSelector => _remoteConfigCubit.state.commentAgeSelector;
String get _commentIndentSelector =>
_remoteConfigCubit.state.commentIndentSelector;
Stream<Comment> fetchCommentsStream(Item item) async* {
final bool isOnWifi = await _isOnWifi;
@ -246,8 +256,9 @@ class HackerNewsWebRepository {
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
final List<ConnectivityResult> status =
await Connectivity().checkConnectivity();
return status.contains(ConnectivityResult.wifi);
}
static Future<String> _parseCommentTextHtml(String text) async {

View File

@ -0,0 +1,23 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class RemoteConfigRepository {
RemoteConfigRepository({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
static const String _path =
'https://raw.githubusercontent.com/Livinglist/Hacki/master/assets/';
Future<Map<String, dynamic>> fetchRemoteConfig() async {
const String fileName =
kReleaseMode ? 'remote-config.json' : 'remote-config-dev.json';
final Response<dynamic> response = await _dio.get(
'$_path$fileName',
);
final String data = response.data as String? ?? '';
final Map<String, dynamic> json = jsonDecode(data) as Map<String, dynamic>;
return json;
}
}

View File

@ -4,5 +4,6 @@ export 'hacker_news_web_repository.dart';
export 'offline_repository.dart';
export 'post_repository.dart';
export 'preference_repository.dart';
export 'remote_config_repository.dart';
export 'search_repository.dart';
export 'sembast_repository.dart';

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
@ -17,7 +19,8 @@ class SembastRepository {
SembastRepository({
Database? database,
Database? cache,
}) {
Logger? logger,
}) : _logger = logger ?? locator.get<Logger>() {
if (database == null) {
initializeDatabase();
} else {
@ -31,6 +34,9 @@ class SembastRepository {
}
}
final Logger _logger;
static const String _logPrefix = '[SembastRepository]';
Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe;
@ -44,6 +50,9 @@ class SembastRepository {
final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
final File file = File(dbPath);
final FileStat stat = file.statSync();
_logger.i('$_logPrefix hacki.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_database = db;
@ -51,9 +60,12 @@ class SembastRepository {
}
Future<Database> initializeCache() async {
final Directory dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki_cache.db');
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String dbPath = join(tempDir.path, 'hacki_cache.db');
final File file = File(dbPath);
final FileStat stat = file.statSync();
_logger.i('$_logPrefix hacki_cache.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;
@ -209,7 +221,6 @@ class SembastRepository {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
return db.transaction((Transaction txn) async {
await store.record(key).put(txn, info.toJson());
});
@ -233,17 +244,23 @@ class SembastRepository {
//#endregion
Future<FileSystemEntity> deleteCachedComments() async {
Future<void> deleteCachedComments() async {
final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete();
final File file = File(dbPath);
if (file.existsSync()) {
await file.delete();
}
}
Future<FileSystemEntity> deleteCachedMetadata() async {
Future<void> deleteCachedMetadata() async {
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String cachePath = join(tempDir.path, 'hacki_cache.db');
return File(cachePath).delete();
final File file = File(cachePath);
if (file.existsSync()) {
await file.delete();
}
}
}

View File

@ -43,13 +43,14 @@ class _HomeScreenState extends State<HomeScreen>
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
static final int tabLength = StoryType.values.length + 1;
static const String logPrefix = '[HomeScreen]';
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
locator.get<Logger>().i('$logPrefix resetting comments in CommentCache');
Future<void>.delayed(
AppDurations.ms500,
locator.get<CommentCache>().resetComments,
@ -123,9 +124,10 @@ class _HomeScreenState extends State<HomeScreen>
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.swipeGestureEnabled != current.swipeGestureEnabled,
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isSwipeGestureEnabled != current.isSwipeGestureEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return DefaultTabController(
length: tabLength,
@ -150,7 +152,7 @@ class _HomeScreenState extends State<HomeScreen>
body: BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return TabBarView(
physics: preferenceState.swipeGestureEnabled
physics: preferenceState.isSwipeGestureEnabled
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
controller: tabController,
@ -190,12 +192,12 @@ class _HomeScreenState extends State<HomeScreen>
void onStoryTapped(Story story) {
final PreferenceState prefState = context.read<PreferenceCubit>().state;
final bool useReader = prefState.readerEnabled;
final bool useReader = prefState.isReaderEnabled;
final StoryMarkingMode storyMarkingMode = prefState.storyMarkingMode;
final bool offlineReading =
context.read<StoriesBloc>().state.isOfflineReading;
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
final bool markReadStoriesEnabled = prefState.markReadStoriesEnabled;
final bool markReadStoriesEnabled = prefState.isMarkReadStoriesEnabled;
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.

View File

@ -24,6 +24,14 @@ class MobileHomeScreen extends StatelessWidget {
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
)
else
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: DownloadProgressReminder(),
),
],
);

View File

@ -37,7 +37,7 @@ class PinnedStories extends StatelessWidget {
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.complexStoryTileEnabled
icon: preferenceState.isComplexStoryTileEnabled
? Icons.close
: null,
label: 'Unpin',
@ -51,9 +51,10 @@ class PinnedStories extends StatelessWidget {
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story),
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
showWebPreview: preferenceState.isComplexStoryTileEnabled,
showMetadata: preferenceState.isMetadataEnabled,
showUrl: preferenceState.isUrlEnabled,
showFavicon: preferenceState.isFaviconEnabled,
),
),
),

View File

@ -41,13 +41,22 @@ class TabletHomeScreen extends StatelessWidget {
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
if (!context.read<ReminderCubit>().state.hasShown)
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
)
else
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const DownloadProgressReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,

View File

@ -53,6 +53,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
controller: usernameController,
cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false,
autofillHints: const <String>[AutofillHints.username],
decoration: InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
@ -75,6 +76,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
cursorColor: Theme.of(context).colorScheme.primary,
obscureText: true,
autocorrect: false,
autofillHints: const <String>[AutofillHints.password],
decoration: InputDecoration(
hintText: 'Password',
focusedBorder: UnderlineInputBorder(
@ -182,7 +184,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
}
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
backgroundColor: WidgetStateProperty.all(
state.agreedToEULA
? Theme.of(context).colorScheme.primary
: Palette.grey,

View File

@ -150,7 +150,7 @@ class MainView extends StatelessWidget {
},
),
),
if (context.read<PreferenceCubit>().state.devModeEnabled)
if (context.read<PreferenceCubit>().state.isDevModeEnabled)
Positioned(
height: Dimens.pt4,
bottom: Dimens.zero,
@ -289,7 +289,7 @@ class _ParentItemSection extends StatelessWidget {
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
.isReaderEnabled,
offlineReading: context
.read<StoriesBloc>()
.state

View File

@ -5,11 +5,9 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/profile/models/models.dart';
import 'package:hacki/screens/profile/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
@ -98,6 +96,7 @@ class _ProfileScreenState extends State<ProfileScreen>
return ItemsListView<Item>(
showWebPreviewOnStoryTile: false,
showMetadataOnStoryTile: false,
showFavicon: false,
showUrl: false,
showAuthor: false,
useSimpleTileForStory: true,
@ -192,21 +191,22 @@ class _ProfileScreenState extends State<ProfileScreen>
PreferenceState previous,
PreferenceState current,
) =>
previous.complexStoryTileEnabled !=
current.complexStoryTileEnabled ||
previous.metadataEnabled !=
current.metadataEnabled ||
previous.urlEnabled != current.urlEnabled,
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled !=
current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile:
prefState.complexStoryTileEnabled,
prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile:
prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshControllerFav,
items: favState.favItems,
@ -417,27 +417,27 @@ class _ProfileScreenState extends State<ProfileScreen>
void onCommentTapped(Comment comment, {VoidCallback? then}) {
throttle.run(() {
locator
.get<HackerNewsRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then(((Story, List<Comment>)? res) {
if (res != null && mounted) {
final Story parent = res.$1;
final List<Comment> children = res.$2;
goToItemScreen(
args: ItemScreenArgs(
item: parent,
targetComments: children.isEmpty
? <Comment>[comment]
: <Comment>[
...children,
comment.copyWith(level: children.length),
],
onlyShowTargetComment: true,
),
)?.then((_) => then?.call());
}
});
context.read<NotificationCubit>().onCommentTapped(
comment,
then: ((Story, List<Comment>)? res) {
if (res != null && mounted) {
final Story parent = res.$1;
final List<Comment> children = res.$2;
goToItemScreen(
args: ItemScreenArgs(
item: parent,
targetComments: children.isEmpty
? <Comment>[comment]
: <Comment>[
...children,
comment.copyWith(level: children.length),
],
onlyShowTargetComment: true,
),
)?.then((_) => then?.call());
}
},
);
});
}

View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
class EnterOfflineModeListTile extends StatelessWidget {
const EnterOfflineModeListTile({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) =>
previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) {
return SwitchListTile(
value: state.isOfflineReading,
title: const Text('Offline Mode'),
onChanged: (bool value) {
HapticFeedbackUtil.light();
context.read<StoriesBloc>().add(
value ? StoriesEnterOfflineMode() : StoriesExitOfflineMode(),
);
},
);
},
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/cubits/notification/notification_cubit.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -30,7 +32,9 @@ class InboxView extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
if (unreadCommentsIds.isNotEmpty)
if (context.read<NotificationCubit>().state.status !=
Status.inProgress &&
unreadCommentsIds.isNotEmpty)
TextButton(
onPressed: onMarkAllAsReadTapped,
child: const Text('Mark all as read'),
@ -69,64 +73,79 @@ class InboxView extends StatelessWidget {
child: ListView(
children: <Widget>[
...comments.map((Comment e) {
final NotificationState state =
context.read<NotificationCubit>().state;
return <Widget>[
FadeIn(
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
),
child: InkWell(
onTap: () => onCommentTapped(e),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Dimens.pt8,
horizontal: Dimens.pt6,
Stack(
children: <Widget>[
if (state.commentFetchingStatus == Status.inProgress &&
state.tappedCommentId == e.id)
Positioned(
left: Dimens.zero,
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
child: LinearProgressIndicator(
color: Theme.of(context)
.primaryColor
.withOpacity(0.1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
'''${e.timeAgo} from ${e.by}:''',
style: TextStyle(
color: Theme.of(context).metadataColor,
),
),
const SizedBox(
width: Dimens.pt12,
),
],
),
Linkify(
text: e.text,
style: TextStyle(
color: unreadCommentsIds.contains(e.id)
? Theme.of(context)
.colorScheme
.onSurface
: Theme.of(context).readGrey,
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(
unreadCommentsIds.contains(e.id)
? 1
: 0.6,
),
FadeIn(
child: InkWell(
onTap: () => onCommentTapped(e),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Dimens.pt8,
horizontal: Dimens.pt12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
'''${e.timeAgo} from ${e.by}:''',
style: TextStyle(
color:
Theme.of(context).metadataColor,
),
),
const SizedBox(
width: Dimens.pt12,
),
],
),
maxLines: 4,
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url, context),
),
],
Linkify(
text: e.text,
style: TextStyle(
color: unreadCommentsIds.contains(e.id)
? Theme.of(context)
.colorScheme
.onSurface
: Theme.of(context).readGrey,
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(
unreadCommentsIds.contains(e.id)
? 1
: 0.6,
),
),
maxLines: 4,
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url, context),
),
],
),
),
),
),
),
],
),
const Divider(
height: Dimens.zero,

View File

@ -91,8 +91,10 @@ class OfflineListTile extends StatelessWidget {
}
});
} else {
Connectivity().checkConnectivity().then((ConnectivityResult res) {
if (res != ConnectivityResult.none) {
Connectivity()
.checkConnectivity()
.then((List<ConnectivityResult> res) {
if (!res.contains(ConnectivityResult.none)) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(

View File

@ -23,6 +23,7 @@ import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/profile/models/page_type.dart';
import 'package:hacki/screens/profile/qr_code_scanner_screen.dart';
import 'package:hacki/screens/profile/qr_code_view_screen.dart';
import 'package:hacki/screens/profile/widgets/enter_offline_mode_list_tile.dart';
import 'package:hacki/screens/profile/widgets/offline_list_tile.dart';
import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/profile/widgets/text_scale_factor_settings.dart';
@ -78,6 +79,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
}
},
),
const EnterOfflineModeListTile(),
const OfflineListTile(),
const SizedBox(
height: Dimens.pt8,
@ -205,9 +207,10 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
const TextScaleFactorSettings(),
const Divider(),
StoryTile(
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
showWebPreview: preferenceState.isComplexStoryTileEnabled,
showMetadata: preferenceState.isMetadataEnabled,
showUrl: preferenceState.isUrlEnabled,
showFavicon: preferenceState.isFaviconEnabled,
story: Story.placeholder(),
onTap: () => LinkUtil.launch(
Constants.guidelineLink,
@ -252,7 +255,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
horizontal: Dimens.pt16,
),
child: DropdownMenu<StoryMarkingMode>(
enabled: preferenceState.markReadStoriesEnabled,
enabled: preferenceState.isMarkReadStoriesEnabled,
label: Text(StoryMarkingModePreference().title),
initialSelection: preferenceState.storyMarkingMode,
onSelected: (StoryMarkingMode? storyMarkingMode) {
@ -338,13 +341,20 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
),
onTap: showClearCacheDialog,
),
if (preferenceState.isDevModeEnabled)
ListTile(
title: const Text(
'Logs',
),
onTap: () {},
),
ListTile(
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),
onTap: showAboutHackiDialog,
onLongPress: () {
final DevMode updatedDevMode =
DevMode(val: !preferenceState.devModeEnabled);
DevMode(val: !preferenceState.isDevModeEnabled);
context.read<PreferenceCubit>().update(updatedDevMode);
HapticFeedbackUtil.heavy();
if (updatedDevMode.val) {

View File

@ -1,4 +1,5 @@
export 'centered_message_view.dart';
export 'enter_offline_mode_list_tile.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';
export 'settings.dart';

View File

@ -46,7 +46,9 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
void dispose() {
refreshController.dispose();
scrollController.dispose();
focusNode.dispose();
focusNode
..unfocus()
..dispose();
textEditingController.dispose();
super.dispose();
}
@ -359,9 +361,11 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
FadeIn(
child: StoryTile(
showWebPreview:
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
prefState.isComplexStoryTileEnabled,
showMetadata:
prefState.isMetadataEnabled,
showUrl: prefState.isUrlEnabled,
showFavicon: prefState.isFaviconEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
@ -383,7 +387,7 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
),
),
),
if (!prefState.complexStoryTileEnabled)
if (!prefState.isComplexStoryTileEnabled)
const Divider(
height: Dimens.zero,
),

View File

@ -76,7 +76,7 @@ class PostedByFilterChip extends StatelessWidget {
),
),
TextButton(
onPressed: () => context.pop(null),
onPressed: () => context.pop(),
child: const Text(
'Clear',
),
@ -87,7 +87,7 @@ class PostedByFilterChip extends StatelessWidget {
context.pop(text.isEmpty ? null : text);
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
backgroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
),

View File

@ -56,6 +56,14 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
// Don't show confirmation dialog if content is empty.
if (state.text.isNullOrEmpty &&
state.url.isNullOrEmpty &&
state.title.isNullOrEmpty) {
context.pop();
return;
}
showDialog<bool>(
context: context,
barrierDismissible: false,
@ -114,7 +122,36 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
onPressed: context.read<SubmitCubit>().onSubmitTapped,
onPressed: () {
showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Submit?'),
actions: <Widget>[
TextButton(
onPressed: () => context.pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => context.pop(true),
child: const Text(
'Yes',
style: TextStyle(
color: Palette.red,
),
),
),
],
);
},
).then((bool? value) {
if (value ?? false) {
context.read<SubmitCubit>().submit();
}
});
},
)
else
IconButton(

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -10,6 +11,7 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
@ -308,7 +310,7 @@ class CommentTile extends StatelessWidget {
final double commentBackgroundColorOpacity =
Theme.of(context).canvasColor != Palette.white ? 0.03 : 0.15;
final Color commentColor = prefState.eyeCandyEnabled
final Color commentColor = prefState.isEyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
final bool isMyComment = comment.deleted == false &&
@ -404,7 +406,7 @@ class CommentTile extends StatelessWidget {
}
void _onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
if (context.read<PreferenceCubit>().state.isTapAnywhereToCollapseEnabled) {
_collapse(context);
}
}
@ -414,21 +416,31 @@ class CommentTile extends StatelessWidget {
final CollapseCubit collapseCubit = context.read<CollapseCubit>()
..collapse(onStateChanged: HapticFeedbackUtil.selection);
if (collapseCubit.state.collapsed &&
preferenceCubit.state.autoScrollEnabled) {
preferenceCubit.state.isAutoScrollEnabled) {
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
final List<Comment> comments = commentsCubit.state.comments;
final int indexOfNextComment = comments.indexOf(comment) + 1;
if (indexOfNextComment < comments.length) {
Future<void>.delayed(
AppDurations.ms300,
() {
commentsCubit.itemScrollController.scrollTo(
index: indexOfNextComment,
alignment: 0.1,
duration: AppDurations.ms300,
);
},
);
final int indexOfComment = comments.indexOf(comment);
if (indexOfComment < comments.length) {
final double? leadingEdge =
commentsCubit.itemPositionsListener.itemPositions.value
.singleWhereOrNull(
(ItemPosition e) => e.index - 1 == indexOfComment,
)
?.itemLeadingEdge;
final bool willBeOutsideOfScreen =
leadingEdge != null && leadingEdge < 0.1;
if (willBeOutsideOfScreen) {
Future<void>.delayed(
AppDurations.ms200,
() {
commentsCubit.itemScrollController.scrollTo(
index: indexOfComment + 1,
alignment: 0.15,
duration: AppDurations.ms300,
);
},
);
}
}
}
}

View File

@ -18,13 +18,15 @@ class CustomChip extends StatelessWidget {
return FilterChip(
shadowColor: Palette.transparent,
selectedShadowColor: Palette.transparent,
backgroundColor: Palette.transparent,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
side: selected
? BorderSide(color: Theme.of(context).colorScheme.primary)
: BorderSide(color: Theme.of(context).colorScheme.onSurface),
label: Text(label),
labelStyle: TextStyle(
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
color: selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
checkmarkColor: selected ? Theme.of(context).colorScheme.onPrimary : null,
selected: selected,

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/styles/styles.dart';
class DownloadProgressReminder extends StatefulWidget {
const DownloadProgressReminder({super.key});
@override
State<DownloadProgressReminder> createState() =>
_DownloadProgressReminderState();
}
class _DownloadProgressReminderState extends State<DownloadProgressReminder>
with SingleTickerProviderStateMixin, ItemActionMixin {
@override
Widget build(BuildContext context) {
return BlocSelector<StoriesBloc, StoriesState,
(int, int, StoriesDownloadStatus)>(
selector: (StoriesState state) {
return (
state.storiesDownloaded,
state.storiesToBeDownloaded,
state.downloadStatus
);
},
builder: (BuildContext context, (int, int, StoriesDownloadStatus) state) {
final int storiesDownloaded = state.$1;
final int storiesToBeDownloaded = state.$2;
final StoriesDownloadStatus status = state.$3;
final double progress = storiesToBeDownloaded == 0
? 0
: storiesDownloaded / storiesToBeDownloaded;
final bool isVisible = status == StoriesDownloadStatus.downloading;
return Visibility(
visible: isVisible,
child: FadeIn(
child: Material(
color: Theme.of(context).colorScheme.primary,
clipBehavior: Clip.hardEdge,
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt4,
),
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt12,
top: Dimens.pt10,
right: Dimens.pt10,
),
child: Row(
children: <Widget>[
Text(
'Downloading all stories ($storiesDownloaded/$storiesToBeDownloaded)',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: TextDimens.pt12,
),
),
const Spacer(),
],
),
),
const Spacer(),
LinearProgressIndicator(
value: progress,
color:
Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
],
),
),
),
);
},
);
}
}

View File

@ -15,6 +15,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
const ItemsListView({
required this.showWebPreviewOnStoryTile,
required this.showMetadataOnStoryTile,
required this.showFavicon,
required this.showUrl,
required this.items,
required this.onTap,
@ -40,6 +41,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool useSimpleTileForStory;
final bool showWebPreviewOnStoryTile;
final bool showMetadataOnStoryTile;
final bool showFavicon;
final bool showUrl;
final bool enablePullDown;
final bool markReadStories;
@ -74,7 +76,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
if (e is Story) {
final bool hasRead = context.read<StoriesBloc>().hasRead(e);
final bool swipeGestureEnabled =
context.read<PreferenceCubit>().state.swipeGestureEnabled;
context.read<PreferenceCubit>().state.isSwipeGestureEnabled;
return <Widget>[
if (useSimpleTileForStory)
FadeIn(
@ -140,6 +142,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
showWebPreview: showWebPreviewOnStoryTile,
showMetadata: showMetadataOnStoryTile,
showUrl: showUrl,
showFavicon: showFavicon,
hasRead: markReadStories && hasRead,
),
),

View File

@ -145,6 +145,7 @@ class _LinkPreviewState extends State<LinkPreview> {
String? title = '',
String? desc = '',
String? imageUri = '',
String? iconUri = '',
bool isIcon = false,
}) {
return Container(
@ -169,6 +170,7 @@ class _LinkPreviewState extends State<LinkPreview> {
title: widget.story.title,
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
iconUri: iconUri,
imagePath: Constants.hackerNewsLogoPath,
onTap: widget.onTap,
hasRead: widget.hasRead,
@ -209,6 +211,7 @@ class _LinkPreviewState extends State<LinkPreview> {
title: _errorTitle,
desc: _errorBody,
imageUri: null,
iconUri: null,
)
: _buildLinkContainer(
context.storyTileHeight,
@ -216,14 +219,8 @@ class _LinkPreviewState extends State<LinkPreview> {
desc: WebAnalyzer.isNotEmpty(info!.description)
? info.description
: _errorBody,
imageUri: widget.showMultimedia
? (WebAnalyzer.isNotEmpty(info.image)
? info.image
: WebAnalyzer.isNotEmpty(info.icon)
? info.icon
: null)
: null,
isIcon: !WebAnalyzer.isNotEmpty(info.image),
imageUri: widget.showMultimedia ? info.image : null,
iconUri: widget.showMultimedia ? info.icon : null,
);
return AnimatedCrossFade(

View File

@ -3,7 +3,9 @@ import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/widgets/tap_down_wrapper.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
@ -23,6 +25,7 @@ class LinkView extends StatelessWidget {
required this.bodyMaxLines,
super.key,
this.imageUri,
this.iconUri,
this.imagePath,
this.showMultiMedia = true,
this.bodyTextOverflow,
@ -43,6 +46,7 @@ class LinkView extends StatelessWidget {
final String title;
final String description;
final String? imageUri;
final String? iconUri;
final String? imagePath;
final VoidCallback onTap;
final bool showMultiMedia;
@ -111,24 +115,37 @@ class LinkView extends StatelessWidget {
child: SizedBox(
height: layoutHeight,
width: layoutHeight,
child: (imageUri?.isEmpty ?? true) && imagePath != null
? Image.asset(
imagePath!,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: imageUri!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri,
errorWidget: (_, __, ___) => Center(
child: Text(
r'¯\_(ツ)_/¯',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
child: CachedNetworkImage(
imageUrl: imageUri ?? '',
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri,
errorWidget: (_, __, ___) {
if (url.isEmpty) {
return FadeIn(
child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
);
},
),
);
},
),
),
),
)
@ -156,3 +173,23 @@ class LinkView extends StatelessWidget {
);
}
}
class _HackerNewsImage extends StatelessWidget {
const _HackerNewsImage({
required this.height,
});
final double height;
@override
Widget build(BuildContext context) {
return FadeIn(
child: Image.asset(
Constants.hackerNewsLogoPath,
height: height,
width: height,
fit: BoxFit.fitWidth,
),
);
}
}

View File

@ -59,7 +59,9 @@ class OfflineBanner extends StatelessWidget {
},
).then((bool? value) {
if (value ?? false) {
context.read<StoriesBloc>().add(StoriesExitOffline());
context
.read<StoriesBloc>()
.add(StoriesExitOfflineMode());
context.read<AuthBloc>().add(AuthInitialize());
context.read<PinCubit>().init();
WebAnalyzer.cacheMap.clear();

View File

@ -49,9 +49,11 @@ class _StoriesListViewState extends State<StoriesListView>
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.manualPaginationEnabled != current.manualPaginationEnabled,
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isManualPaginationEnabled !=
current.isManualPaginationEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (StoriesState previous, StoriesState current) =>
@ -75,7 +77,7 @@ class _StoriesListViewState extends State<StoriesListView>
current.statusByType[widget.storyType]),
builder: (BuildContext context, StoriesState state) {
bool shouldShowLoadButton() {
return preferenceState.manualPaginationEnabled &&
return preferenceState.isManualPaginationEnabled &&
state.statusByType[widget.storyType] == Status.success &&
(state.storiesByType[widget.storyType]?.length ?? 0) <
(state.storyIdsByType[widget.storyType]?.length ?? 0);
@ -83,11 +85,12 @@ class _StoriesListViewState extends State<StoriesListView>
return ItemsListView<Story>(
showOfflineBanner: true,
markReadStories: preferenceState.markReadStoriesEnabled,
markReadStories: preferenceState.isMarkReadStoriesEnabled,
showWebPreviewOnStoryTile:
preferenceState.complexStoryTileEnabled,
showMetadataOnStoryTile: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
preferenceState.isComplexStoryTileEnabled,
showMetadataOnStoryTile: preferenceState.isMetadataEnabled,
showFavicon: preferenceState.isFaviconEnabled,
showUrl: preferenceState.isUrlEnabled,
refreshController: refreshController,
scrollController: scrollController,
items: state.storiesByType[storyType]!,
@ -99,7 +102,7 @@ class _StoriesListViewState extends State<StoriesListView>
context.read<PinCubit>().refresh();
},
onLoadMore: () {
if (preferenceState.manualPaginationEnabled) {
if (preferenceState.isManualPaginationEnabled) {
refreshController
..refreshCompleted(resetFooterState: true)
..loadComplete();
@ -128,10 +131,10 @@ class _StoriesListViewState extends State<StoriesListView>
child: OutlinedButton(
onPressed: loadMoreStories,
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
minimumSize: WidgetStateProperty.all(
const Size(double.infinity, Dimens.pt48),
),
foregroundColor: MaterialStateColor.resolveWith(
foregroundColor: WidgetStateColor.resolveWith(
(_) => Theme.of(context).colorScheme.onSurface,
),
),
@ -147,7 +150,7 @@ class _StoriesListViewState extends State<StoriesListView>
itemBuilder: (Widget child, Story story) {
return Slidable(
key: ValueKey<Story>(story),
enabled: !preferenceState.swipeGestureEnabled,
enabled: !preferenceState.isSwipeGestureEnabled,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
@ -159,10 +162,10 @@ class _StoriesListViewState extends State<StoriesListView>
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
icon: preferenceState.complexStoryTileEnabled
icon: preferenceState.isComplexStoryTileEnabled
? Icons.push_pin_outlined
: null,
label: preferenceState.complexStoryTileEnabled
label: preferenceState.isComplexStoryTileEnabled
? null
: 'Pin',
),
@ -171,10 +174,10 @@ class _StoriesListViewState extends State<StoriesListView>
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
icon: preferenceState.complexStoryTileEnabled
icon: preferenceState.isComplexStoryTileEnabled
? Icons.more_horiz
: null,
label: preferenceState.complexStoryTileEnabled
label: preferenceState.isComplexStoryTileEnabled
? null
: 'More',
),
@ -193,9 +196,10 @@ class _StoriesListViewState extends State<StoriesListView>
children: <Widget>[
SlidableAction(
onPressed: (_) => mark(story),
backgroundColor: preferenceState.markReadStoriesEnabled
? Theme.of(context).colorScheme.primary
: Palette.grey,
backgroundColor:
preferenceState.isMarkReadStoriesEnabled
? Theme.of(context).colorScheme.primary
: Palette.grey,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
icon: state.readStoriesIds.contains(story.id)
@ -241,7 +245,7 @@ class _StoriesListViewState extends State<StoriesListView>
HapticFeedbackUtil.light();
final StoriesBloc storiesBloc = context.read<StoriesBloc>();
final bool markReadStoriesEnabled =
context.read<PreferenceCubit>().state.markReadStoriesEnabled;
context.read<PreferenceCubit>().state.isMarkReadStoriesEnabled;
if (markReadStoriesEnabled) {
if (storiesBloc.state.readStoriesIds.contains(story.id)) {
storiesBloc.add(StoryUnread(story: story));

View File

@ -1,18 +1,22 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:shimmer/shimmer.dart';
class StoryTile extends StatelessWidget {
const StoryTile({
required this.showWebPreview,
required this.showMetadata,
required this.showFavicon,
required this.showUrl,
required this.story,
required this.onTap,
@ -23,6 +27,7 @@ class StoryTile extends StatelessWidget {
final bool showWebPreview;
final bool showMetadata;
final bool showFavicon;
final bool showUrl;
final bool hasRead;
final Story story;
@ -123,71 +128,125 @@ class StoryTile extends StatelessWidget {
excludeSemantics: true,
child: InkWell(
onTap: onTap,
onLongPress: () {
if (story.url.isNotEmpty) {
LinkUtil.launch(
story.url,
context,
useReader:
context.read<PreferenceCubit>().state.isReaderEnabled,
offlineReading:
context.read<StoriesBloc>().state.isOfflineReading,
);
}
},
child: Padding(
padding: const EdgeInsets.only(left: Dimens.pt12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color: hasRead
? Theme.of(context).readGrey
: null,
fontWeight:
hasRead ? null : FontWeight.bold,
),
),
if (showUrl && story.url.isNotEmpty)
if (showFavicon) ...<Widget>[
if (story.url.isNotEmpty)
SizedBox(
height: Dimens.pt20,
width: Dimens.pt24,
child: Center(
child: CachedNetworkImage(
fit: BoxFit.fitHeight,
imageUrl: Constants.favicon(story.url),
errorWidget: (_, __, ___) {
return const Icon(
Icons.public,
size: Dimens.pt20,
);
},
),
),
)
else
const SizedBox(
height: Dimens.pt20,
width: Dimens.pt24,
child: Center(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
),
),
const SizedBox(
width: Dimens.pt8,
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text.rich(
TextSpan(
text: ' (${story.readableUrl})',
children: <TextSpan>[
TextSpan(
text: story.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color: hasRead
? Theme.of(context).readGrey
: null,
fontWeight:
hasRead ? null : FontWeight.bold,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: hasRead
? Theme.of(context).readGrey
: null,
),
),
],
),
textScaler: MediaQuery.of(context).textScaler,
),
),
],
),
if (showMetadata)
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: hasRead
? Theme.of(context).readGrey
: null,
: Theme.of(context).metadataColor,
),
maxLines: 1,
),
),
],
),
textScaler: MediaQuery.of(context).textScaler,
),
),
],
),
if (showMetadata)
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasRead
? Theme.of(context).readGrey
: Theme.of(context).metadataColor,
),
maxLines: 1,
),
const SizedBox(
height: Dimens.pt14,
),
],
),
const SizedBox(
height: Dimens.pt8,
),
],
),

View File

@ -10,6 +10,7 @@ export 'custom_dropdown_menu.dart';
export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart';
export 'device_gesture_wrapper.dart';
export 'download_progress_reminder.dart';
export 'item_text.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';

View File

@ -38,15 +38,15 @@ abstract class Fetcher {
final Logger logger = Logger();
final PreferenceRepository preferenceRepository =
PreferenceRepository(logger: logger);
final AuthRepository authRepository = AuthRepository(
preferenceRepository: preferenceRepository,
logger: logger,
);
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
final SembastRepository sembastRepository = SembastRepository();
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository(
sembastRepository: sembastRepository,
logger: logger,
);
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();

View File

@ -85,6 +85,7 @@ class WebAnalyzer {
RegExp('(title|icon|description|image)', caseSensitive: false);
static final RegExp _lineReg = RegExp(r'[\n\r]|&nbsp;|&gt;');
static final RegExp _spaceReg = RegExp(r'\s+');
static const String _logPrefix = '[WebAnalyzer]';
static bool isEmpty(String? str) {
return !isNotEmpty(str);
@ -120,7 +121,7 @@ class WebAnalyzer {
if (info != null) {
locator.get<Logger>().d('''
fetched mem cached metadata using key $key for $story:
$_logPrefix fetched mem cached metadata using key $key for $story:
${info.toJson()}
''');
return info;
@ -168,7 +169,7 @@ ${info.toJson()}
/// [5] If there is file cache, move it to mem cache for later retrieval.
if (info != null) {
locator.get<Logger>().d('''
fetched file cached metadata using key $key for $story:
$_logPrefix fetched file cached metadata using key $key for $story:
${info.toJson()}
''');
cacheMap[key] = info;
@ -189,7 +190,7 @@ ${info.toJson()}
if (info is WebInfo) {
locator
.get<Logger>()
.d('caching metadata using key $key for $story.');
.d('$_logPrefix caching metadata using key $key for $story.');
unawaited(
locator.get<SembastRepository>().cacheMetadata(
key: key,
@ -232,23 +233,20 @@ ${info.toJson()}
required Story story,
String? url,
}) async {
List<dynamic>? res;
if (url != null) {
res = await compute(
_fetchInfoFromUrl,
<dynamic>[url, multimedia],
);
}
if (url == null) return null;
final List<dynamic>? res = await compute(
_fetchInfoFromUrl,
<dynamic>[url, multimedia],
);
late final bool shouldRetry;
InfoBase? info;
String? fallbackDescription;
// If description is empty, use one of the comments under the story.
if (res == null || isEmpty(res[2] as String?)) {
final List<int> ids = <int>[story.id, ...story.kids];
final String? commentText = await _fetchInfoFromStory(ids);
shouldRetry = commentText == null;
fallbackDescription = commentText ?? 'no comment yet';
} else {
@ -279,7 +277,6 @@ ${info.toJson()}
description: fallbackDescription,
).._shouldRetry = shouldRetry;
}
return info;
}
@ -291,7 +288,6 @@ ${info.toJson()}
final bool multimedia = message[1] as bool;
final InfoBase? info = await _getInfo(url, multimedia);
if (info is WebInfo) {
return <dynamic>[
'0',
@ -419,14 +415,12 @@ ${info.toJson()}
} catch (e) {
try {
html = gbk.decode(response.bodyBytes);
} catch (e) {
locator
.get<Logger>()
.e('''web page resolution failure from:$url Error:$e''');
}
} catch (_) {}
}
if (html == null) return null;
if (html == null) {
return null;
}
final String headHtml = _getHeadHtml(html);
final Document document = parser.parse(headHtml);
@ -443,7 +437,7 @@ ${info.toJson()}
final WebInfo info = WebInfo(
title: _analyzeTitle(document),
icon: _analyzeIcon(document, uri),
icon: await _analyzeIcon(document, uri),
description: _analyzeDescription(document, html),
image: _analyzeImage(document, uri),
);
@ -530,7 +524,7 @@ ${info.toJson()}
return description;
}
static String? _analyzeIcon(Document document, Uri uri) {
static Future<String?> _analyzeIcon(Document document, Uri uri) async {
final List<Element> meta = document.head!.getElementsByTagName('link');
String? icon = '';
// get icon first
@ -559,7 +553,7 @@ ${info.toJson()}
if (metaIcon != null) {
icon = metaIcon.attributes['href'];
} else {
return '${uri.origin}/favicon.ico';
return null;
}
return _handleUrl(uri, icon);

View File

@ -73,7 +73,8 @@ abstract class LinkUtil {
if (val) {
if (link.contains('http')) {
if (Platform.isAndroid &&
context.read<PreferenceCubit>().state.customTabEnabled == false) {
context.read<PreferenceCubit>().state.isCustomTabEnabled ==
false) {
launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
final Color primaryColor = Theme.of(context).colorScheme.primary;

View File

@ -8,10 +8,12 @@ import 'package:path_provider/path_provider.dart';
abstract class LogUtil {
static LogPrinter get logPrinter => kReleaseMode
? SimplePrinter(colors: false)
: PrettyPrinter(
methodCount: 0,
? SimplePrinter(
colors: false,
printTime: true,
)
: PrettyPrinter(
printTime: true,
);
static LogOutput logOutput(File outputFile) => MultiOutput(

View File

@ -33,14 +33,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.11"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.5.0"
async:
dependency: transitive
description:
@ -61,26 +77,26 @@ packages:
dependency: "direct main"
description:
name: bloc
sha256: f53a110e3b48dcd78136c10daa5d51512443cea5e1348c9d80a320095fa2db9e
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
url: "https://pub.dev"
source: hosted
version: "8.1.3"
version: "8.1.4"
bloc_concurrency:
dependency: "direct main"
description:
name: bloc_concurrency
sha256: "5857eb6653b4dd5e30e1ffab91037957fd64a9b9c5e53d26714ef25a46c04679"
sha256: "456b7a3616a7c1ceb975c14441b3f198bf57d81cb95b7c6de5cb0c60201afcd8"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
version: "0.2.5"
bloc_test:
dependency: "direct dev"
description:
name: bloc_test
sha256: "55a48f69e0d480717067c5377c8485a3fcd41f1701a820deef72fa0f4ee7215f"
sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2"
url: "https://pub.dev"
source: hosted
version: "9.1.6"
version: "9.1.7"
boolean_selector:
dependency: transitive
description:
@ -109,10 +125,10 @@ packages:
dependency: transitive
description:
name: cached_network_image_web
sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316"
sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.2.0"
characters:
dependency: transitive
description:
@ -149,18 +165,18 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0"
sha256: db7a4e143dc72cc3cb2044ef9b052a7ebfe729513e6a82943bc3526f784365b8
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "6.0.3"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb
url: "https://pub.dev"
source: hosted
version: "1.2.4"
version: "2.0.0"
convert:
dependency: transitive
description:
@ -173,18 +189,18 @@ packages:
dependency: transitive
description:
name: coverage
sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76"
sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e"
url: "https://pub.dev"
source: hosted
version: "1.7.2"
version: "1.8.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e
sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32"
url: "https://pub.dev"
source: hosted
version: "0.3.3+8"
version: "0.3.4+1"
crypto:
dependency: transitive
description:
@ -213,10 +229,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110"
sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91
url: "https://pub.dev"
source: hosted
version: "9.1.2"
version: "10.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@ -237,10 +253,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3"
sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5"
url: "https://pub.dev"
source: hosted
version: "5.4.0"
version: "5.4.3+1"
equatable:
dependency: "direct main"
description:
@ -269,8 +285,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: flutter3_compatibility
resolved-ref: "896306d04130f870c7ce99ce832fd977283b2392"
ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b
resolved-ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b
url: "https://github.com/livinglist/feature_discovery"
source: git
version: "0.14.1"
@ -307,18 +323,18 @@ packages:
dependency: "direct main"
description:
name: flutter_bloc
sha256: "87325da1ac757fcc4813e6b34ed5dd61169973871fdf181d6c2109dd6935ece1"
sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2
url: "https://pub.dev"
source: hosted
version: "8.1.4"
version: "8.1.5"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba"
sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.3.2"
flutter_driver:
dependency: "direct dev"
description: flutter
@ -328,10 +344,10 @@ packages:
dependency: "direct main"
description:
name: flutter_email_sender
sha256: "5001e9158f91a8799140fb30a11ad89cd587244f30b4f848d87085985c49b60f"
sha256: fb515d4e073d238d0daf1d765e5318487b6396d46b96e0ae9745dbc9a133f97a
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "6.0.3"
flutter_fadein:
dependency: "direct main"
description:
@ -408,10 +424,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: c18f1de98fe0bb9dd5ba91e1330d4febc8b6a7de6aae3ffe475ef423723e72f3
sha256: "40e6fbd2da7dcc7ed78432c5cdab1559674b4af035fddbfb2f9a8f9c2112fcef"
url: "https://pub.dev"
source: hosted
version: "16.3.2"
version: "17.1.2"
flutter_local_notifications_linux:
dependency: transitive
description:
@ -424,10 +440,10 @@ packages:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef"
sha256: "340abf67df238f7f0ef58f4a26d2a83e1ab74c77ab03cd2b2d5018ac64db30b7"
url: "https://pub.dev"
source: hosted
version: "7.0.0+1"
version: "7.1.0"
flutter_material_color_picker:
dependency: "direct main"
description:
@ -436,70 +452,79 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_native_splash:
dependency: "direct main"
description:
name: flutter_native_splash
sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840
url: "https://pub.dev"
source: hosted
version: "2.4.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685
sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "9.2.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e"
sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.2.1"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c
sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.1.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e"
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20"
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108"
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.1.2"
flutter_siri_suggestions:
dependency: "direct main"
description:
name: flutter_siri_suggestions
sha256: db5473d79fb47067fb52fdbfff3399dc4f6bbd162eeb5bde0c6d61fafcbc104b
url: "https://pub.dev"
source: hosted
path: "."
ref: master
resolved-ref: "849f17a0b26c1388c45178b554c0690637527849"
url: "https://github.com/Livinglist/flutter_siri_suggestions"
source: git
version: "2.1.0"
flutter_slidable:
dependency: "direct main"
description:
name: flutter_slidable
sha256: "19ed4813003a6ff4e9c6bcce37e792a2a358919d7603b2b31ff200229191e44c"
sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -522,10 +547,10 @@ packages:
dependency: transitive
description:
name: frontend_server_client
sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612"
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "3.2.0"
version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
@ -535,10 +560,10 @@ packages:
dependency: "direct main"
description:
name: get_it
sha256: e6017ce7fdeaf218dc51a100344d8cb70134b80e28b760f8bb23c242437bafd7
sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1
url: "https://pub.dev"
source: hosted
version: "7.6.7"
version: "7.7.0"
glob:
dependency: transitive
description:
@ -551,10 +576,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "170c46e237d6eb0e6e9f0e8b3f56101e14fb64f787016e42edd74c39cf8b176a"
sha256: abec47eb8c8c36ebf41d0a4c64dbbe7f956e39a012b3aafc530e951bdc12fe3f
url: "https://pub.dev"
source: hosted
version: "13.2.0"
version: "14.1.4"
hive:
dependency: "direct main"
description:
@ -583,10 +608,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.2.1"
http_multi_server:
dependency: transitive
description:
@ -607,10 +632,18 @@ packages:
dependency: "direct main"
description:
name: hydrated_bloc
sha256: "00a2099680162e74b5a836b8a7f446e478520a9cae9f6032e028ad8129f4432d"
sha256: af35b357739fe41728df10bec03aad422cdc725a1e702e03af9d2a41ea05160c
url: "https://pub.dev"
source: hosted
version: "9.1.4"
version: "9.1.5"
image:
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
in_app_review:
dependency: "direct main"
description:
@ -659,26 +692,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.1"
linkify:
dependency: "direct main"
description:
@ -691,10 +724,10 @@ packages:
dependency: "direct main"
description:
name: logger
sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac"
sha256: af05cc8714f356fd1f3888fb6741cbe9fbe25cdb6eedbab80e1a6db21047d4a4
url: "https://pub.dev"
source: hosted
version: "2.0.2+1"
version: "2.3.0"
logging:
dependency: transitive
description:
@ -731,10 +764,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.12.0"
mime:
dependency: transitive
description:
@ -795,18 +828,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79"
sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0
url: "https://pub.dev"
source: hosted
version: "5.0.1"
version: "8.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6"
sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
path:
dependency: "direct main"
description:
@ -819,26 +852,26 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_android:
dependency: "direct main"
description:
name: path_provider_android
sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668"
sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.5"
path_provider_foundation:
dependency: "direct main"
description:
name: path_provider_foundation
sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f"
sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.4.0"
path_provider_linux:
dependency: transitive
description:
@ -915,10 +948,10 @@ packages:
dependency: transitive
description:
name: provider
sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096"
sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
url: "https://pub.dev"
source: hosted
version: "6.1.1"
version: "6.1.2"
pub_semver:
dependency: transitive
description:
@ -996,50 +1029,50 @@ packages:
dependency: "direct main"
description:
name: sembast
sha256: "9a9f0c7aca07043fef857b8b365f41592e48832b61462292699b57978e241c11"
sha256: dbe19600cff55d43f19405be79138c3fd2c08a87b0b152b18609b9427d113a64
url: "https://pub.dev"
source: hosted
version: "3.6.0"
version: "3.7.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900"
sha256: ef3489a969683c4f3d0239010cc8b7a2a46543a8d139e111c06c558875083544
url: "https://pub.dev"
source: hosted
version: "7.2.2"
version: "9.0.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956
sha256: "0f9e4418835d1b2c3ae78fdb918251959106cefdbc4dd43526e182f80e82f6d4"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "4.0.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02"
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.3"
shared_preferences_android:
dependency: "direct main"
description:
name: shared_preferences_android
sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06"
sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.2.3"
shared_preferences_foundation:
dependency: "direct main"
description:
name: shared_preferences_foundation
sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c"
sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7"
url: "https://pub.dev"
source: hosted
version: "2.3.5"
version: "2.4.0"
shared_preferences_linux:
dependency: transitive
description:
@ -1060,10 +1093,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_web
sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21"
sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.3.0"
shared_preferences_windows:
dependency: transitive
description:
@ -1153,18 +1186,18 @@ packages:
dependency: transitive
description:
name: sqflite
sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6
sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.3.3+1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5"
sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
version: "2.5.4"
stack_trace:
dependency: transitive
description:
@ -1232,34 +1265,34 @@ packages:
dependency: transitive
description:
name: test
sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f
sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073"
url: "https://pub.dev"
source: hosted
version: "1.24.9"
version: "1.25.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
version: "0.7.0"
test_core:
dependency: transitive
description:
name: test_core
sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a
sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4"
url: "https://pub.dev"
source: hosted
version: "0.5.9"
version: "0.6.0"
timezone:
dependency: transitive
description:
name: timezone
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
sha256: a6ccda4a69a442098b602c44e61a1e2b4bf6f5516e875bbf0f427d5df14745d5
url: "https://pub.dev"
source: hosted
version: "0.9.2"
version: "0.9.3"
typed_data:
dependency: transitive
description:
@ -1268,38 +1301,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
universal_platform:
dependency: "direct main"
description:
name: universal_platform
sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
version: "1.1.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c
sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e"
url: "https://pub.dev"
source: hosted
version: "6.2.4"
version: "6.2.6"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745
sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.3.3"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03"
sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89"
url: "https://pub.dev"
source: hosted
version: "6.2.4"
version: "6.3.0"
url_launcher_linux:
dependency: transitive
description:
@ -1312,10 +1353,10 @@ packages:
dependency: transitive
description:
name: url_launcher_macos
sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234
sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.2.0"
url_launcher_platform_interface:
dependency: transitive
description:
@ -1328,10 +1369,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b
sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.3.1"
url_launcher_windows:
dependency: transitive
description:
@ -1344,10 +1385,10 @@ packages:
dependency: transitive
description:
name: uuid
sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8"
url: "https://pub.dev"
source: hosted
version: "4.3.3"
version: "4.4.0"
vector_math:
dependency: transitive
description:
@ -1376,10 +1417,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "13.0.0"
version: "14.2.1"
wakelock:
dependency: "direct main"
description:
@ -1432,18 +1473,18 @@ packages:
dependency: transitive
description:
name: web
sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05"
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
url: "https://pub.dev"
source: hosted
version: "0.4.2"
version: "0.5.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23"
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.5"
webdriver:
dependency: transitive
description:
@ -1464,18 +1505,18 @@ packages:
dependency: "direct main"
description:
name: webview_flutter
sha256: "25e1b6e839e8cbfbd708abc6f85ed09d1727e24e08e08c6b8590d7c65c9a8932"
sha256: "6869c8786d179f929144b4a1f86e09ac0eddfe475984951ea6c634774c16b522"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
version: "4.8.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "3e5f4e9d818086b0d01a66fb1ff9cc72ab0cc58c71980e3d3661c5685ea0efb0"
sha256: f42447ca49523f11d8f70abea55ea211b3cafe172dd7a0e7ac007bb35dd356dc
url: "https://pub.dev"
source: hosted
version: "3.15.0"
version: "3.16.4"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -1488,26 +1529,26 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "9bf168bccdf179ce90450b5f37e36fe263f591c9338828d6bf09b6f8d0f57f86"
sha256: "7affdf9d680c015b11587181171d3cad8093e449db1f7d9f0f08f4f33d24f9a0"
url: "https://pub.dev"
source: hosted
version: "3.12.0"
version: "3.13.1"
win32:
dependency: "direct overridden"
description:
name: win32
sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8"
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
url: "https://pub.dev"
source: hosted
version: "5.2.0"
version: "5.5.1"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a"
sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.3"
workmanager:
dependency: "direct main"
description:
@ -1541,5 +1582,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.3.0-279.1.beta <4.0.0"
flutter: ">=3.19.0"
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.3"

View File

@ -1,62 +1,66 @@
name: hacki
description: A Hacker News reader.
version: 2.7.1+139
version: 2.8.2+147
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: "3.19.0"
flutter: "3.22.3"
dependencies:
adaptive_theme: ^3.2.0
animations: ^2.0.8
badges: ^3.0.2
bloc: ^8.1.1
bloc_concurrency: ^0.2.2
bloc_concurrency: ^0.2.5
cached_network_image: ^3.3.0
clipboard: ^0.1.3
collection: ^1.17.1
connectivity_plus: ^5.0.1
device_info_plus: ^9.1.0
dio: ^5.0.3
connectivity_plus: ^6.0.3
device_info_plus: ^10.1.0
dio: ^5.4.3+1
equatable: ^2.0.5
fast_gbk: ^1.0.0
feature_discovery:
git:
url: https://github.com/livinglist/feature_discovery
ref: flutter3_compatibility
ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b
flutter:
sdk: flutter
flutter_bloc: ^8.1.2
flutter_cache_manager: ^3.3.0
flutter_email_sender: ^6.0.1
flutter_bloc: ^8.1.5
flutter_cache_manager: ^3.3.2
flutter_email_sender: ^6.0.3
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_inappwebview: ^6.0.0
flutter_local_notifications: ^16.1.0
flutter_local_notifications: ^17.1.2
flutter_material_color_picker: ^1.2.0
flutter_secure_storage: ^9.0.0
flutter_siri_suggestions: ^2.1.0
flutter_native_splash: ^2.4.1
flutter_secure_storage: ^9.2.2
flutter_siri_suggestions:
git:
url: https://github.com/Livinglist/flutter_siri_suggestions
ref: master
flutter_slidable: ^3.0.0
font_awesome_flutter: ^10.3.0
get_it: ^7.2.0
go_router: ^13.2.0
get_it: ^7.7.0
go_router: ^14.1.4
hive: ^2.2.3
html: ^0.15.1
html_unescape: ^2.0.0
http: ^1.1.0
hydrated_bloc: ^9.1.0
hydrated_bloc: ^9.1.5
in_app_review:
path: components/in_app_review
intl: ^0.19.0
linkify: ^5.0.0
logger: ^2.0.1
logger: ^2.3.0
memoize: ^3.0.0
package_info_plus: ^5.0.1
package_info_plus: ^8.0.0
path: ^1.8.2
path_provider: ^2.0.12
path_provider_android: ^2.0.22
path_provider_foundation: ^2.1.1
path_provider: ^2.1.3
path_provider_android: ^2.2.5
path_provider_foundation: ^2.4.0
pretty_dio_logger: ^1.3.1
pull_to_refresh:
git:
@ -68,19 +72,19 @@ dependencies:
responsive_builder: ^0.7.0
rxdart: ^0.27.7
scrollable_positioned_list: ^0.3.5
sembast: ^3.5.0+1
share_plus: ^7.2.1
shared_preferences: ^2.2.2
shared_preferences_android: ^2.2.1
shared_preferences_foundation: ^2.3.4
sembast: ^3.7.1
share_plus: ^9.0.0
shared_preferences: ^2.2.3
shared_preferences_android: ^2.2.3
shared_preferences_foundation: ^2.4.0
shimmer: ^3.0.0
synced_shared_preferences:
path: components/synced_shared_preferences
universal_platform: ^1.0.0+1
url_launcher: ^6.2.1
universal_platform: ^1.1.0
url_launcher: ^6.2.6
visibility_detector: ^0.4.0+2
wakelock: ^0.6.2
webview_flutter: ^4.4.1
webview_flutter: ^4.8.0
workmanager: ^0.5.1
dependency_overrides:
@ -130,5 +134,29 @@ flutter:
- asset: assets/fonts/exo_2/Exo2-Regular.ttf
- asset: assets/fonts/exo_2/Exo2-Bold.ttf
weight: 700
- family: AtkinsonHyperlegible
fonts:
- asset: assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Regular.ttf
- asset: assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
weight: 700
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
# dart run flutter_native_splash:create
# To restore Flutter's default white splash screen, run the following command in the terminal:
# dart run flutter_native_splash:remove
# IMPORTANT NOTE: These parameter do not affect the configuration of Android 12 and later, which
# handle splash screens differently that prior versions of Android. Android 12 and later must be
# configured specifically in the android_12 section below.
# color or background_image is the only required parameter. Use color to set the background
# of your splash screen to a solid color. Use background_image to set the background of your
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
color: "#ffffff"
color_dark: "#000000"

View File

@ -0,0 +1,10 @@
include: package:very_good_analysis/analysis_options.5.0.0.yaml
linter:
rules:
parameter_assignments: false
public_member_api_docs: false
library_private_types_in_public_api: false
omit_local_variable_types: false
one_member_abstracts: false
always_specify_types: true
avoid_print: false

View File

@ -0,0 +1,133 @@
import 'dart:async';
import 'package:args/args.dart';
import 'package:dio/dio.dart';
import 'package:html/dom.dart' hide Comment;
import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
Future<void> main(List<String> arguments) async {
/// Get the GitHub token from args for so that we can create issues if
/// anything doesn't go as expected.
final ArgParser parser = ArgParser()
..addFlag('github-token', negatable: false, abbr: 't');
final ArgResults argResults = parser.parse(arguments);
final String token = argResults.rest.first;
/// The expected parser result.
const String text = '''
What does it say about the world we live in where blogs do more basic journalism than CNN? All that one would have had to do is read the report actually provided.
I don't think I'm being too extreme when I say that, apart from maybe PBS, there is no reputable source of news in America. If you don't believe me, pick a random story, watch it as it gets rewritten a million times through Reuters, then check back on the facts of the story one year later. A news story gets twisted to promote some narrative that will sell papers, and when the facts of the story are finally verified (usually not by the news themselves, but lawyers or courts or whoever), the story is dropped and never reported on again.
Again, if the only thing a reporter had to do was read the report to find the facts of the case to verify what is and isn't true, what the fuck is even the point of a news agency?''';
/// Get HTML of the thread.
const String itemBaseUrl = 'https://news.ycombinator.com/item?id=';
const Map<String, String> headers = <String, String>{
'accept': '*/*',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
};
const int itemId = 11536543;
final Dio dio = Dio();
final Uri url = Uri.parse('$itemBaseUrl$itemId');
final Options option = Options(headers: headers, persistentConnection: true);
final Response<String> response =
await dio.getUri<String>(url, options: option);
/// Parse the HTML and select all the comment elements.
final String data = response.data ?? '';
final Document document = parse(data);
const String athingComtrSelector =
'#hnmain > tbody > tr > td > table > tbody > .athing.comtr';
final List<Element> elements = document.querySelectorAll(athingComtrSelector);
/// Verify comment text parser using the first comment element.
if (elements.isNotEmpty) {
final Element e = elements.first;
const String commentTextSelector =
'''td > table > tbody > tr > td.default > div.comment > div.commtext''';
final Element? cmtTextElement = e.querySelector(commentTextSelector);
final String parsedText =
await parseCommentTextHtml(cmtTextElement?.innerHtml ?? '');
if (parsedText != text) {
final Uri url =
Uri.parse('https://api.github.com/repos/livinglist/hacki/issues');
const String issueTitle = 'Parser check failed.';
/// Check if an issue with same title already exists.
final Response<String> response = await dio.getUri<String>(url);
if (response.data?.contains(issueTitle) ?? false) {
print('Issue already exists.');
return;
} else {
print('Diff detected, creating issue...');
/// Create the issue if one does not exist.
final Map<String, String> githubHeaders = <String, String>{
'Authorization': 'Bearer $token',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
};
final Map<String, dynamic> githubIssuePayload = <String, dynamic>{
'assignees': <String>['livinglist'],
'title': issueTitle,
'body': '''
| Expected | Actual |
| ------------- | ------------- |
| ${text.replaceAll('\n', '<br>')} | ${parsedText.replaceAll('\n', '<br>')} |''',
};
await dio.postUri<String>(
url,
data: githubIssuePayload,
options: Options(
headers: githubHeaders,
),
);
print('Issue created.');
}
} else {
print('Expected:\n$text\n');
print('Actual:\n$parsedText\n');
}
} else {
throw Exception('No comment from Hacker News.');
}
}
Future<String> parseCommentTextHtml(String text) async {
return HtmlUnescape()
.convert(text)
.replaceAllMapped(
RegExp(
r'\<div class="reply"\>(.*?)\<\/div\>',
dotAll: true,
),
(Match match) => '',
)
.replaceAllMapped(
RegExp(
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
dotAll: true,
),
(Match match) => '${match[2]}',
)
.replaceAllMapped(
RegExp(
r'\<p\>(.*?)\<\/p\>',
dotAll: true,
),
(Match match) => '\n\n${match[1]}',
)
.replaceAllMapped(
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(Match match) => match[1] ?? '',
)
.replaceAllMapped(
RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*',
)
.trim();
}

125
scripts/pubspec.lock Normal file
View File

@ -0,0 +1,125 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: "direct main"
description:
name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
collection:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.18.0"
csslib:
dependency: transitive
description:
name: csslib
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
dio:
dependency: "direct main"
description:
name: dio
sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5"
url: "https://pub.dev"
source: hosted
version: "5.4.3+1"
html:
dependency: "direct main"
description:
name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
url: "https://pub.dev"
source: hosted
version: "0.15.4"
html_unescape:
dependency: "direct main"
description:
name: html_unescape
sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.15.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8"
url: "https://pub.dev"
source: hosted
version: "5.1.0"
sdks:
dart: ">=3.0.0 <4.0.0"

13
scripts/pubspec.yaml Normal file
View File

@ -0,0 +1,13 @@
name: parser_verifier
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
args: ^2.5.0
dio: ^5.0.3
html: ^0.15.1
html_unescape: ^2.0.0
dev_dependencies:
very_good_analysis: ^5.0.0