Compare commits
64 Commits
Author | SHA1 | Date | |
---|---|---|---|
3e3941380d | |||
bbed4e0e75 | |||
a4ae6a20e1 | |||
3413b1686d | |||
c24670d5d8 | |||
a50c456390 | |||
915eb47ab6 | |||
c442a5d2e7 | |||
fbedf327ee | |||
45c684b774 | |||
b6015ae6ca | |||
b240dccc8e | |||
949562a34a | |||
9d8af331c7 | |||
031ff7519d | |||
62bab9d781 | |||
b9ff92a27b | |||
0332cd531d | |||
b76c5dd64c | |||
7325a08002 | |||
78bb1c6a6c | |||
c34ffe22da | |||
a621dc0291 | |||
88a12d3339 | |||
50d4cdfad9 | |||
366a461c96 | |||
3f1e9d0fff | |||
d09c10b3f8 | |||
fd5730e189 | |||
c9cc6a5df0 | |||
8d4b232097 | |||
8af643e584 | |||
70a56f4ade | |||
c685f33f99 | |||
518608893d | |||
856efa7c14 | |||
d1957ffb82 | |||
553a37961d | |||
bade5b4356 | |||
ab43d1a2c4 | |||
cf5c0b3263 | |||
d7295afa41 | |||
1ecddf9d5b | |||
479903ed77 | |||
1e4c10e819 | |||
473a65427a | |||
ad6ccc9376 | |||
995dfed85d | |||
0e74f88a8d | |||
c2e6d7ea98 | |||
e46432b86c | |||
9763a94e1d | |||
077fcbf9da | |||
9cdb6b7383 | |||
d01524020d | |||
fb2072676e | |||
162c7a2689 | |||
e218527953 | |||
3dddfa66cf | |||
3fd0a9a1ea | |||
7a35fe451d | |||
575ba8c2ef | |||
e82998bb32 | |||
3389e98861 |
8
.github/workflows/commit_check.yml
vendored
@ -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
@ -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 }}
|
9
.github/workflows/publish_ios.yml
vendored
@ -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:
|
||||
|
@ -11,3 +11,4 @@ linter:
|
||||
analyzer:
|
||||
exclude:
|
||||
- "submodules/**"
|
||||
- "scripts/**"
|
||||
|
@ -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]
|
||||
|
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
After Width: | Height: | Size: 69 B |
@ -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>
|
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
After Width: | Height: | Size: 69 B |
@ -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>
|
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
After Width: | Height: | Size: 69 B |
@ -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>
|
||||
|
BIN
android/app/src/main/res/drawable/background.png
Normal file
After Width: | Height: | Size: 69 B |
@ -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>
|
||||
|
19
android/app/src/main/res/values-night-v31/styles.xml
Normal 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>
|
@ -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
|
||||
|
19
android/app/src/main/res/values-v31/styles.xml
Normal 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>
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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"
|
BIN
assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
Normal file
15
assets/remote-config-dev.json
Normal 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
@ -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"
|
||||
}
|
@ -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"
|
||||
|
2
fastlane/metadata/android/en-US/changelogs/145.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Favicon in mini story tile.
|
||||
- UX improvements.
|
1
fastlane/metadata/android/en-US/changelogs/148.txt
Normal file
@ -0,0 +1 @@
|
||||
- UX improvements.
|
1
fastlane/metadata/android/en-US/changelogs/149.txt
Normal file
@ -0,0 +1 @@
|
||||
- Improved tablet mode, you can now resize submission panel.
|
@ -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',
|
||||
);
|
||||
|
@ -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:")
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)";
|
||||
|
@ -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))
|
||||
|
||||
|
22
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
vendored
Normal file
After Width: | Height: | Size: 69 B |
@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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});
|
||||
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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
@ -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';
|
||||
}
|
@ -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]';
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
||||
|
46
lib/cubits/remote_config/remote_config_cubit.dart
Normal 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';
|
||||
}
|
101
lib/cubits/remote_config/remote_config_state.dart
Normal 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];
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
98
lib/extensions/loggable.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
|
@ -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 {
|
||||
|
@ -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});
|
||||
|
||||
|
8
lib/models/hacker_news_data_source.dart
Normal file
@ -0,0 +1,8 @@
|
||||
enum HackerNewsDataSource {
|
||||
api('API'),
|
||||
web('Web');
|
||||
|
||||
const HackerNewsDataSource(this.description);
|
||||
|
||||
final String description;
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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' : ''}''';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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> {
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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]';
|
||||
}
|
||||
|
32
lib/repositories/remote_config_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -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) {
|
||||
|
@ -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.');
|
||||
|
@ -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(
|
||||
|