Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
6
.github/workflows/commit_check.yml
vendored
@ -3,8 +3,10 @@ name: Commit Guard
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- '!master'
|
||||
pull_request:
|
||||
# Run on any TARGET branches.
|
||||
branches: [ '**' ]
|
||||
|
||||
jobs:
|
||||
commit_check:
|
||||
@ -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
|
||||
|
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
|
||||
|
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"
|
||||
}
|
@ -3,5 +3,13 @@
|
||||
"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"
|
||||
"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"
|
||||
|
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'
|
||||
|
||||
|
@ -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
|
||||
@ -48,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
|
||||
@ -62,8 +62,8 @@ DEPENDENCIES:
|
||||
- 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`)
|
||||
@ -75,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`)
|
||||
|
||||
@ -97,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:
|
||||
@ -123,8 +123,8 @@ 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:
|
||||
@ -137,10 +137,10 @@ SPEC CHECKSUMS:
|
||||
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
@ -152,10 +152,10 @@ SPEC CHECKSUMS:
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
|
||||
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,13 +3,13 @@ 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/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';
|
||||
import 'package:responsive_builder/responsive_builder.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
@ -17,23 +17,24 @@ 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,
|
||||
@ -53,53 +54,53 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
on<StoryDownloaded>(onStoryDownloaded);
|
||||
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
|
||||
on<StoriesExitOfflineMode>(onExitOfflineMode);
|
||||
on<StoriesPageSizeChanged>(onPageSizeChanged);
|
||||
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>? _preferenceSubscription;
|
||||
static const int _smallPageSize = 10;
|
||||
static const int _largePageSize = 20;
|
||||
static const int _tabletSmallPageSize = 15;
|
||||
static const int _tabletLargePageSize = 25;
|
||||
static const int _pageSize = 30;
|
||||
|
||||
Future<void> onInitialize(
|
||||
StoriesInitialize event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
_preferenceSubscription ??= _preferenceCubit.stream
|
||||
.distinct((PreferenceState previous, PreferenceState next) {
|
||||
return previous.isComplexStoryTileEnabled ==
|
||||
next.isComplexStoryTileEnabled;
|
||||
})
|
||||
.debounceTime(AppDurations.oneSecond)
|
||||
.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.isComplexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
final HackerNewsDataSource dataSource = _preferenceCubit.state.dataSource;
|
||||
logInfo('data source: $dataSource');
|
||||
|
||||
if (pageSize != state.currentPageSize) {
|
||||
add(StoriesPageSizeChanged(pageSize: pageSize));
|
||||
}
|
||||
});
|
||||
final bool isComplexTile = _preferenceCubit.state.isComplexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
const StoriesState.init().copyWith(
|
||||
currentPageSize: pageSize,
|
||||
downloadStatus: state.downloadStatus,
|
||||
storiesDownloaded: state.storiesDownloaded,
|
||||
storiesToBeDownloaded: state.storiesToBeDownloaded,
|
||||
isOfflineReading: state.isOfflineReading,
|
||||
dataSource: dataSource,
|
||||
),
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
@ -109,41 +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)),
|
||||
ids: ids.sublist(0, min(_pageSize, ids.length)),
|
||||
)
|
||||
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
|
||||
.onDone(() => add(StoryLoadingCompleted(type: type)));
|
||||
} else {
|
||||
} 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),
|
||||
sequential: _preferenceCubit.state.isComplexStoryTileEnabled ||
|
||||
_preferenceCubit.state.isFaviconEnabled,
|
||||
ids: ids.sublist(0, min(_pageSize, ids.length)),
|
||||
sequential: true,
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).asFuture<void>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,62 +203,99 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
}
|
||||
|
||||
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
|
||||
if (state.statusByType[event.type] == Status.inProgress) return;
|
||||
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) {
|
||||
final List<int>? ids = state.storyIdsByType[type];
|
||||
if (ids == null) {
|
||||
logError('ids should not be null.');
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
type: type,
|
||||
to: Status.failure,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isOfflineReading) {
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
(Story story) => add(StoryLoaded(story: story, type: event.type)),
|
||||
)
|
||||
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
|
||||
} else {
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
(Story story) => add(StoryLoaded(story: story, type: event.type)),
|
||||
)
|
||||
.onDone(() => add(StoryLoadingCompleted(type: event.type)));
|
||||
}
|
||||
} 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,8 +311,11 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
final Story story = event.story;
|
||||
if (state.storiesByType[event.type]?.contains(story) ?? false) {
|
||||
_logger.d('story already exists.');
|
||||
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);
|
||||
@ -349,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) {
|
||||
@ -382,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);
|
||||
}
|
||||
|
||||
@ -399,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));
|
||||
@ -453,13 +523,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onPageSizeChanged(
|
||||
StoriesPageSizeChanged event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
Future<void> onExitOfflineMode(
|
||||
StoriesExitOfflineMode event,
|
||||
Emitter<StoriesState> emit,
|
||||
@ -517,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
|
||||
String get logIdentifier => '[StoriesBloc]';
|
||||
}
|
||||
|
@ -6,19 +6,35 @@ abstract class StoriesEvent extends Equatable {
|
||||
}
|
||||
|
||||
class LoadStories extends StoriesEvent {
|
||||
LoadStories({required this.type, this.isRefreshing = false});
|
||||
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,
|
||||
isRefreshing,
|
||||
useApi,
|
||||
];
|
||||
}
|
||||
|
||||
class StoriesInitialize extends StoriesEvent {
|
||||
StoriesInitialize({
|
||||
this.startup = false,
|
||||
});
|
||||
|
||||
final bool startup;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
@ -33,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 {
|
||||
@ -76,15 +104,6 @@ class StoriesEnterOfflineMode extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class StoriesPageSizeChanged extends StoriesEvent {
|
||||
StoriesPageSizeChanged({required this.pageSize});
|
||||
|
||||
final int pageSize;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[pageSize];
|
||||
}
|
||||
|
||||
class StoryLoaded extends StoriesEvent {
|
||||
StoryLoaded({required this.story, required this.type});
|
||||
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -88,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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
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 =
|
||||
@ -182,13 +179,13 @@ 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:
|
||||
@ -205,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,
|
||||
@ -280,11 +277,13 @@ 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:
|
||||
@ -301,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);
|
||||
}
|
||||
@ -385,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);
|
||||
});
|
||||
@ -412,7 +411,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
return;
|
||||
} else {
|
||||
await router.push(
|
||||
'/${ItemScreen.routeName}',
|
||||
Paths.item.landing,
|
||||
extra: ItemScreenArgs(item: parent),
|
||||
);
|
||||
|
||||
@ -435,7 +434,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
return;
|
||||
} else {
|
||||
await router.push(
|
||||
'/${ItemScreen.routeName}',
|
||||
Paths.item.landing,
|
||||
extra: ItemScreenArgs(item: parent),
|
||||
);
|
||||
|
||||
@ -732,4 +731,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => '[CommentsCubit]';
|
||||
}
|
||||
|
@ -6,20 +6,19 @@ 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,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
@ -28,7 +27,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -38,7 +36,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
late final StreamSubscription<String>? _usernameSubscription;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@ -184,7 +181,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);
|
||||
@ -215,6 +212,9 @@ class FavCubit extends Cubit<FavState> {
|
||||
_usernameSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => '[FavCubit]';
|
||||
}
|
||||
|
||||
extension on FavCubit {
|
||||
|
@ -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)
|
||||
@ -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,31 +101,17 @@ 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 {
|
||||
@ -274,4 +257,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
then?.call(res);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => '[NotificationCubit]';
|
||||
}
|
||||
|
@ -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]';
|
||||
}
|
||||
|
@ -89,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();
|
||||
|
@ -1,14 +1,16 @@
|
||||
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> {
|
||||
RemoteConfigCubit({RemoteConfigRepository? remoteConfigRepository})
|
||||
: _remoteConfigRepository =
|
||||
class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> with Loggable {
|
||||
RemoteConfigCubit({
|
||||
RemoteConfigRepository? remoteConfigRepository,
|
||||
}) : _remoteConfigRepository =
|
||||
remoteConfigRepository ?? locator.get<RemoteConfigRepository>(),
|
||||
super(RemoteConfigState.init()) {
|
||||
init();
|
||||
@ -21,7 +23,10 @@ class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> {
|
||||
.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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -35,4 +40,7 @@ class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> {
|
||||
Map<String, dynamic>? toJson(RemoteConfigState state) {
|
||||
return state.data;
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => 'RemoteConfigCubit';
|
||||
}
|
||||
|
@ -10,6 +10,48 @@ final class RemoteConfigState extends Equatable {
|
||||
@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:
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -1,40 +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()) {
|
||||
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.
|
||||
@ -44,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';
|
||||
@ -104,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;
|
||||
@ -176,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,
|
||||
@ -351,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:
|
||||
|
@ -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 {
|
||||
|
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;
|
||||
}
|
@ -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';
|
||||
|
@ -28,6 +28,7 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
StoryMarkingModePreference(),
|
||||
AppColorPreference(),
|
||||
DateFormatPreference(),
|
||||
HackerNewsDataSourcePreference(),
|
||||
const TextScaleFactorPreference(),
|
||||
|
||||
/// Order of items below matters and
|
||||
@ -164,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}) {
|
||||
@ -586,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));
|
||||
@ -425,6 +422,9 @@ class HackerNewsRepository {
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => '[HackerNewsRepository]';
|
||||
}
|
||||
|
||||
extension on Map<String, dynamic> {
|
||||
|
@ -1,26 +1,36 @@
|
||||
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>[
|
||||
@ -29,11 +39,22 @@ class HackerNewsWebRepository {
|
||||
],
|
||||
),
|
||||
_remoteConfigCubit =
|
||||
remoteConfigCubit ?? locator.get<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': '*/*',
|
||||
@ -41,6 +62,195 @@ class HackerNewsWebRepository {
|
||||
'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 =
|
||||
@ -71,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();
|
||||
}
|
||||
@ -149,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();
|
||||
}
|
||||
@ -295,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]';
|
||||
}
|
||||
|
@ -1,18 +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 {
|
||||
final Response<dynamic> response = await _dio.get(
|
||||
'https://raw.githubusercontent.com/Livinglist/Hacki/master/assets/remote-config.json',
|
||||
);
|
||||
final String data = response.data as String? ?? '';
|
||||
final Map<String, dynamic> json = jsonDecode(data) as Map<String, dynamic>;
|
||||
return json;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:path/path.dart';
|
||||
@ -13,7 +14,7 @@ import 'package:sembast/sembast_io.dart';
|
||||
/// 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 +45,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;
|
||||
@ -54,6 +58,9 @@ class SembastRepository {
|
||||
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;
|
||||
@ -251,4 +258,7 @@ class SembastRepository {
|
||||
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();
|
||||
@ -217,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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -319,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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
@ -157,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: () {
|
||||
|
@ -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';
|
||||
@ -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(
|
||||
|
50
lib/screens/log/log_screen.dart
Normal file
@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/extensions/context_extension.dart';
|
||||
import 'package:hacki/utils/haptic_feedback_util.dart';
|
||||
import 'package:hacki/utils/log_util.dart';
|
||||
|
||||
class LogScreen extends StatelessWidget {
|
||||
const LogScreen({super.key});
|
||||
|
||||
static const String routeName = 'log';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<String>>(
|
||||
future: LogUtil.exportLogAsStrings(),
|
||||
builder: (BuildContext context, AsyncSnapshot<List<String>> snapshot) {
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).canvasColor.withOpacity(0.6),
|
||||
elevation: 0,
|
||||
actions: <Widget>[
|
||||
if (snapshot.data != null)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final String data = snapshot.data!.reduce(
|
||||
(
|
||||
String lhs,
|
||||
String rhs,
|
||||
) =>
|
||||
lhs + rhs,
|
||||
);
|
||||
Clipboard.setData(ClipboardData(text: data))
|
||||
.whenComplete(HapticFeedbackUtil.selection);
|
||||
context.showSnackBar(content: 'Log copied.');
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
...?snapshot.data?.map(Text.new),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/paths.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -316,7 +317,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
selected: false,
|
||||
onSelected: (bool val) {
|
||||
if (authState.isLoggedIn) {
|
||||
context.push('/${SubmitScreen.routeName}');
|
||||
context.push(Paths.item.submit);
|
||||
} else {
|
||||
showSnackBar(
|
||||
content: 'You need to log in first.',
|
||||
|
@ -59,7 +59,9 @@ class _QrCodeScannerScreenState extends State<QrCodeScannerScreen> {
|
||||
});
|
||||
controller.scannedDataStream.listen((Barcode scanData) {
|
||||
controller.stopCamera();
|
||||
context.pop(scanData.code);
|
||||
if (mounted) {
|
||||
context.pop(scanData.code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,7 @@ class EnterOfflineModeListTile extends StatelessWidget {
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
return SwitchListTile(
|
||||
value: state.isOfflineReading,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
title: const Text('Offline Mode'),
|
||||
onChanged: (bool value) {
|
||||
HapticFeedbackUtil.light();
|
||||
|
@ -32,7 +32,9 @@ class InboxView extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (unreadCommentsIds.isNotEmpty)
|
||||
if (context.read<NotificationCubit>().state.status !=
|
||||
Status.inProgress &&
|
||||
unreadCommentsIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: onMarkAllAsReadTapped,
|
||||
child: const Text('Mark all as read'),
|
||||
|
@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class OfflineListTile extends StatelessWidget {
|
||||
const OfflineListTile({super.key});
|
||||
@ -18,7 +18,7 @@ class OfflineListTile extends StatelessWidget {
|
||||
listener: (BuildContext context, StoriesState state) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.failure ||
|
||||
state.downloadStatus == StoriesDownloadStatus.finished) {
|
||||
Wakelock.disable();
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
},
|
||||
buildWhen: (StoriesState previous, StoriesState current) =>
|
||||
@ -86,15 +86,18 @@ class OfflineListTile extends StatelessWidget {
|
||||
),
|
||||
).then((bool? abortDownloading) {
|
||||
if (abortDownloading ?? false) {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(StoriesCancelDownload());
|
||||
WakelockPlus.enable();
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<StoriesBloc>().add(StoriesCancelDownload());
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Connectivity()
|
||||
.checkConnectivity()
|
||||
.then((List<ConnectivityResult> res) {
|
||||
if (!res.contains(ConnectivityResult.none)) {
|
||||
if (!res.contains(ConnectivityResult.none) && context.mounted) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
@ -117,12 +120,13 @@ class OfflineListTile extends StatelessWidget {
|
||||
),
|
||||
).then((bool? includeWebPage) {
|
||||
if (includeWebPage != null) {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(
|
||||
StoriesDownload(
|
||||
includingWebPage: includeWebPage,
|
||||
),
|
||||
);
|
||||
WakelockPlus.enable();
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<StoriesBloc>().add(
|
||||
StoriesDownload(includingWebPage: includeWebPage),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -2,9 +2,9 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_email_sender/flutter_email_sender.dart';
|
||||
@ -16,13 +16,12 @@ import 'package:hacki/blocs/blocs.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';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/profile/models/page_type.dart';
|
||||
import 'package:hacki/screens/profile/qr_code_scanner_screen.dart';
|
||||
import 'package:hacki/screens/profile/qr_code_view_screen.dart';
|
||||
import 'package:hacki/screens/profile/widgets/enter_offline_mode_list_tile.dart';
|
||||
import 'package:hacki/screens/profile/widgets/offline_list_tile.dart';
|
||||
import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
|
||||
@ -50,7 +49,7 @@ class Settings extends StatefulWidget {
|
||||
State<Settings> createState() => _SettingsState();
|
||||
}
|
||||
|
||||
class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
@ -84,119 +83,174 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
const SizedBox(
|
||||
height: Dimens.pt8,
|
||||
),
|
||||
Flex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
overflowSpacing: Dimens.pt12,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Row(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt16,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text('Default fetch mode'),
|
||||
DropdownMenu<FetchMode>(
|
||||
initialSelection: preferenceState.fetchMode,
|
||||
dropdownMenuEntries: FetchMode.values
|
||||
.map(
|
||||
(FetchMode val) =>
|
||||
DropdownMenuEntry<FetchMode>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
const Text('Default fetch mode'),
|
||||
DropdownMenu<FetchMode>(
|
||||
initialSelection: preferenceState.fetchMode,
|
||||
dropdownMenuEntries: FetchMode.values
|
||||
.map(
|
||||
(FetchMode val) =>
|
||||
DropdownMenuEntry<FetchMode>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (FetchMode? fetchMode) {
|
||||
if (fetchMode != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
FetchModePreference(
|
||||
val: fetchMode.index,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (FetchMode? fetchMode) {
|
||||
if (fetchMode != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
FetchModePreference(
|
||||
val: fetchMode.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text('Default comments order'),
|
||||
DropdownMenu<CommentsOrder>(
|
||||
initialSelection: preferenceState.order,
|
||||
dropdownMenuEntries: CommentsOrder.values
|
||||
.map(
|
||||
(CommentsOrder val) =>
|
||||
DropdownMenuEntry<CommentsOrder>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (CommentsOrder? order) {
|
||||
if (order != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
CommentsOrderPreference(
|
||||
val: order.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt16,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt16,
|
||||
right: Dimens.pt16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text('Default comments order'),
|
||||
DropdownMenu<CommentsOrder>(
|
||||
initialSelection: preferenceState.order,
|
||||
dropdownMenuEntries: CommentsOrder.values
|
||||
.map(
|
||||
(CommentsOrder val) =>
|
||||
DropdownMenuEntry<CommentsOrder>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (CommentsOrder? order) {
|
||||
if (order != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
CommentsOrderPreference(
|
||||
val: order.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
),
|
||||
Row(
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
overflowSpacing: Dimens.pt12,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt16,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'Date time display of comments',
|
||||
),
|
||||
DropdownMenu<DateDisplayFormat>(
|
||||
initialSelection:
|
||||
preferenceState.displayDateFormat,
|
||||
dropdownMenuEntries: DateDisplayFormat.values
|
||||
.map(
|
||||
(DateDisplayFormat val) =>
|
||||
DropdownMenuEntry<DateDisplayFormat>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (DateDisplayFormat? order) {
|
||||
if (order != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
DateFormatPreference(
|
||||
val: order.index,
|
||||
),
|
||||
);
|
||||
DateDisplayFormat.clearCache();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'Date time display of comments',
|
||||
),
|
||||
DropdownMenu<DateDisplayFormat>(
|
||||
initialSelection: preferenceState.displayDateFormat,
|
||||
dropdownMenuEntries: DateDisplayFormat.values
|
||||
.map(
|
||||
(DateDisplayFormat val) =>
|
||||
DropdownMenuEntry<DateDisplayFormat>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (DateDisplayFormat? order) {
|
||||
if (order != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
DateFormatPreference(
|
||||
val: order.index,
|
||||
),
|
||||
);
|
||||
DateDisplayFormat.clearCache();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt16,
|
||||
right: Dimens.pt16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'Data source',
|
||||
),
|
||||
BlocSelector<StoriesBloc, StoriesState, bool>(
|
||||
selector: (StoriesState state) =>
|
||||
state.statusByType.values.any(
|
||||
(Status status) => status == Status.inProgress,
|
||||
),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
bool isInProgress,
|
||||
) {
|
||||
return DropdownMenu<HackerNewsDataSource>(
|
||||
/// Make sure no stories are being fetched
|
||||
/// before switching data source.
|
||||
enabled: !isInProgress,
|
||||
initialSelection: preferenceState.dataSource,
|
||||
dropdownMenuEntries:
|
||||
HackerNewsDataSource.values
|
||||
.map(
|
||||
(HackerNewsDataSource val) =>
|
||||
DropdownMenuEntry<
|
||||
HackerNewsDataSource>(
|
||||
value: val,
|
||||
label: val.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (HackerNewsDataSource? source) {
|
||||
if (source != null) {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<PreferenceCubit>().update(
|
||||
HackerNewsDataSourcePreference(
|
||||
val: source.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -341,6 +395,15 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
),
|
||||
onTap: showClearCacheDialog,
|
||||
),
|
||||
if (preferenceState.isDevModeEnabled)
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Logs',
|
||||
),
|
||||
onTap: () {
|
||||
context.go(Paths.log.landing);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('nothing interesting here.'),
|
||||
@ -778,7 +841,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
error.logError(stackTrace: stackTrace);
|
||||
logError(error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -901,8 +964,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
}
|
||||
|
||||
Future<void> onImportFavoritesTapped(FavCubit favCubit) async {
|
||||
final String? res =
|
||||
await router.push('/${QrCodeScannerScreen.routeName}') as String?;
|
||||
final String? res = await router.push(Paths.qrCode.scanner) as String?;
|
||||
final List<int>? ids =
|
||||
res?.split('\n').map(int.tryParse).whereType<int>().toList();
|
||||
if (ids == null) return;
|
||||
@ -926,18 +988,18 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
switch (destination) {
|
||||
case ExportDestination.qrCode:
|
||||
await router.push(
|
||||
'/${QrCodeViewScreen.routeName}',
|
||||
Paths.qrCode.viewer,
|
||||
extra: allFavoritesStr,
|
||||
);
|
||||
case ExportDestination.clipBoard:
|
||||
try {
|
||||
await FlutterClipboard.copy(allFavoritesStr)
|
||||
await Clipboard.setData(ClipboardData(text: allFavoritesStr))
|
||||
.whenComplete(HapticFeedbackUtil.selection);
|
||||
showSnackBar(
|
||||
content: 'Ids of favorites have been copied to clipboard.',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
error.logError(stackTrace: stackTrace);
|
||||
logError(error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -965,7 +1027,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
context.read<FavCubit>().removeAll();
|
||||
showSnackBar(content: 'All favorites have been removed.');
|
||||
} catch (error, stackTrace) {
|
||||
error.logError(stackTrace: stackTrace);
|
||||
logError(error, stackTrace: stackTrace);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
@ -980,4 +1042,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get logIdentifier => '[Settings]';
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
export 'home/home_screen.dart';
|
||||
export 'item/item_screen.dart';
|
||||
export 'log/log_screen.dart';
|
||||
export 'profile/profile_screen.dart';
|
||||
export 'profile/qr_code_scanner_screen.dart';
|
||||
export 'profile/qr_code_view_screen.dart';
|
||||
|
@ -67,7 +67,8 @@ class PostedByFilterChip extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(
|
||||
right: Dimens.pt12,
|
||||
),
|
||||
child: ButtonBar(
|
||||
child: OverflowBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => context.pop(filter?.author),
|
||||
|
@ -39,7 +39,7 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
context.pop();
|
||||
HapticFeedbackUtil.light();
|
||||
showSnackBar(
|
||||
content: 'Post submitted successfully.',
|
||||
content: 'Post submitted.',
|
||||
);
|
||||
} else if (state.status == Status.failure) {
|
||||
showErrorSnackBar();
|
||||
@ -88,7 +88,7 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
);
|
||||
},
|
||||
).then((bool? value) {
|
||||
if (value ?? false) {
|
||||
if (context.mounted && (value ?? false)) {
|
||||
context.pop();
|
||||
}
|
||||
});
|
||||
@ -147,7 +147,7 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
);
|
||||
},
|
||||
).then((bool? value) {
|
||||
if (value ?? false) {
|
||||
if (context.mounted && (value ?? false)) {
|
||||
context.read<SubmitCubit>().submit();
|
||||
}
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
@ -10,6 +11,7 @@ import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
@ -417,18 +419,28 @@ class CommentTile extends StatelessWidget {
|
||||
preferenceCubit.state.isAutoScrollEnabled) {
|
||||
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
|
||||
final List<Comment> comments = commentsCubit.state.comments;
|
||||
final int indexOfNextComment = comments.indexOf(comment) + 1;
|
||||
if (indexOfNextComment < comments.length) {
|
||||
Future<void>.delayed(
|
||||
AppDurations.ms300,
|
||||
() {
|
||||
commentsCubit.itemScrollController.scrollTo(
|
||||
index: indexOfNextComment,
|
||||
alignment: 0.1,
|
||||
duration: AppDurations.ms300,
|
||||
);
|
||||
},
|
||||
);
|
||||
final int indexOfComment = comments.indexOf(comment);
|
||||
if (indexOfComment < comments.length) {
|
||||
final double? leadingEdge =
|
||||
commentsCubit.itemPositionsListener.itemPositions.value
|
||||
.singleWhereOrNull(
|
||||
(ItemPosition e) => e.index - 1 == indexOfComment,
|
||||
)
|
||||
?.itemLeadingEdge;
|
||||
final bool willBeOutsideOfScreen =
|
||||
leadingEdge != null && leadingEdge < 0.1;
|
||||
if (willBeOutsideOfScreen) {
|
||||
Future<void>.delayed(
|
||||
AppDurations.ms200,
|
||||
() {
|
||||
commentsCubit.itemScrollController.scrollTo(
|
||||
index: indexOfComment + 1,
|
||||
alignment: 0.15,
|
||||
duration: AppDurations.ms300,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,7 +117,9 @@ class _CountDownReminderState extends State<CountdownReminder>
|
||||
final ItemScreenArgs args = ItemScreenArgs(item: story);
|
||||
goToItemScreen(args: args);
|
||||
|
||||
context.read<ReminderCubit>().removeLastReadStoryId();
|
||||
if (context.mounted) {
|
||||
context.read<ReminderCubit>().removeLastReadStoryId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
83
lib/screens/widgets/download_progress_reminder.dart
Normal file
@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class DownloadProgressReminder extends StatefulWidget {
|
||||
const DownloadProgressReminder({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadProgressReminder> createState() =>
|
||||
_DownloadProgressReminderState();
|
||||
}
|
||||
|
||||
class _DownloadProgressReminderState extends State<DownloadProgressReminder>
|
||||
with SingleTickerProviderStateMixin, ItemActionMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<StoriesBloc, StoriesState,
|
||||
(int, int, StoriesDownloadStatus)>(
|
||||
selector: (StoriesState state) {
|
||||
return (
|
||||
state.storiesDownloaded,
|
||||
state.storiesToBeDownloaded,
|
||||
state.downloadStatus
|
||||
);
|
||||
},
|
||||
builder: (BuildContext context, (int, int, StoriesDownloadStatus) state) {
|
||||
final int storiesDownloaded = state.$1;
|
||||
final int storiesToBeDownloaded = state.$2;
|
||||
final StoriesDownloadStatus status = state.$3;
|
||||
final double progress = storiesToBeDownloaded == 0
|
||||
? 0
|
||||
: storiesDownloaded / storiesToBeDownloaded;
|
||||
final bool isVisible = status == StoriesDownloadStatus.downloading;
|
||||
return Visibility(
|
||||
visible: isVisible,
|
||||
child: FadeIn(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(
|
||||
Dimens.pt4,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt12,
|
||||
top: Dimens.pt10,
|
||||
right: Dimens.pt10,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Downloading all stories ($storiesDownloaded/$storiesToBeDownloaded)',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: TextDimens.pt12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -115,37 +115,45 @@ class LinkView extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutHeight,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUri ?? '',
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
cacheKey: imageUri,
|
||||
errorWidget: (_, __, ___) {
|
||||
if (url.isEmpty) {
|
||||
return FadeIn(
|
||||
child: imageUri == null && url.isEmpty
|
||||
? FadeIn(
|
||||
child: Center(
|
||||
child: _HackerNewsImage(
|
||||
height: layoutHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: Constants.favicon(url),
|
||||
fit: BoxFit.scaleDown,
|
||||
cacheKey: iconUri,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUri ?? Constants.favicon(url),
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
cacheKey: imageUri,
|
||||
errorWidget: (_, __, ___) {
|
||||
return const FadeIn(
|
||||
child: Icon(
|
||||
Icons.public,
|
||||
size: Dimens.pt20,
|
||||
if (url.isEmpty) {
|
||||
return FadeIn(
|
||||
child: Center(
|
||||
child: _HackerNewsImage(
|
||||
height: layoutHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: Constants.favicon(url),
|
||||
fit: BoxFit.scaleDown,
|
||||
cacheKey: iconUri,
|
||||
errorWidget: (_, __, ___) {
|
||||
return const FadeIn(
|
||||
child: Icon(
|
||||
Icons.public,
|
||||
size: Dimens.pt20,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@ -58,7 +58,7 @@ class OfflineBanner extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
).then((bool? value) {
|
||||
if (value ?? false) {
|
||||
if (context.mounted && (value ?? false)) {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesExitOfflineMode());
|
||||
|
@ -3,7 +3,6 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -67,8 +66,8 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
}
|
||||
},
|
||||
buildWhen: (StoriesState previous, StoriesState current) =>
|
||||
(current.currentPageByType[storyType] == 0 &&
|
||||
previous.currentPageByType[storyType] == 0) ||
|
||||
(current.currentPageByType[storyType] == 1 &&
|
||||
previous.currentPageByType[storyType] == 1) ||
|
||||
(previous.storiesByType[storyType]!.length !=
|
||||
current.storiesByType[storyType]!.length) ||
|
||||
(previous.readStoriesIds.length !=
|
||||
@ -76,13 +75,6 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
(previous.statusByType[widget.storyType] !=
|
||||
current.statusByType[widget.storyType]),
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
bool shouldShowLoadButton() {
|
||||
return preferenceState.isManualPaginationEnabled &&
|
||||
state.statusByType[widget.storyType] == Status.success &&
|
||||
(state.storiesByType[widget.storyType]?.length ?? 0) <
|
||||
(state.storyIdsByType[widget.storyType]?.length ?? 0);
|
||||
}
|
||||
|
||||
return ItemsListView<Story>(
|
||||
showOfflineBanner: true,
|
||||
markReadStories: preferenceState.isMarkReadStoriesEnabled,
|
||||
@ -114,38 +106,33 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: state.isOfflineReading ? null : header,
|
||||
loadStyle: LoadStyle.HideAlways,
|
||||
footer: Center(
|
||||
child: AnimatedCrossFade(
|
||||
alignment: Alignment.center,
|
||||
crossFadeState: shouldShowLoadButton()
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: AppDurations.ms300,
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt48,
|
||||
right: Dimens.pt48,
|
||||
top: Dimens.pt36,
|
||||
bottom: Dimens.pt12,
|
||||
),
|
||||
child: OutlinedButton(
|
||||
onPressed: loadMoreStories,
|
||||
style: ButtonStyle(
|
||||
minimumSize: WidgetStateProperty.all(
|
||||
const Size(double.infinity, Dimens.pt48),
|
||||
footer: preferenceState.isManualPaginationEnabled &&
|
||||
state.statusByType[widget.storyType] == Status.success
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt48,
|
||||
right: Dimens.pt48,
|
||||
top: Dimens.pt36,
|
||||
bottom: Dimens.pt12,
|
||||
),
|
||||
foregroundColor: WidgetStateColor.resolveWith(
|
||||
(_) => Theme.of(context).colorScheme.onSurface,
|
||||
child: OutlinedButton(
|
||||
onPressed: loadMoreStories,
|
||||
style: ButtonStyle(
|
||||
minimumSize: WidgetStateProperty.all(
|
||||
const Size(double.infinity, Dimens.pt48),
|
||||
),
|
||||
foregroundColor: WidgetStateColor.resolveWith(
|
||||
(_) => Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 1}''',
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 2}''',
|
||||
),
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
onMoreTapped: onMoreTapped,
|
||||
itemBuilder: (Widget child, Story story) {
|
||||
return Slidable(
|
||||
|
@ -10,6 +10,7 @@ export 'custom_dropdown_menu.dart';
|
||||
export 'custom_linkify/custom_linkify.dart';
|
||||
export 'custom_tab_bar.dart';
|
||||
export 'device_gesture_wrapper.dart';
|
||||
export 'download_progress_reminder.dart';
|
||||
export 'item_text.dart';
|
||||
export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
|
@ -7,7 +7,6 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:path_provider_android/path_provider_android.dart';
|
||||
import 'package:path_provider_foundation/path_provider_foundation.dart';
|
||||
import 'package:shared_preferences_android/shared_preferences_android.dart';
|
||||
@ -35,18 +34,12 @@ abstract class Fetcher {
|
||||
static const int _subscriptionUpperLimit = 15;
|
||||
|
||||
static Future<void> fetchReplies() async {
|
||||
final Logger logger = Logger();
|
||||
final PreferenceRepository preferenceRepository =
|
||||
PreferenceRepository(logger: logger);
|
||||
|
||||
final AuthRepository authRepository = AuthRepository(
|
||||
preferenceRepository: preferenceRepository,
|
||||
logger: logger,
|
||||
);
|
||||
|
||||
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
|
||||
final PreferenceRepository preferenceRepository = PreferenceRepository();
|
||||
final AuthRepository authRepository =
|
||||
AuthRepository(preferenceRepository: preferenceRepository);
|
||||
final SembastRepository sembastRepository = SembastRepository();
|
||||
|
||||
final HackerNewsRepository hackerNewsRepository =
|
||||
HackerNewsRepository(sembastRepository: sembastRepository);
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
|
@ -85,6 +85,7 @@ class WebAnalyzer {
|
||||
RegExp('(title|icon|description|image)', caseSensitive: false);
|
||||
static final RegExp _lineReg = RegExp(r'[\n\r]| |>');
|
||||
static final RegExp _spaceReg = RegExp(r'\s+');
|
||||
static const String _logPrefix = '[WebAnalyzer]';
|
||||
|
||||
static bool isEmpty(String? str) {
|
||||
return !isNotEmpty(str);
|
||||
@ -120,7 +121,7 @@ class WebAnalyzer {
|
||||
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
fetched mem cached metadata using key $key for $story:
|
||||
$_logPrefix fetched mem cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
return info;
|
||||
@ -168,7 +169,7 @@ ${info.toJson()}
|
||||
/// [5] If there is file cache, move it to mem cache for later retrieval.
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
fetched file cached metadata using key $key for $story:
|
||||
$_logPrefix fetched file cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
cacheMap[key] = info;
|
||||
@ -189,7 +190,7 @@ ${info.toJson()}
|
||||
if (info is WebInfo) {
|
||||
locator
|
||||
.get<Logger>()
|
||||
.d('caching metadata using key $key for $story.');
|
||||
.d('$_logPrefix caching metadata using key $key for $story.');
|
||||
unawaited(
|
||||
locator.get<SembastRepository>().cacheMetadata(
|
||||
key: key,
|
||||
|
@ -13,6 +13,7 @@ abstract class Dimens {
|
||||
static const double pt18 = 18;
|
||||
static const double pt20 = 20;
|
||||
static const double pt24 = 24;
|
||||
static const double pt30 = 30;
|
||||
static const double pt36 = 36;
|
||||
static const double pt40 = 40;
|
||||
static const double pt48 = 48;
|
||||
|
@ -5,12 +5,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.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';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart'
|
||||
show ItemScreen, ItemScreenArgs, WebViewScreen;
|
||||
import 'package:hacki/screens/screens.dart' show ItemScreenArgs;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
abstract class LinkUtil {
|
||||
@ -40,7 +40,7 @@ abstract class LinkUtil {
|
||||
.then((bool cached) {
|
||||
if (cached) {
|
||||
router.push(
|
||||
'/${WebViewScreen.routeName}',
|
||||
Paths.webView.landing,
|
||||
extra: link,
|
||||
);
|
||||
}
|
||||
@ -58,7 +58,7 @@ abstract class LinkUtil {
|
||||
.then((Item? item) {
|
||||
if (item != null) {
|
||||
router.push(
|
||||
'/${ItemScreen.routeName}',
|
||||
Paths.item.landing,
|
||||
extra: ItemScreenArgs(item: item),
|
||||
);
|
||||
}
|
||||
@ -70,7 +70,7 @@ abstract class LinkUtil {
|
||||
final Uri uri = Uri.parse(link);
|
||||
|
||||
canLaunchUrl(uri).then((bool val) {
|
||||
if (val) {
|
||||
if (val && context.mounted) {
|
||||
if (link.contains('http')) {
|
||||
if (Platform.isAndroid &&
|
||||
context.read<PreferenceCubit>().state.isCustomTabEnabled ==
|
||||
|