Compare commits

..

64 Commits

Author SHA1 Message Date
3e3941380d feat: show msg if no favorites. (#481) 2024-09-22 23:07:55 -07:00
bbed4e0e75 fix: favorites screen. (#480) 2024-09-22 21:22:16 -07:00
a4ae6a20e1 feat: separate tabs for comments and stories in favorites screen. (#479) 2024-09-21 21:55:16 -07:00
3413b1686d fix: double tap instead of long press to open url. (#478) 2024-09-20 00:45:35 -07:00
c24670d5d8 fix: remove excessive network image error logs. (#477) 2024-09-19 23:11:44 -07:00
a50c456390 fix: empty page when data source is web. (#476) 2024-09-19 15:59:16 -07:00
915eb47ab6 fix: tablet mode center border not disappearing in full screen. (#474) 2024-09-16 01:07:02 -07:00
c442a5d2e7 chore: bump flutter version to 3.24.3. (#473) 2024-09-16 00:50:32 -07:00
fbedf327ee feat: resizable submission and story section on tablet. (#472) 2024-09-04 11:33:36 -07:00
45c684b774 fix: set target to iOS 14 (#465) 2024-09-03 18:23:06 -07:00
b6015ae6ca fix: incorrect LSMinimumSystemVersion (#464) 2024-08-28 18:23:22 -07:00
b240dccc8e fix: update podfile.lock. (#462) 2024-08-20 16:38:48 -07:00
949562a34a fix: remove siri suggestion plugin. (#461) 2024-08-20 16:34:51 -07:00
9d8af331c7 feat: enter offline mode automatically if no network connection detected on startup. (#460) 2024-08-20 12:28:55 -07:00
031ff7519d fix: dropdown menu theming. (#459) 2024-08-20 00:54:54 -07:00
62bab9d781 fix: offline toggle and data source dropdown. (#458) 2024-08-19 23:18:10 -07:00
b9ff92a27b fix: pagination when fetching from API. (#457) 2024-08-19 17:30:08 -07:00
0332cd531d feat: data source selection. (#456) 2024-08-16 03:47:02 -07:00
b76c5dd64c fix: manual pagination. (#455) 2024-08-16 01:29:30 -07:00
7325a08002 refactor: clean up logging. (#454) 2024-08-16 01:13:13 -07:00
78bb1c6a6c fix: empty data from share extension. (#453) 2024-08-14 21:12:24 -07:00
c34ffe22da fix: logging deeplink handling. (#452) 2024-08-14 17:14:09 -07:00
a621dc0291 fix: share extension. (#451) 2024-08-14 14:44:27 -07:00
88a12d3339 feat: add log screen. (#450) 2024-08-14 13:43:59 -07:00
50d4cdfad9 fix: home page pagination and duplicate stories. (#449) 2024-08-14 13:04:55 -07:00
366a461c96 feat: fetch stories from web directly instead of firebase api. (#448) 2024-08-13 16:20:51 -07:00
3f1e9d0fff fix: overflow bar alignment. (#447) 2024-08-13 10:11:02 -07:00
d09c10b3f8 chore: bump version to 2.8.3 (#446) 2024-08-13 08:10:03 -07:00
fd5730e189 fix: deprecated uikit code (#444)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-13 07:36:07 -07:00
c9cc6a5df0 fix: flutter deprecation (#445) 2024-08-13 07:31:52 -07:00
8d4b232097 fix: migrate uiapplicationmain to main (#443)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-12 15:00:24 -07:00
8af643e584 fix: swap wakelock to maintained one (#440)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-12 13:31:34 -07:00
70a56f4ade update commit_check.yml (#442) 2024-08-12 13:25:05 -07:00
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
137 changed files with 3701 additions and 1679 deletions

View File

@ -3,11 +3,13 @@ name: Commit Guard
on:
push:
branches:
- "**"
- '!master'
pull_request:
# Run on any TARGET branches.
branches: [ '**' ]
jobs:
releases:
commit_check:
name: Check commit
runs-on: macos-latest
timeout-minutes: 30
@ -21,4 +23,4 @@ jobs:
- run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
- run: submodules/flutter/bin/flutter analyze lib test integration_test
- run: submodules/flutter/bin/flutter test
- run: submodules/flutter/bin/flutter test

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,15 @@
{
"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",
"storySelector": "#hnmain > tbody > tr > td > table > tbody > .athing",
"subtextSelector": "#hnmain > tbody > tr > td > table > tbody > tr > .subtext",
"titlelineSelector": ".title > .titleline > a",
"pointSelector": ".subline > .score",
"userSelector": ".subline > .hnuser",
"ageSelector": ".subline > .age",
"cmtCountSelector": ".subline > a",
"moreLinkSelector": "#hnmain > tbody > tr:nth-child(3) > td > table > tbody > tr > td.title > a"
}

15
assets/remote-config.json Normal file
View File

@ -0,0 +1,15 @@
{
"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",
"storySelector": "#hnmain > tbody > tr > td > table > tbody > .athing",
"subtextSelector": "#hnmain > tbody > tr > td > table > tbody > tr > .subtext",
"titlelineSelector": ".title > .titleline > a",
"pointSelector": ".subline > .score",
"userSelector": ".subline > .hnuser",
"ageSelector": ".subline > .age",
"cmtCountSelector": ".subline > a",
"moreLinkSelector": "#hnmain > tbody > tr:nth-child(3) > td > table > tbody > tr > td.title > a"
}

View File

@ -5,103 +5,116 @@ packages:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.8.2"
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
url: "https://pub.dartlang.org"
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
version: "1.3.0"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.18.0"
fake_async:
dependency: transitive
description:
name: fake_async
url: "https://pub.dartlang.org"
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
lints:
leak_tracker:
dependency: transitive
description:
name: lints
url: "https://pub.dartlang.org"
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "10.0.5"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.11"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.1.3"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.7.0"
version: "1.15.0"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
version: "1.9.0"
sky_engine:
dependency: transitive
description: flutter
@ -111,58 +124,66 @@ packages:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.8.1"
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
url: "https://pub.dartlang.org"
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.2.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev"
source: hosted
version: "0.4.8"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "0.7.2"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
sdks:
dart: ">=2.16.2 <3.0.0"
flutter: ">=2.5.0"
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

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

View File

@ -0,0 +1 @@
- UX improvements.

View File

@ -0,0 +1 @@
- Improved tablet mode, you can now resize submission panel.

View File

@ -60,7 +60,7 @@ void main() {
expect(firstStoryFinder, findsOneWidget);
await tester.tap(firstStoryFinder);
await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(seconds: 5));
},
reportKey: 'scrolling_timeline',
);

View File

@ -13,7 +13,7 @@ class ActionViewController: UIViewController {
let hostAppBundleIdentifier = "com.jiaqi.hacki"
let sharedKey = "ShareKey"
var sharedText: [String] = []
let urlContentType = kUTTypeURL as String
let urlContentType = UTType.url
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
@ -32,7 +32,7 @@ class ActionViewController: UIViewController {
}
private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: urlContentType.identifier, options: nil) { [weak self] data, error in
if error == nil, let item = data as? URL, let this = self {
this.sharedText.append(item.absoluteString)
@ -66,7 +66,7 @@ class ActionViewController: UIViewController {
}
private func redirectToHostApp() {
let url = URL(string: "ShareMedia://dataUrl=\(sharedKey)#text")
let url = URL(string: "ShareMedia-\(hostAppBundleIdentifier)://dataUrl=\(sharedKey)#text")
var responder = self as UIResponder?
let selectorOpenURL = sel_registerName("openURL:")

View File

@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"

View File

@ -1,2 +1,4 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"

View File

@ -1,3 +1,6 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

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,9 +16,9 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- flutter_native_splash (0.0.1):
- Flutter
- flutter_siri_suggestions (0.0.1):
- flutter_secure_storage (6.0.0):
- Flutter
- in_app_review (0.2.0):
- Flutter
@ -34,7 +34,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):
@ -49,7 +48,7 @@ PODS:
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- wakelock (0.0.1):
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
@ -57,14 +56,14 @@ 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`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@ -76,7 +75,7 @@ DEPENDENCIES:
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
- workmanager (from `.symlinks/plugins/workmanager/ios`)
@ -84,11 +83,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,10 +97,10 @@ 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:
:path: ".symlinks/plugins/flutter_siri_suggestions/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test:
@ -125,40 +123,39 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/synced_shared_preferences/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
workmanager:
: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_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 13825b8a9334a850581300559b8839134b124670
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
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
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: 0957b955069bb512c22bae4cadad9f4c34161dbe
PODFILE CHECKSUM: f03c7c11cf2b623592c89c68c628682778bb78b4
COCOAPODS: 1.13.0
COCOAPODS: 1.15.2

View File

@ -231,11 +231,11 @@
buildPhases = (
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
);
@ -387,7 +387,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@ -537,6 +537,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -548,7 +549,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -567,7 +569,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -578,7 +580,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = "";
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -619,6 +621,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@ -636,7 +639,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -674,6 +678,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -685,7 +690,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -706,7 +712,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -717,7 +723,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = "";
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -740,7 +746,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO;
@ -752,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = "";
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -776,20 +782,20 @@
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
@ -817,7 +823,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -825,13 +831,13 @@
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -856,20 +862,20 @@
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -895,20 +901,20 @@
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
@ -938,7 +944,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -946,13 +952,13 @@
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -979,20 +985,20 @@
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = "";
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = "";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@ -6,7 +6,7 @@ import flutter_secure_storage
import path_provider_foundation
import flutter_local_notifications
@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
@ -22,7 +22,7 @@ import flutter_local_notifications
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*15))

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,88 @@
<?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-$(PRODUCT_BUNDLE_IDENTIFIER)</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>LSMinimumSystemVersion</key>
<string>14.0</string>
<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>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/>
<key>AppGroupId</key>
<string>group.com.jiaqi.hacki</string>
</dict>
</plist>

View File

@ -8,11 +8,11 @@ class ShareViewController: SLComposeServiceViewController {
let sharedKey = "ShareKey"
var sharedMedia: [SharedMediaFile] = []
var sharedText: [String] = []
let imageContentType = kUTTypeImage as String
let videoContentType = kUTTypeMovie as String
let textContentType = kUTTypeText as String
let urlContentType = kUTTypeURL as String
let fileURLType = kUTTypeFileURL as String;
let imageContentType = UTType.image
let videoContentType = UTType.movie
let textContentType = UTType.text
let urlContentType = UTType.url
let fileURLType = UTType.fileURL
override func isContentValid() -> Bool {
return true
@ -29,15 +29,15 @@ class ShareViewController: SLComposeServiceViewController {
if let content = extensionContext!.inputItems[0] as? NSExtensionItem {
if let contents = content.attachments {
for (index, attachment) in (contents).enumerated() {
if attachment.hasItemConformingToTypeIdentifier(imageContentType) {
if attachment.hasItemConformingToTypeIdentifier(imageContentType.identifier) {
handleImages(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(textContentType) {
} else if attachment.hasItemConformingToTypeIdentifier(textContentType.identifier) {
handleText(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(fileURLType) {
} else if attachment.hasItemConformingToTypeIdentifier(fileURLType.identifier) {
handleFiles(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(urlContentType) {
} else if attachment.hasItemConformingToTypeIdentifier(urlContentType.identifier) {
handleUrl(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType) {
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType.identifier) {
handleVideos(content: content, attachment: attachment, index: index)
}
}
@ -55,8 +55,8 @@ class ShareViewController: SLComposeServiceViewController {
}
private func handleText (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: textContentType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: textContentType.identifier, options: nil) { [weak self] data, error in
if error == nil, let item = data as? String, let this = self {
this.sharedText.append(item)
@ -76,8 +76,8 @@ class ShareViewController: SLComposeServiceViewController {
}
private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: urlContentType.identifier, options: nil) { [weak self] data, error in
if error == nil, let item = data as? URL, let this = self {
this.sharedText.append(item.absoluteString)
@ -87,6 +87,7 @@ class ShareViewController: SLComposeServiceViewController {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.sharedText, forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .text)
}
@ -97,8 +98,8 @@ class ShareViewController: SLComposeServiceViewController {
}
private func handleImages (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: imageContentType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: imageContentType.identifier, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
@ -126,8 +127,8 @@ class ShareViewController: SLComposeServiceViewController {
}
private func handleVideos (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: videoContentType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: videoContentType.identifier, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
@ -158,8 +159,8 @@ class ShareViewController: SLComposeServiceViewController {
}
private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: fileURLType, options: nil) { [weak self] data, error in
attachment.loadItem(forTypeIdentifier: fileURLType.identifier, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
@ -199,10 +200,10 @@ class ShareViewController: SLComposeServiceViewController {
}
private func redirectToHostApp(type: RedirectType) {
let url = URL(string: "ShareMedia://dataUrl=\(sharedKey)#\(type)")
let url = URL(string: "ShareMedia-\(hostAppBundleIdentifier)://dataUrl=\(sharedKey)#\(type)")
var responder = self as UIResponder?
let selectorOpenURL = sel_registerName("openURL:")
while (responder != nil) {
if (responder?.responds(to: selectorOpenURL))! {
let _ = responder?.perform(selectorOpenURL, with: url)
@ -311,7 +312,7 @@ class ShareViewController: SLComposeServiceViewController {
// Debug method to print out SharedMediaFile details in the console
func toString() {
print("[SharedMediaFile] \n\tpath: \(self.path)\n\tthumbnail: \(self.thumbnail)\n\tduration: \(self.duration)\n\ttype: \(self.type)")
print("[SharedMediaFile] \n\tpath: \(self.path)\n\tthumbnail: \(String(describing: self.thumbnail))\n\tduration: \(String(describing: self.duration))\n\ttype: \(self.type)")
}
}

View File

@ -3,35 +3,38 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.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:logger/logger.dart';
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> {
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
StoriesBloc({
required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
@ -46,56 +49,59 @@ 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<StoriesPageSizeChanged>(onPageSizeChanged);
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
on<StoriesExitOfflineMode>(onExitOfflineMode);
on<ClearAllReadStories>(onClearAllReadStories);
_preferenceSubscription = _preferenceCubit.stream
.distinct((PreferenceState lhs, PreferenceState rhs) {
return lhs.dataSource == rhs.dataSource;
}).listen((PreferenceState prefState) {
add(StoriesInitialize());
});
}
final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final PreferenceRepository _preferenceRepository;
final Logger _logger;
DeviceScreenType? deviceScreenType;
StreamSubscription<PreferenceState>? _streamSubscription;
static const int _smallPageSize = 10;
static const int _largePageSize = 20;
static const int _tabletSmallPageSize = 15;
static const int _tabletLargePageSize = 25;
StreamSubscription<PreferenceState>? _preferenceSubscription;
static const int _pageSize = 30;
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);
final HackerNewsDataSource dataSource = _preferenceCubit.state.dataSource;
logInfo('data source: $dataSource');
if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize));
}
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
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,
dataSource: dataSource,
),
);
for (final StoryType type in StoryType.values) {
if (event.startup) {
final List<ConnectivityResult> status =
await Connectivity().checkConnectivity();
logInfo('network status: $status');
if (status.contains(ConnectivityResult.none)) {
logInfo('no network connection, entering offline mode.');
add(StoriesEnterOfflineMode());
}
}
for (final StoryType type in _preferenceCubit.state.tabs) {
add(LoadStories(type: type));
}
}
@ -104,38 +110,70 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
LoadStories event,
Emitter<StoriesState> emit,
) async {
if (state.dataSource == null) {
logError('data source should not be null.');
}
final StoryType type = event.type;
if (state.isOfflineReading) {
logInfo('($type) loading stories from local cache.');
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
emit(
state
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
.copyWithCurrentPageUpdated(type: type, to: 1)
.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));
});
} else {
ids: ids.sublist(0, min(_pageSize, ids.length)),
)
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
.onDone(() => add(StoryLoadingCompleted(type: type)));
} else if (event.useApi || state.dataSource == HackerNewsDataSource.api) {
logInfo('($type) loading stories from API.');
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: 1)
.copyWithStatusUpdated(type: type, to: Status.inProgress),
);
await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.fetchStoriesStream(
ids: ids.sublist(0, min(_pageSize, ids.length)),
sequential: true,
)
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).asFuture<void>();
add(StoriesLoaded(type: type));
add(StoryLoadingCompleted(type: type));
} else {
logInfo('($type) loading stories from web.');
emit(
state
.copyWithCurrentPageUpdated(type: type, to: 1)
.copyWithStatusUpdated(type: type, to: Status.inProgress),
);
await _hackerNewsWebRepository
.fetchStoriesStream(event.type, page: 1)
.handleError((dynamic e) {
logError('($type) error loading stories from web $e');
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
add(event.copyWith(useApi: true));
}
}).listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).asFuture<void>();
add(StoryLoadingCompleted(type: type));
}
}
@ -161,76 +199,103 @@ 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) {
Future<void> onLoadMore(
StoriesLoadMore event,
Emitter<StoriesState> emit,
) async {
final StoryType type = event.type;
if (state.statusByType[type] == Status.inProgress) return;
emit(
state.copyWithStatusUpdated(
type: event.type,
type: type,
to: Status.inProgress,
),
);
final int currentPage = state.currentPageByType[event.type]!;
final int len = state.storyIdsByType[event.type]!.length;
final int currentPage = state.currentPageByType[type]! + 1;
emit(
state.copyWithCurrentPageUpdated(type: event.type, to: currentPage + 1),
state.copyWithCurrentPageUpdated(type: type, to: currentPage),
);
final int currentPageSize = state.currentPageSize;
final int lower = currentPageSize * (currentPage + 1);
int upper = currentPageSize + lower;
if (len > lower) {
if (len < upper) {
upper = len;
}
if (state.isOfflineReading) {
_offlineRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
if (state.isOfflineReading) {
final List<int>? ids = state.storyIdsByType[type];
if (ids == null) {
logError('ids should not be null.');
emit(
state.copyWithStatusUpdated(
type: type,
to: Status.failure,
),
)
.listen((Story story) {
add(
StoryLoaded(
story: story,
type: event.type,
),
);
}).onDone(() {
add(StoriesLoaded(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));
});
);
return;
}
} else {
emit(
state.copyWithStatusUpdated(
type: event.type,
to: Status.success,
),
final int length = ids.length;
final int lower = min(length, _pageSize * (currentPage - 1));
final int upper = min(length, lower + _pageSize);
final List<int> idsForCurrentPage = ids.sublist(
lower,
upper,
);
_offlineRepository
.getCachedStoriesStream(ids: idsForCurrentPage)
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
.onDone(() => add(StoryLoadingCompleted(type: type)));
} else if (event.useApi || state.dataSource == HackerNewsDataSource.api) {
late final int length;
List<int>? ids = state.storyIdsByType[type];
if (ids == null || ids.isEmpty) {
ids = await _hackerNewsRepository.fetchStoryIds(type: type);
length = ids.length;
emit(state.copyWithStoryIdsUpdated(type: type, to: ids));
} else {
length = ids.length;
}
final int lower = min(length, _pageSize * (currentPage - 1));
final int upper = min(length, lower + _pageSize);
final List<int> idsForCurrentPage = ids.sublist(
lower,
upper,
);
_hackerNewsRepository
.fetchStoriesStream(ids: idsForCurrentPage)
.listen(
(Story story) => add(StoryLoaded(story: story, type: type)),
)
.onDone(() => add(StoryLoadingCompleted(type: type)));
} else {
_hackerNewsWebRepository
.fetchStoriesStream(type, page: currentPage)
.handleError((dynamic e) {
logError('error loading more stories $e');
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
/// Fall back to use API instead.
add(event.copyWith(useApi: true));
emit(
state.copyWithCurrentPageUpdated(
type: type,
to: currentPage - 1,
),
);
}
})
.listen(
(Story story) => add(StoryLoaded(story: story, type: type)),
)
.onDone(() => add(StoryLoadingCompleted(type: type)));
}
}
@ -238,7 +303,21 @@ 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]
?.where((Story s) => s.id == story.id)
.isNotEmpty ??
false) {
logDebug('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 +325,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 +335,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 +348,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 +419,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
<StreamSubscription<Comment>>[];
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading');
logDebug('aborting downloading');
for (final StreamSubscription<Comment> stream in downloadStreams) {
await stream.cancel();
}
_logger.d('deleting downloaded contents');
logDebug('deleting downloaded contents');
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
break;
}
_logger.d('fetching story $id');
logDebug('fetching story $id');
final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) {
@ -377,7 +452,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.cacheStory(story: story);
if (story.url.isNotEmpty && includingWebPage) {
_logger.i('downloading ${story.url}');
logInfo('downloading ${story.url}');
await _offlineRepository.cacheUrl(url: story.url);
}
@ -394,18 +469,18 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.listen(
(Comment comment) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading from comments stream');
logDebug('aborting downloading from comments stream');
downloadStream?.cancel();
return;
}
_logger.d('fetched comment ${comment.id}');
logDebug('fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
)..onDone(() {
_logger.d(
logDebug(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
@ -448,22 +523,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
}
Future<void> onPageSizeChanged(
StoriesPageSizeChanged event,
Future<void> onExitOfflineMode(
StoriesExitOfflineMode event,
Emitter<StoriesState> emit,
) async {
emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize());
}
Future<void> onExitOffline(
StoriesExitOffline event,
Future<void> onEnterOfflineMode(
StoriesEnterOfflineMode event,
Emitter<StoriesState> emit,
) async {
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(isOfflineReading: false));
emit(state.copyWith(isOfflineReading: true));
add(StoriesInitialize());
}
@ -508,19 +580,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
bool hasRead(Story story) => state.readStoriesIds.contains(story.id);
int getPageSize({required bool isComplexTile}) {
int pageSize = isComplexTile ? _smallPageSize : _largePageSize;
if (deviceScreenType != DeviceScreenType.mobile) {
pageSize = isComplexTile ? _tabletSmallPageSize : _tabletLargePageSize;
}
return pageSize;
@override
Future<void> close() async {
await _preferenceSubscription?.cancel();
await super.close();
}
@override
Future<void> close() async {
await _streamSubscription?.cancel();
await super.close();
}
String get logIdentifier => '[StoriesBloc]';
}

View File

@ -6,28 +6,39 @@ abstract class StoriesEvent extends Equatable {
}
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
LoadStories({
required this.type,
this.isRefreshing = false,
this.useApi = false,
});
final StoryType type;
final bool isRefreshing;
final bool useApi;
LoadStories copyWith({required bool useApi}) {
return LoadStories(type: type, isRefreshing: isRefreshing, useApi: useApi);
}
@override
List<Object?> get props => <Object?>[type];
List<Object?> get props => <Object?>[
type,
isRefreshing,
useApi,
];
}
class StoriesInitialize extends StoriesEvent {
StoriesInitialize({
this.startup = false,
});
final bool startup;
@override
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});
@ -38,12 +49,24 @@ class StoriesRefresh extends StoriesEvent {
}
class StoriesLoadMore extends StoriesEvent {
StoriesLoadMore({required this.type});
StoriesLoadMore({
required this.type,
this.useApi = false,
});
final StoryType type;
final bool useApi;
StoriesLoadMore copyWith({required bool useApi}) {
return StoriesLoadMore(type: type, useApi: useApi);
}
@override
List<Object?> get props => <Object?>[type];
List<Object?> get props => <Object?>[
type,
useApi,
];
}
class StoriesDownload extends StoriesEvent {
@ -71,18 +94,14 @@ 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 StoriesPageSizeChanged extends StoriesEvent {
StoriesPageSizeChanged({required this.pageSize});
final int pageSize;
class StoriesEnterOfflineMode extends StoriesEvent {
@override
List<Object?> get props => <Object?>[pageSize];
List<Object?> get props => <Object?>[];
}
class StoryLoaded extends StoriesEvent {
@ -95,6 +114,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

@ -17,9 +17,9 @@ class StoriesState extends Equatable {
required this.readStoriesIds,
required this.isOfflineReading,
required this.downloadStatus,
required this.currentPageSize,
required this.storiesDownloaded,
required this.storiesToBeDownloaded,
required this.dataSource,
});
const StoriesState.init({
@ -53,10 +53,10 @@ class StoriesState extends Equatable {
},
}) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.idle,
currentPageSize = 0,
readStoriesIds = const <int>{},
storiesDownloaded = 0,
storiesToBeDownloaded = 0;
storiesToBeDownloaded = 0,
dataSource = null;
final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType;
@ -65,9 +65,9 @@ class StoriesState extends Equatable {
final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus;
final bool isOfflineReading;
final int currentPageSize;
final int storiesDownloaded;
final int storiesToBeDownloaded;
final HackerNewsDataSource? dataSource;
StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType,
@ -77,9 +77,9 @@ class StoriesState extends Equatable {
Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus,
bool? isOfflineReading,
int? currentPageSize,
int? storiesDownloaded,
int? storiesToBeDownloaded,
HackerNewsDataSource? dataSource,
}) {
return StoriesState(
storiesByType: storiesByType ?? this.storiesByType,
@ -89,10 +89,10 @@ class StoriesState extends Equatable {
readStoriesIds: readStoriesIds ?? this.readStoriesIds,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
storiesToBeDownloaded:
storiesToBeDownloaded ?? this.storiesToBeDownloaded,
dataSource: dataSource ?? this.dataSource,
);
}
@ -179,8 +179,8 @@ class StoriesState extends Equatable {
readStoriesIds,
isOfflineReading,
downloadStatus,
currentPageSize,
storiesDownloaded,
storiesToBeDownloaded,
dataSource,
];
}

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 {
@ -82,6 +88,7 @@ abstract class AppDurations {
static const Duration ms600 = Duration(milliseconds: 600);
static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2);
static const Duration fiveSeconds = Duration(seconds: 5);
static const Duration tenSeconds = Duration(seconds: 10);
static const Duration sec30 = Duration(seconds: 30);
static const Duration oneMinute = Duration(minutes: 1);

View File

@ -5,13 +5,13 @@ class CustomLogFilter extends LogFilter {
Level? get level => Level.trace;
/// The minimal level allowed in production.
static const Level _minimalLevel = Level.info;
static const Level minimalLevel = Level.info;
@override
bool shouldLog(LogEvent event) {
bool shouldLog = false;
if (event.level.index >= _minimalLevel.index) {
if (event.level.index >= minimalLevel.index) {
return true;
}

View File

@ -25,52 +25,46 @@ final GoRouter router = GoRouter(
return ItemScreen.phone(args);
},
),
GoRoute(
path: LogScreen.routeName,
builder: (_, __) => const LogScreen(),
),
GoRoute(
path: WebViewScreen.routeName,
builder: (_, GoRouterState state) {
final String? link = state.extra as String?;
if (link == null) {
throw GoError("link can't be null");
}
return WebViewScreen(
url: link,
);
},
),
GoRoute(
path: SubmitScreen.routeName,
builder: (_, __) => BlocProvider<SubmitCubit>(
create: (_) => SubmitCubit(),
child: const SubmitScreen(),
),
),
GoRoute(
path: QrCodeScannerScreen.routeName,
builder: (_, __) => const QrCodeScannerScreen(),
),
GoRoute(
path: QrCodeViewScreen.routeName,
builder: (_, GoRouterState state) {
final String? data = state.extra as String?;
if (data == null) {
throw GoError("data can't be null");
}
return QrCodeViewScreen(
data: data,
);
},
),
],
),
GoRoute(
path: '/${ItemScreen.routeName}',
builder: (_, GoRouterState state) {
final ItemScreenArgs? args = state.extra as ItemScreenArgs?;
if (args == null) {
throw GoError("args can't be null");
}
return ItemScreen.phone(args);
},
),
GoRoute(
path: '/${SubmitScreen.routeName}',
builder: (_, __) => BlocProvider<SubmitCubit>(
create: (_) => SubmitCubit(),
child: const SubmitScreen(),
),
),
GoRoute(
path: '/${QrCodeScannerScreen.routeName}',
builder: (_, __) => const QrCodeScannerScreen(),
),
GoRoute(
path: '/${QrCodeViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? data = state.extra as String?;
if (data == null) {
throw GoError("data can't be null");
}
return QrCodeViewScreen(
data: data,
);
},
),
GoRoute(
path: '/${WebViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? link = state.extra as String?;
if (link == null) {
throw GoError("link can't be null");
}
return WebViewScreen(
url: link,
);
},
),
],
);

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())

47
lib/config/paths.dart Normal file
View File

@ -0,0 +1,47 @@
import 'package:hacki/screens/screens.dart';
abstract class Paths {
static const LogPaths log = LogPaths._();
static const HomePaths home = HomePaths._();
static const ItemPaths item = ItemPaths._();
static const QrCodePaths qrCode = QrCodePaths._();
static const WebViewPaths webView = WebViewPaths._();
}
class HomePaths with RootPaths {
const HomePaths._();
String get landing => rootPath('');
}
class ItemPaths with RootPaths {
const ItemPaths._();
String get landing => rootPath(ItemScreen.routeName);
String get submit => rootPath(SubmitScreen.routeName);
}
class LogPaths with RootPaths {
const LogPaths._();
String get landing => rootPath(LogScreen.routeName);
}
class QrCodePaths with RootPaths {
const QrCodePaths._();
String get scanner => rootPath(QrCodeScannerScreen.routeName);
String get viewer => rootPath(QrCodeViewScreen.routeName);
}
class WebViewPaths with RootPaths {
const WebViewPaths._();
String get landing => rootPath(WebViewScreen.routeName);
}
mixin RootPaths {
String rootPath(String path) => '/$path';
}

View File

@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
@ -17,13 +18,12 @@ import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
class CommentsCubit extends Cubit<CommentsState> with Loggable {
CommentsCubit({
required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
@ -37,7 +37,6 @@ class CommentsCubit extends Cubit<CommentsState> {
SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache,
@ -50,7 +49,6 @@ class CommentsCubit extends Cubit<CommentsState> {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
isOfflineReading: isOfflineReading,
@ -68,7 +66,6 @@ class CommentsCubit extends Cubit<CommentsState> {
final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
@ -83,7 +80,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
static const int _webFetchingCmtCountLowerLimit = 5;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
@ -103,8 +100,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 +179,19 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
logDebug('fetching comments of ${item.id} from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
logError(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 +202,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
logDebug('fetching comments of ${item.id} from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
@ -279,17 +277,19 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
logDebug(
'fetching comments of ${item.id} from web.',
);
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
logError(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 +300,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
logDebug('fetching comments of ${item.id} from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}
@ -384,8 +384,8 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
})
..onError((dynamic error) {
_logger.e(error);
..onError((dynamic e) {
logError(e);
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
});
@ -411,7 +411,7 @@ class CommentsCubit extends Cubit<CommentsState> {
return;
} else {
await router.push(
'/${ItemScreen.routeName}',
Paths.item.landing,
extra: ItemScreenArgs(item: parent),
);
@ -434,7 +434,7 @@ class CommentsCubit extends Cubit<CommentsState> {
return;
} else {
await router.push(
'/${ItemScreen.routeName}',
Paths.item.landing,
extra: ItemScreenArgs(item: parent),
);
@ -731,4 +731,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
await super.close();
}
@override
String get logIdentifier => '[CommentsCubit]';
}

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

@ -6,20 +6,20 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'fav_state.dart';
class FavCubit extends Cubit<FavState> {
class FavCubit extends Cubit<FavState> with Loggable {
FavCubit({
required AuthBloc authBloc,
AuthRepository? authRepository,
PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
SembastRepository? sembastRepository,
}) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository =
@ -28,7 +28,8 @@ class FavCubit extends Cubit<FavState> {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
super(FavState.init()) {
init();
}
@ -38,9 +39,9 @@ class FavCubit extends Cubit<FavState> {
final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final SembastRepository _sembastRepository;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20;
static const int _pageSize = 100;
Future<void> init() async {
_usernameSubscription = _authBloc.stream
@ -50,7 +51,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,
),
@ -58,6 +59,8 @@ class FavCubit extends Cubit<FavState> {
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
getFromCache: (int id) =>
_sembastRepository.getCachedItem(id: id),
)
.listen(_onItemLoaded)
.onDone(() {
@ -100,7 +103,10 @@ class FavCubit extends Cubit<FavState> {
void removeFav(int id) {
_preferenceRepository
..removeFav(username: username, id: id)
..removeFav(username: '', id: id);
..removeFav(
username: '',
id: id,
);
emit(
state.copyWith(
@ -155,7 +161,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)),
@ -184,7 +190,7 @@ class FavCubit extends Cubit<FavState> {
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
of: _authBloc.state.username,
);
_logger.d('fetched ${ids.length} favorite items from HN.');
logDebug('fetched ${ids.length} favorite items from HN.');
final List<int> combinedIds = <int>[...ids, ...state.favIds];
final LinkedHashSet<int> mergedIds =
LinkedHashSet<int>.from(combinedIds);
@ -203,6 +209,7 @@ class FavCubit extends Cubit<FavState> {
}
void _onItemLoaded(Item item) {
_sembastRepository.cacheItem(item);
emit(
state.copyWith(
favItems: List<Item>.from(state.favItems)..add(item),
@ -210,11 +217,17 @@ class FavCubit extends Cubit<FavState> {
);
}
void switchTab() =>
emit(state.copyWith(isDisplayingStories: !state.isDisplayingStories));
@override
Future<void> close() {
_usernameSubscription?.cancel();
return super.close();
}
@override
String get logIdentifier => '[FavCubit]';
}
extension on FavCubit {

View File

@ -7,6 +7,7 @@ class FavState extends Equatable {
required this.status,
required this.mergeStatus,
required this.currentPage,
required this.isDisplayingStories,
});
FavState.init()
@ -14,13 +15,21 @@ class FavState extends Equatable {
favItems = <Item>[],
status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0;
currentPage = 0,
isDisplayingStories = true;
final List<int> favIds;
final List<Item> favItems;
final Status status;
final Status mergeStatus;
final int currentPage;
final bool isDisplayingStories;
List<Comment> get favComments =>
favItems.whereType<Comment>().toList(growable: false);
List<Story> get favStories =>
favItems.whereType<Story>().toList(growable: false);
FavState copyWith({
List<int>? favIds,
@ -28,6 +37,7 @@ class FavState extends Equatable {
Status? status,
Status? mergeStatus,
int? currentPage,
bool? isDisplayingStories,
}) {
return FavState(
favIds: favIds ?? this.favIds,
@ -35,6 +45,7 @@ class FavState extends Equatable {
status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage,
isDisplayingStories: isDisplayingStories ?? this.isDisplayingStories,
);
}
@ -45,5 +56,6 @@ class FavState extends Equatable {
currentPage,
favIds,
favItems,
isDisplayingStories,
];
}

View File

@ -7,20 +7,19 @@ 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:logger/logger.dart';
part 'notification_state.dart';
class NotificationCubit extends Cubit<NotificationState> {
class NotificationCubit extends Cubit<NotificationState> with Loggable {
NotificationCubit({
required AuthBloc authBloc,
required PreferenceCubit preferenceCubit,
HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository,
Logger? logger,
}) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit,
_hackerNewsRepository =
@ -29,7 +28,6 @@ class NotificationCubit extends Cubit<NotificationState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(NotificationState.init()) {
_authBloc.stream
.map((AuthState event) => event.username)
@ -37,16 +35,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();
}
});
@ -61,7 +59,6 @@ class NotificationCubit extends Cubit<NotificationState> {
final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository;
final Logger _logger;
Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5);
@ -78,7 +75,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
logInfo('${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});
@ -104,36 +101,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 +231,33 @@ 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);
});
}
@override
String get logIdentifier => '[NotificationCubit]';
}

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

@ -6,23 +6,19 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'preference_state.dart';
class PreferenceCubit extends Cubit<PreferenceState> {
class PreferenceCubit extends Cubit<PreferenceState> with Loggable {
PreferenceCubit({
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(PreferenceState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
final Logger _logger;
void init() {
for (final BooleanPreference p
@ -73,7 +69,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
}
void update<T>(Preference<T> preference) {
_logger.i('updating $preference to ${preference.val}');
logInfo('updating $preference to ${preference.val}');
emit(state.copyWithPreference(preference));
@ -97,4 +93,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
throw UnimplementedError();
}
}
@override
String get logIdentifier => '[PreferenceCubit]';
}

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;
@ -87,6 +89,12 @@ class PreferenceState extends Equatable {
) as MaterialColor;
}
HackerNewsDataSource get dataSource {
return HackerNewsDataSource.values.elementAt(
preferences.singleWhereType<HackerNewsDataSourcePreference>().val,
);
}
List<StoryType> get tabs {
final String result =
preferences.singleWhereType<TabOrderPreference>().val.toString();

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,46 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/repositories/remote_config_repository.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'remote_config_state.dart';
class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> with Loggable {
RemoteConfigCubit({
RemoteConfigRepository? remoteConfigRepository,
}) : _remoteConfigRepository =
remoteConfigRepository ?? locator.get<RemoteConfigRepository>(),
super(RemoteConfigState.init()) {
init();
}
final RemoteConfigRepository _remoteConfigRepository;
void init() {
_remoteConfigRepository
.fetchRemoteConfig()
.then((Map<String, dynamic> data) {
if (data.isNotEmpty) {
logInfo('remote config fetched: $data');
emit(state.copyWith(data: data));
} else {
logInfo('remote config fetched is empty.');
}
});
}
@override
RemoteConfigState? fromJson(Map<String, dynamic> json) {
return RemoteConfigState(data: json);
}
@override
Map<String, dynamic>? toJson(RemoteConfigState state) {
return state.data;
}
@override
String get logIdentifier => 'RemoteConfigCubit';
}

View File

@ -0,0 +1,101 @@
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 storySelector => getString(
key: 'storySelector',
fallback: '''#hnmain > tbody > tr > td > table > tbody > .athing''',
);
String get subtextSelector => getString(
key: 'subtextSelector',
fallback:
'''#hnmain > tbody > tr > td > table > tbody > tr > .subtext''',
);
String get titlelineSelector => getString(
key: 'titlelineSelector',
fallback: '''.title > .titleline > a''',
);
String get pointSelector => getString(
key: 'pointSelector',
fallback: '''.subline > .score''',
);
String get userSelector => getString(
key: 'userSelector',
fallback: '''.subline > .hnuser''',
);
String get ageSelector => getString(
key: 'ageSelector',
fallback: '''.subline > .age''',
);
String get cmtCountSelector => getString(
key: 'cmtCountSelector',
fallback: '''.subline > a''',
);
String get moreLinkSelector => getString(
key: 'moreLinkSelector',
fallback:
''''#hnmain > tbody > tr:nth-child(3) > td > table > tbody > tr > td.title > a''',
);
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

@ -1,25 +1,23 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'split_view_state.dart';
class SplitViewCubit extends Cubit<SplitViewState> {
class SplitViewCubit extends HydratedCubit<SplitViewState> with Loggable {
SplitViewCubit({
CommentCache? commentCache,
Logger? logger,
}) : _commentCache = commentCache ?? locator.get<CommentCache>(),
_logger = logger ?? locator.get<Logger>(),
super(const SplitViewState.init());
final Logger _logger;
final CommentCache _commentCache;
void updateItemScreenArgs(ItemScreenArgs args) {
_logger.i('resetting comments in CommentCache');
logInfo('resetting comments in CommentCache');
_commentCache.resetComments();
emit(state.copyWith(itemScreenArgs: args));
}
@ -28,5 +26,36 @@ class SplitViewCubit extends Cubit<SplitViewState> {
void disableSplitView() => emit(state.copyWith(enabled: false));
void zoom() => emit(state.copyWith(expanded: !state.expanded));
void zoom() => emit(
state.copyWith(
expanded: !state.expanded,
resizingAnimationDuration: AppDurations.ms300,
),
);
void updateSubmissionPanelWidth(double width) => emit(
state.copyWith(
submissionPanelWidth: width,
resizingAnimationDuration: Duration.zero,
),
);
@override
String get logIdentifier => '[SplitViewCubit]';
static const String _submissionPanelWidthKey = 'submissionPanelWidth';
@override
SplitViewState? fromJson(Map<String, dynamic> json) {
return state.copyWith(
submissionPanelWidth: json[_submissionPanelWidthKey] as double?,
);
}
@override
Map<String, dynamic>? toJson(SplitViewState state) {
return <String, dynamic>{
_submissionPanelWidthKey: state.submissionPanelWidth,
};
}
}

View File

@ -5,25 +5,36 @@ class SplitViewState extends Equatable {
required this.itemScreenArgs,
required this.expanded,
required this.enabled,
required this.resizingAnimationDuration,
this.submissionPanelWidth,
});
const SplitViewState.init()
: enabled = false,
expanded = false,
submissionPanelWidth = null,
resizingAnimationDuration = Duration.zero,
itemScreenArgs = null;
final bool enabled;
final bool expanded;
final double? submissionPanelWidth;
final Duration resizingAnimationDuration;
final ItemScreenArgs? itemScreenArgs;
SplitViewState copyWith({
bool? enabled,
bool? expanded,
double? submissionPanelWidth,
Duration? resizingAnimationDuration,
ItemScreenArgs? itemScreenArgs,
}) {
return SplitViewState(
enabled: enabled ?? this.enabled,
expanded: expanded ?? this.expanded,
submissionPanelWidth: submissionPanelWidth ?? this.submissionPanelWidth,
resizingAnimationDuration:
resizingAnimationDuration ?? this.resizingAnimationDuration,
itemScreenArgs: itemScreenArgs ?? this.itemScreenArgs,
);
}
@ -32,6 +43,8 @@ class SplitViewState extends Equatable {
List<Object?> get props => <Object?>[
enabled,
expanded,
submissionPanelWidth,
resizingAnimationDuration,
itemScreenArgs,
];
}

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

@ -1,38 +1,38 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.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:logger/logger.dart';
part 'tab_state.dart';
class TabCubit extends Cubit<TabState> {
class TabCubit extends Cubit<TabState> with Loggable {
TabCubit({
required PreferenceCubit preferenceCubit,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_logger = logger ?? locator.get<Logger>(),
super(TabState.init());
super(TabState.init()) {
init();
}
final PreferenceCubit _preferenceCubit;
final Logger _logger;
void init() {
final List<StoryType> tabs = _preferenceCubit.state.tabs;
_logger.i('updating tabs to $tabs');
logInfo('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');
logDebug(
'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)
..removeAt(startIndex < endIndex ? startIndex : startIndex + 1);
_logger.d(updatedTabs);
logDebug(updatedTabs);
emit(state.copyWith(tabs: updatedTabs));
// Check to make sure there's no duplicate.
@ -42,4 +42,7 @@ class TabCubit extends Cubit<TabState> {
);
}
}
@override
String get logIdentifier => '[TabCubit]';
}

View File

@ -3,7 +3,7 @@ export 'date_time_extension.dart';
export 'int_extension.dart';
export 'item_action_mixin.dart';
export 'list_extension.dart';
export 'object_extension.dart';
export 'loggable.dart';
export 'set_extension.dart';
export 'string_extension.dart';
export 'widget_extension.dart';

View File

@ -3,12 +3,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/screens/screens.dart' show ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
@ -39,7 +40,7 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
if (splitViewEnabled && !forceNewScreen) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
context.push('/${ItemScreen.routeName}', extra: args);
context.push(Paths.item.landing, extra: args);
}
return Future<void>.value();
@ -164,7 +165,7 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
if (mounted && (yesTapped ?? false)) {
context.read<AuthBloc>().add(AuthFlag(item: item));
showSnackBar(content: 'Comment flagged!');
}
@ -202,6 +203,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
);
},
).then((bool? yesTapped) {
if (!mounted) return;
if (yesTapped ?? false) {
if (isBlocked) {
context.read<BlocklistCubit>().removeFromBlocklist(item.by);

View File

@ -0,0 +1,98 @@
import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
mixin Loggable {
String get logIdentifier;
Logger get _logger => locator.get<Logger>();
/// Log a message at level [Level.trace].
void logTrace(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.t(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
/// Log a message at level [Level.debug].
void logDebug(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.d(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
/// Log a message at level [Level.info].
void logInfo(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.i(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
/// Log a message at level [Level.warning].
void logWarning(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.w(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
/// Log a message at level [Level.error].
void logError(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.e(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
/// Log a message at level [Level.fatal].
void logFatal(
dynamic message, {
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
_logger.f(
'$logIdentifier $message',
time: time,
error: error,
stackTrace: stackTrace,
);
}
}

View File

@ -1,23 +0,0 @@
import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
extension ObjectExtension on Object {
void log([String identifier = '']) {
locator.get<Logger>().d('$identifier ${toString()}');
}
void logInfo({String identifier = ''}) {
locator.get<Logger>().i('$identifier ${toString()}');
}
void logError({
String identifier = '',
StackTrace? stackTrace,
}) {
locator.get<Logger>().e(
identifier,
error: this,
stackTrace: stackTrace ?? StackTrace.current,
);
}
}

View File

@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
@ -54,6 +53,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 +72,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(
@ -103,16 +103,6 @@ Future<void> main({bool testing = false}) async {
badge: true,
sound: true,
);
FlutterSiriSuggestions.instance.configure(
onLaunch: (Map<String, dynamic> message) async {
final String? storyId = message['key'] as String?;
if (storyId == null) return;
siriSuggestionSubject.add(storyId);
},
);
} else if (Platform.isAndroid) {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
@ -139,8 +129,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 +150,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(),
@ -174,7 +165,7 @@ class HackiApp extends StatelessWidget {
create: (BuildContext context) => StoriesBloc(
preferenceCubit: context.read<PreferenceCubit>(),
filterCubit: context.read<FilterCubit>(),
),
)..add(StoriesInitialize(startup: true)),
),
BlocProvider<AuthBloc>(
lazy: false,
@ -217,7 +208,7 @@ class HackiApp extends StatelessWidget {
),
BlocProvider<ReminderCubit>(
lazy: false,
create: (BuildContext context) => ReminderCubit()..init(),
create: (BuildContext context) => ReminderCubit(),
),
BlocProvider<PostCubit>(
lazy: false,
@ -230,24 +221,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 +270,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 +305,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);
@ -336,6 +340,14 @@ class HackiApp extends StatelessWidget {
: Palette.black,
),
),
disabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: (isDarkModeEnabled
? Palette.white
: Palette.black)
.withOpacity(0.4),
),
),
),
sliderTheme: SliderThemeData(
inactiveTrackColor:
@ -345,7 +357,7 @@ class HackiApp extends StatelessWidget {
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
side: WidgetStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),

View File

@ -11,12 +11,19 @@ class AppException implements Exception {
}
class RateLimitedException extends AppException {
RateLimitedException() : super(message: 'Rate limited...');
RateLimitedException(this.statusCode)
: super(message: 'Rate limited ($statusCode)...');
final int? statusCode;
}
class RateLimitedWithFallbackException extends AppException {
RateLimitedWithFallbackException()
: super(message: 'Rate limited, fetching from API instead...');
RateLimitedWithFallbackException(this.statusCode)
: super(
message: 'Rate limited ($statusCode), fetching from API instead...',
);
final int? statusCode;
}
class PossibleParsingException extends AppException {

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

@ -0,0 +1,8 @@
enum HackerNewsDataSource {
api('API'),
web('Web');
const HackerNewsDataSource(this.description);
final String description;
}

View File

@ -41,6 +41,7 @@ class BuildableComment extends Comment with Buildable {
BuildableComment copyWith({
int? level,
bool? hidden,
int? kid,
}) {
return BuildableComment(
id: id,
@ -49,7 +50,7 @@ class BuildableComment extends Comment with Buildable {
score: score,
by: by,
text: text,
kids: kids,
kids: kid == null ? kids : <int>[...kids, kid],
dead: dead,
deleted: deleted,
hidden: hidden ?? this.hidden,

View File

@ -36,6 +36,7 @@ class Comment extends Item {
Comment copyWith({
int? level,
bool? hidden,
int? kid,
}) {
return Comment(
id: id,
@ -44,7 +45,7 @@ class Comment extends Item {
score: score,
by: by,
text: text,
kids: kids,
kids: kid == null ? kids : <int>[...kids, kid],
dead: dead,
deleted: deleted,
hidden: hidden ?? this.hidden,

View File

@ -62,7 +62,7 @@ class Story extends Item {
}
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
'''$score point${score > 1 ? 's' : ''}${by.isNotEmpty ? ' $by ' : ' '}$timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
String get screenReaderLabel =>
'''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';

View File

@ -6,6 +6,7 @@ export 'export_destination.dart';
export 'fetch_mode.dart';
export 'font.dart';
export 'font_size.dart';
export 'hacker_news_data_source.dart';
export 'item/item.dart';
export 'post_data.dart';
export 'preference.dart';

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(),
@ -28,15 +28,20 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
StoryMarkingModePreference(),
AppColorPreference(),
DateFormatPreference(),
HackerNewsDataSourcePreference(),
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(),
@ -105,11 +110,11 @@ final class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode';
@override
String get title => 'Swipe Gesture';
String get title => 'Swipe Gesture for Switching Tabs';
@override
String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu and double tap to open the url (if complex tile is disabled).''';
}
final class NotificationModePreference extends BooleanPreference {
@ -160,7 +165,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 +206,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);
@ -561,3 +587,22 @@ final class DateFormatPreference extends IntPreference {
@override
String get title => 'Date Format';
}
final class HackerNewsDataSourcePreference extends IntPreference {
HackerNewsDataSourcePreference({int? val})
: super(val: val ?? _hackerNewsDataSourceDefaultValue);
static final int _hackerNewsDataSourceDefaultValue =
HackerNewsDataSource.api.index;
@override
HackerNewsDataSourcePreference copyWith({required int? val}) {
return HackerNewsDataSourcePreference(val: val);
}
@override
String get key => 'hackerNewsDataSource';
@override
String get title => 'Date Source';
}

View File

@ -1,13 +1,22 @@
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
top('topstories', ''),
best('beststories', 'best'),
latest('newstories', 'newest'),
ask('askstories', 'ask'),
show('showstories', 'show');
const StoryType(this.path);
const StoryType(
this.apiPathParam,
this.webPathParam,
);
final String path;
/// The path param used in the official Hacker News API.
/// e.g. https://hacker-news.firebaseio.com/v0/{apiPathParam}.json
final String apiPathParam;
/// The path param used in the HN web.
/// e.g. https://news.ycombinator.com/{webPathParam}
final String webPathParam;
String get label {
switch (this) {

View File

@ -1,28 +1,25 @@
import 'dart:async';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/preference_repository.dart';
import 'package:logger/logger.dart';
/// [AuthRepository] if for logging user in/out and performing actions
/// that require a logged in user such as [flag], [favorite], [upvote],
/// and [downvote].
///
/// For posting actions such as posting a comment, see [PostRepository].
class AuthRepository extends PostableRepository {
class AuthRepository extends PostableRepository with Loggable {
AuthRepository({
super.dio,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>();
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>();
final PreferenceRepository _preferenceRepository;
final Logger _logger;
Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
@ -49,8 +46,8 @@ class AuthRepository extends PostableRepository {
username: username,
password: password,
);
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
return false;
}
}
@ -131,4 +128,7 @@ class AuthRepository extends PostableRepository {
return performDefaultPost(uri, data);
}
@override
String get logIdentifier => '[AuthRepository]';
}

View File

@ -2,30 +2,27 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
/// [HackerNewsRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User].
///
/// You can learn more about the Hacker News API at
/// https://github.com/HackerNews/API.
class HackerNewsRepository {
class HackerNewsRepository with Loggable {
HackerNewsRepository({
FirebaseClient? firebaseClient,
SembastRepository? sembastRepository,
Logger? logger,
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>();
sembastRepository ?? locator.get<SembastRepository>();
final FirebaseClient _firebaseClient;
final SembastRepository _sembastRepository;
final Logger _logger;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
@ -118,7 +115,7 @@ class HackerNewsRepository {
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.get('$_baseUrl${type.apiPathParam}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
@ -246,7 +243,7 @@ class HackerNewsRepository {
return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
logError(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
@ -284,7 +281,7 @@ class HackerNewsRepository {
return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
logError(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
@ -305,40 +302,64 @@ class HackerNewsRepository {
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
Stream<Item> fetchItemsStream({
required List<int> ids,
Future<Item?> Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Item? cachedItem = await getFromCache?.call(id);
if (cachedItem != null) {
yield cachedItem;
} else {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
return null;
});
if (item != null) {
yield item;
}
}
}
/// 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;
}
}
@ -409,6 +430,9 @@ class HackerNewsRepository {
return json;
}
@override
String get logIdentifier => '[HackerNewsRepository]';
}
extension on Map<String, dynamic> {

View File

@ -1,40 +1,256 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.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/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/hacker_news_repository.dart';
import 'package:hacki/utils/utils.dart';
import 'package:html/dom.dart' hide Comment;
import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
class HackerNewsWebRepository with Loggable {
HackerNewsWebRepository({
RemoteConfigCubit? remoteConfigCubit,
HackerNewsRepository? hackerNewsRepository,
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
}) : _dio = dio ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
],
),
_dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
CacheInterceptor(),
],
);
),
_remoteConfigCubit =
remoteConfigCubit ?? locator.get<RemoteConfigCubit>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>() {
_dio.interceptors.add(RetryInterceptor(dio: _dio));
}
/// The client for fetching comments. We should be careful
/// while fetching comments because it will easily trigger
/// 503 from the server.
final Dio _dioWithCache;
/// The client for fetching stories.
final Dio _dio;
final RemoteConfigCubit _remoteConfigCubit;
final HackerNewsRepository _hackerNewsRepository;
static const Map<String, String> _headers = <String, String>{
'accept': '*/*',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
};
static const String _storiesBaseUrl = 'https://news.ycombinator.com';
String get _storySelector => _remoteConfigCubit.state.storySelector;
String get _titlelineSelector => _remoteConfigCubit.state.titlelineSelector;
String get _subtextSelector => _remoteConfigCubit.state.subtextSelector;
String get _pointSelector => _remoteConfigCubit.state.pointSelector;
String get _userSelector => _remoteConfigCubit.state.userSelector;
String get _ageSelector => _remoteConfigCubit.state.ageSelector;
String get _cmtCountSelector => _remoteConfigCubit.state.cmtCountSelector;
String get _moreLinkSelector => _remoteConfigCubit.state.moreLinkSelector;
static final Map<int, int> _next = <int, int>{};
static const List<int> _rateLimitedStatusCode = <int>[
HttpStatus.forbidden,
HttpStatus.serviceUnavailable,
];
Stream<Story> fetchStoriesStream(
StoryType storyType, {
required int page,
}) async* {
Future<Iterable<(Element, Element)>> fetchElements(
int page,
) async {
try {
final String urlStr = switch (storyType) {
StoryType.top => '$_storiesBaseUrl?p=$page',
StoryType.best ||
StoryType.ask ||
StoryType.show =>
'$_storiesBaseUrl/${storyType.webPathParam}?p=$page',
StoryType.latest =>
'$_storiesBaseUrl/${storyType.webPathParam}?next=${_next[page]}'
};
final Uri url = Uri.parse(urlStr);
final Options option = Options(
headers: _headers,
persistentConnection: true,
);
/// Be more conservative while user is on wifi.
final Response<String> response = await _dio.getUri<String>(
url,
options: option,
);
final String data = response.data ?? '';
final Document document = parse(data);
final List<Element> elements =
document.querySelectorAll(_storySelector);
final List<Element> subtextElements =
document.querySelectorAll(_subtextSelector);
if (storyType == StoryType.latest) {
/// Get the next id for latest stories.
final Element? moreLinkElement =
document.querySelector(_moreLinkSelector);
/// Example: "newest?next=41240344&n=31"
final String? href = moreLinkElement?.attributes['href'];
final String? nextIdStr =
href?.split('&n').firstOrNull?.split('=').lastOrNull;
final int? nextId = int.tryParse(nextIdStr ?? '');
if (nextId != null) {
_next[page + 1] = nextId;
}
}
return List<(Element, Element)>.generate(
min(elements.length, subtextElements.length),
(int index) =>
(elements.elementAt(index), subtextElements.elementAt(index)),
);
} on DioException catch (e) {
logError('error fetching stories on page $page: $e');
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
throw RateLimitedWithFallbackException(e.response?.statusCode);
}
throw GenericException();
}
}
final Set<int> fetchedStoryIds = <int>{};
final Iterable<(Element, Element)> elements = await fetchElements(page);
while (elements.isNotEmpty) {
for (final (Element, Element) element in elements) {
final Element titleElement = element.$1;
final Element subtextElement = element.$2;
/// Get id.
final String? idStr = titleElement.attributes['id'];
final int? id = int.tryParse(idStr ?? '');
/// Get user.
final Element? userElement =
subtextElement.querySelector(_userSelector);
final String? user = userElement?.nodes.firstOrNull?.text;
/// Get post date.
final Element? postDateElement =
subtextElement.querySelector(_ageSelector) ??
subtextElement.querySelector('.age');
final String? dateStr = postDateElement?.attributes['title'];
final int? timestamp = dateStr == null
? null
: DateTime.parse(dateStr)
.copyWith(isUtc: true)
.millisecondsSinceEpoch;
/// Get descendants.
final Element? cmtCountElement =
subtextElement.querySelectorAll(_cmtCountSelector).lastOrNull;
final String cmtCountStr = cmtCountElement?.nodes.firstOrNull?.text
?.split('\u{00A0}')
.firstOrNull ??
'';
final int cmtCount = int.tryParse(cmtCountStr) ?? 0;
/// Get title;
final Element? titlelineElement =
titleElement.querySelector(_titlelineSelector);
final String title = titlelineElement?.nodes.firstOrNull?.text ?? '';
final String url = titlelineElement?.attributes['href'] ?? '';
/// Get points.
final Element? ptElement = subtextElement.querySelector(_pointSelector);
/// Example: "80 points"
final String? pointsStr = ptElement?.nodes.firstOrNull?.text;
final int? points =
int.tryParse(pointsStr?.split(' ').firstOrNull ?? '');
if (id == null) continue;
Story story = Story(
id: id,
time: timestamp ?? 0,
score: points ?? 0,
by: user ?? '',
text: '',
kids: const <int>[],
hidden: false,
descendants: cmtCount,
title: title,
type: 'story',
url: storyType == StoryType.ask ? '$_itemBaseUrl$id' : url,
parts: const <int>[],
);
/// If it is a story about launching or from ask section, then
/// we need to fetch it from API since the html doesn't contain
/// too much info.
if (timestamp == null ||
url.isEmpty ||
url.contains('item?id=') ||
title.contains('Launch HN:') ||
title.contains('Ask HN:')) {
final Story? fallbackStory = await _hackerNewsRepository
.fetchStory(id: id)
.timeout(AppDurations.fiveSeconds);
if (fallbackStory != null) {
story = fallbackStory;
}
}
/// Duplicate story means we are done fetching all the stories.
if (fetchedStoryIds.contains(story.id)) return;
fetchedStoryIds.add(story.id);
yield story;
}
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
return;
}
}
static const String _favoritesBaseUrl =
'https://news.ycombinator.com/favorites?id=';
static const String _aThingSelector =
@ -65,8 +281,9 @@ class HackerNewsWebRepository {
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
return parsedIds;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedException();
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
logError('error fetching favorites on page $page: $e');
throw RateLimitedException(e.response?.statusCode);
}
throw GenericException();
}
@ -96,16 +313,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;
@ -139,8 +360,9 @@ class HackerNewsWebRepository {
document.querySelectorAll(_athingComtrSelector);
return elements;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
logError('error fetching comments on page $page: $e');
throw RateLimitedWithFallbackException(e.response?.statusCode);
}
throw GenericException();
}
@ -246,8 +468,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 {
@ -284,4 +507,7 @@ class HackerNewsWebRepository {
)
.trim();
}
@override
String get logIdentifier => 'HackerNewsWebRepository';
}

View File

@ -1,9 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/extensions/loggable.dart';
import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
/// [OfflineRepository] is for storing [Story] and [Comment] for
@ -12,20 +13,18 @@ import 'package:path_provider/path_provider.dart';
/// [Hive] is used as its database and is being stored in the temporary
/// directory assigned by host system which you can retrieve
/// by calling [getTemporaryDirectory].
class OfflineRepository {
class OfflineRepository with Loggable {
OfflineRepository({
Future<Box<List<int>>>? storyIdBox,
Future<Box<Map<dynamic, dynamic>>>? storyBox,
Future<LazyBox<String>>? webPageBox,
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
Logger? logger,
}) : _storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_webPageBox = webPageBox ?? Hive.openLazyBox<String>(_webPageBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName),
_logger = logger ?? locator.get<Logger>();
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
static const String _storyIdBoxName = 'storyIdBox';
static const String _storyBoxName = 'storyBox';
@ -35,7 +34,6 @@ class OfflineRepository {
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
final Future<LazyBox<String>> _webPageBox;
final Logger _logger;
Future<bool> get hasCachedStories =>
_storyBox.then((Box<Map<dynamic, dynamic>> box) => box.isNotEmpty);
@ -48,8 +46,8 @@ class OfflineRepository {
try {
box = await _storyIdBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
box = await _storyIdBox;
}
@ -62,8 +60,8 @@ class OfflineRepository {
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyBoxName);
box = await _storyBox;
}
@ -76,13 +74,19 @@ class OfflineRepository {
try {
box = await _webPageBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_webPageBoxName);
box = await _webPageBox;
}
final String html = await compute(_downloadWebPage, url);
final String html = await compute(_downloadWebPage, url).timeout(
AppDurations.tenSeconds,
onTimeout: () {
logInfo('failed to download $url');
return 'download timeout.';
},
);
return box.put(url, html);
}
@ -90,8 +94,8 @@ class OfflineRepository {
try {
final LazyBox<String> box = await _webPageBox;
return box.get(url);
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return null;
}
@ -101,8 +105,8 @@ class OfflineRepository {
try {
final LazyBox<String> box = await _webPageBox;
return box.containsKey(url);
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return false;
}
@ -113,8 +117,8 @@ class OfflineRepository {
final Box<List<int>> box = await _storyIdBox;
final List<int>? ids = box.get(type.name);
return ids ?? <int>[];
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
return <int>[];
}
@ -125,8 +129,8 @@ class OfflineRepository {
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyBoxName);
return;
}
@ -150,8 +154,8 @@ class OfflineRepository {
try {
box = await _storyBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyBoxName);
return null;
}
@ -169,8 +173,8 @@ class OfflineRepository {
try {
box = await _commentBox;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_commentBoxName);
box = await _commentBox;
}
@ -189,8 +193,8 @@ class OfflineRepository {
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson);
return comment;
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_commentBoxName);
return null;
}
@ -220,8 +224,8 @@ class OfflineRepository {
try {
final Box<List<int>> box = await _storyIdBox;
return box.clear();
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyIdBoxName);
return 0;
}
@ -231,8 +235,8 @@ class OfflineRepository {
try {
final Box<Map<dynamic, dynamic>> box = await _storyBox;
return box.clear();
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_storyBoxName);
return 0;
}
@ -242,8 +246,8 @@ class OfflineRepository {
try {
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
return box.clear();
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_commentBoxName);
return 0;
}
@ -253,8 +257,8 @@ class OfflineRepository {
try {
final LazyBox<String> box = await _webPageBox;
return box.clear();
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
await Hive.deleteBoxFromDisk(_webPageBoxName);
return 0;
}
@ -275,8 +279,11 @@ class OfflineRepository {
final String body = response.body;
client.close();
return body;
} catch (_) {
} catch (e) {
return '''Web page not available.''';
}
}
@override
String get logIdentifier => '[OfflineRepository]';
}

View File

@ -2,22 +2,19 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart';
/// [PreferenceRepository] is for storing user preferences.
class PreferenceRepository {
class PreferenceRepository with Loggable {
PreferenceRepository({
SyncedSharedPreferences? syncedPrefs,
Future<SharedPreferences>? prefs,
FlutterSecureStorage? secureStorage,
Logger? logger,
}) : _syncedPrefs = syncedPrefs ?? SyncedSharedPreferences.instance,
_prefs = prefs ?? SharedPreferences.getInstance(),
_secureStorage = secureStorage ?? const FlutterSecureStorage(),
_logger = logger ?? locator.get<Logger>();
_secureStorage = secureStorage ?? const FlutterSecureStorage();
static const String _usernameKey = 'username';
static const String _passwordKey = 'password';
@ -30,7 +27,6 @@ class PreferenceRepository {
final SyncedSharedPreferences _syncedPrefs;
final Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage;
final Logger _logger;
Future<bool> get loggedIn async => await username != null;
@ -109,8 +105,8 @@ class PreferenceRepository {
await _secureStorage.deleteAll(
aOptions: androidOptions,
);
} catch (_) {
_logger.e(_);
} catch (e) {
logError(e);
}
rethrow;
@ -448,4 +444,7 @@ class PreferenceRepository {
static String _getPushNotificationKey(int commentId) => 'pushed_$commentId';
static String _getHasReadKey(int storyId) => 'hasRead_$storyId';
@override
String get logIdentifier => '[PreferenceRepository]';
}

View File

@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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 {
if (kReleaseMode) {
const String fileName = 'remote-config.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;
} else {
const String fileName = 'remote-config-dev.json';
final String data = await rootBundle.loadString('assets/$fileName');
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,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart';
@ -9,11 +11,13 @@ import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading.
/// This is currently used by [TimeMachineCubit], [NotificationCubit] and
/// [FavCubit].
///
/// Sembast [Database] is used as its database and is being stored in the
/// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory].
class SembastRepository {
class SembastRepository with Loggable {
SembastRepository({
Database? database,
Database? cache,
@ -44,6 +48,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();
logInfo('hacki.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_database = db;
@ -51,16 +58,19 @@ 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();
logInfo('hacki_cache.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;
return db;
}
//#region Cached comments for time machine feature.
//#region Cached comments for time machine feature and favorites screen.
Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
@ -82,7 +92,34 @@ class SembastRepository {
}
}
Future<int> deleteAllCachedComments() async {
Future<Map<String, Object?>> cacheItem(Item item) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
return store.record(item.id).put(db, item.toJson());
}
Future<Item?> getCachedItem({required int id}) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await store.record(id).getSnapshot(db);
if (snapshot != null) {
final bool isStory = snapshot['type'] == 'story';
if (isStory) {
final Story story = Story.fromJson(snapshot.value);
return story;
} else {
final Comment comment = Comment.fromJson(snapshot.value);
return comment;
}
} else {
return null;
}
}
Future<int> deleteAllCachedItems() async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
@ -209,7 +246,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 +269,26 @@ 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();
}
}
@override
String get logIdentifier => '[SembastRepository]';
}

View File

@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.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/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
@ -22,7 +21,6 @@ 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:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
@ -36,7 +34,7 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin {
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin, Loggable {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
@ -49,7 +47,7 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
logInfo('resetting comments in CommentCache');
Future<void>.delayed(
AppDurations.ms500,
locator.get<CommentCache>().resetComments,
@ -98,17 +96,6 @@ class _HomeScreenState extends State<HomeScreen>
tabController = TabController(length: tabLength, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
tabController.dispose();
@ -123,9 +110,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 +138,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 +178,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.
@ -216,7 +204,7 @@ class _HomeScreenState extends State<HomeScreen>
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
context.push('/${ItemScreen.routeName}', extra: args);
context.push(Paths.item.landing, extra: args);
}
}
@ -232,34 +220,23 @@ class _HomeScreenState extends State<HomeScreen>
if (markReadStoriesEnabled && storyMarkingMode.shouldDetectTapping) {
context.read<StoriesBloc>().add(StoryRead(story: story));
}
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void onShareExtensionTapped(String? event) {
logInfo('share intent received: $event');
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<HackerNewsRepository>().fetchItem(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
logInfo('item fetched successfully: $item');
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
});
}
@ -318,4 +295,7 @@ class _HomeScreenState extends State<HomeScreen>
DiscoverableFeature.pinToTop.featureId,
]);
}
@override
String get logIdentifier => '[HomeScreen]';
}

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

@ -1,6 +1,6 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -14,6 +14,8 @@ class TabletHomeScreen extends StatelessWidget {
});
final Widget homeScreen;
static const double _dragPanelWidth = Dimens.pt2;
static const double _dragDotHeight = Dimens.pt30;
@override
Widget build(BuildContext context) {
@ -28,35 +30,110 @@ class TabletHomeScreen extends StatelessWidget {
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded,
previous.expanded != current.expanded ||
previous.submissionPanelWidth != current.submissionPanelWidth,
builder: (BuildContext context, SplitViewState state) {
double submissionPanelWidth =
state.submissionPanelWidth ?? homeScreenWidth;
/// Prevent overflow after orientation change.
if (submissionPanelWidth > MediaQuery.of(context).size.width) {
submissionPanelWidth =
MediaQuery.of(context).size.width - Dimens.pt64;
}
return Stack(
children: <Widget>[
AnimatedPositioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: AppDurations.ms300,
width: submissionPanelWidth,
duration: state.resizingAnimationDuration,
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: submissionPanelWidth - Dimens.pt48,
child: const CountdownReminder(),
)
else
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: submissionPanelWidth - Dimens.pt48,
child: const DownloadProgressReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: AppDurations.ms300,
left: state.expanded
? Dimens.zero
: submissionPanelWidth + _dragPanelWidth,
duration: state.resizingAnimationDuration,
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
if (!state.expanded) ...<Widget>[
Positioned(
left: submissionPanelWidth,
top: Dimens.zero,
bottom: Dimens.zero,
width: _dragPanelWidth,
child: GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
context
.read<SplitViewCubit>()
.updateSubmissionPanelWidth(
details.globalPosition.dx,
);
},
child: ColoredBox(
color: Theme.of(context).colorScheme.tertiary,
child: const SizedBox.shrink(),
),
),
),
Positioned(
left: submissionPanelWidth +
_dragPanelWidth / 2 -
_dragDotHeight / 2,
top: (MediaQuery.of(context).size.height - _dragDotHeight) /
2,
height: _dragDotHeight,
width: _dragDotHeight,
child: GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
context
.read<SplitViewCubit>()
.updateSubmissionPanelWidth(
details.globalPosition.dx,
);
},
child: Container(
width: _dragDotHeight,
height: _dragDotHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiary,
shape: BoxShape.circle,
),
child: Center(
child: FaIcon(
FontAwesomeIcons.gripLinesVertical,
color: Theme.of(context).colorScheme.onTertiary,
size: TextDimens.pt16,
),
),
),
),
),
],
],
);
},

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(
@ -155,7 +157,8 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
child: OverflowBar(
alignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
onPressed: () {
@ -182,7 +185,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

@ -1,7 +1,7 @@
import 'dart:async';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -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
@ -297,12 +297,15 @@ class _ParentItemSection extends StatelessWidget {
),
onLongPress: () {
if (item.url.isNotEmpty) {
FlutterClipboard.copy(item.url)
.whenComplete(() {
Clipboard.setData(
ClipboardData(text: item.url),
).whenComplete(() {
HapticFeedbackUtil.selection();
context.showSnackBar(
content: 'Link copied.',
);
if (context.mounted) {
context.showSnackBar(
content: 'Link copied.',
);
}
});
}
},

View File

@ -40,7 +40,7 @@ class MorePopupMenu extends StatelessWidget {
},
listener: (BuildContext context, VoteState voteState) {
if (voteState.status == VoteStatus.submitted) {
context.showSnackBar(content: 'Vote submitted successfully.');
context.showSnackBar(content: 'Vote submitted.');
} else if (voteState.status == VoteStatus.canceled) {
context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {

View File

@ -63,7 +63,7 @@ class _PollViewState extends State<PollView> with ItemActionMixin {
ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) {
showSnackBar(
content: 'Vote submitted successfully.',
content: 'Vote submitted.',
);
} else if (voteState.status == VoteStatus.canceled) {
showSnackBar(content: 'Vote canceled.');

View File

@ -1,9 +1,10 @@
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
@ -279,7 +280,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
return;
} else if (replyingTo is Story) {
final ItemScreenArgs args = ItemScreenArgs(item: replyingTo);
context.push('/${ItemScreen.routeName}', extra: args);
context.push(Paths.item.landing, extra: args);
expanded = false;
return;
}
@ -343,8 +344,8 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
fontSize: TextDimens.pt14,
),
),
onPressed: () => FlutterClipboard.copy(
replyingTo.text,
onPressed: () => Clipboard.setData(
ClipboardData(text: replyingTo.text),
).then((_) => HapticFeedbackUtil.selection()),
),
IconButton(

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