Compare commits

...

32 Commits

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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
{
"athingComtrSelector": "#hnmain > tbody > tr > td > table > tbody > .athing.comtr",
"commentTextSelector": "td > table > tbody > tr > td.default > div.comment > div.commtext",
"commentHeadSelector": "td > table > tbody > tr > td.default > div > span > a",
"commentAgeSelector": "td > table > tbody > tr > td.default > div > span > span.age",
"commentIndentSelector": "td > table > tbody > tr > td.ind",
"storySelector": "#hnmain > tbody > tr > td > table > tbody > .athing",
"subtextSelector": "#hnmain > tbody > tr > td > table > tbody > tr > .subtext",
"titlelineSelector": ".title > .titleline > a",
"pointSelector": ".subline > .score",
"userSelector": ".subline > .hnuser",
"ageSelector": ".subline > .age",
"cmtCountSelector": ".subline > a",
"moreLinkSelector": "#hnmain > tbody > tr:nth-child(3) > td > table > tbody > tr > td.title > a"
}

View File

@ -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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

View File

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

View File

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

View File

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

View File

@ -3,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]';
}

View File

@ -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});

View File

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

View File

@ -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);

View File

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

View File

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

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

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

View File

@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
@ -17,13 +18,12 @@ import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
class CommentsCubit extends Cubit<CommentsState> with Loggable {
CommentsCubit({
required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
@ -37,7 +37,6 @@ class CommentsCubit extends Cubit<CommentsState> {
SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache,
@ -50,7 +49,6 @@ class CommentsCubit extends Cubit<CommentsState> {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
isOfflineReading: isOfflineReading,
@ -68,7 +66,6 @@ class CommentsCubit extends Cubit<CommentsState> {
final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
@ -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]';
}

View File

@ -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 {

View File

@ -7,20 +7,19 @@ import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'notification_state.dart';
class NotificationCubit extends Cubit<NotificationState> {
class NotificationCubit extends Cubit<NotificationState> with Loggable {
NotificationCubit({
required AuthBloc authBloc,
required PreferenceCubit preferenceCubit,
HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository,
Logger? logger,
}) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit,
_hackerNewsRepository =
@ -29,7 +28,6 @@ class NotificationCubit extends Cubit<NotificationState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(NotificationState.init()) {
_authBloc.stream
.map((AuthState event) => event.username)
@ -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]';
}

View File

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

View File

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

View File

@ -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';
}

View File

@ -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:

View File

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

View File

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

View File

@ -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]';
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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';
}

View File

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

View File

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

View File

@ -2,30 +2,27 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
/// [HackerNewsRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User].
///
/// You can learn more about the Hacker News API at
/// https://github.com/HackerNews/API.
class HackerNewsRepository {
class HackerNewsRepository with Loggable {
HackerNewsRepository({
FirebaseClient? firebaseClient,
SembastRepository? sembastRepository,
Logger? logger,
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>();
sembastRepository ?? locator.get<SembastRepository>();
final FirebaseClient _firebaseClient;
final SembastRepository _sembastRepository;
final Logger _logger;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
@ -118,7 +115,7 @@ class HackerNewsRepository {
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.get('$_baseUrl${type.apiPathParam}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
@ -246,7 +243,7 @@ class HackerNewsRepository {
return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
logError(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
@ -284,7 +281,7 @@ class HackerNewsRepository {
return comment;
}).onError((Object? error, StackTrace stackTrace) {
_logger.e(error, stackTrace: stackTrace);
logError(error, stackTrace: stackTrace);
return _sembastRepository
.getCachedComment(id: id)
.then((Comment? value) => value?.copyWith(level: level));
@ -425,6 +422,9 @@ class HackerNewsRepository {
return json;
}
@override
String get logIdentifier => '[HackerNewsRepository]';
}
extension on Map<String, dynamic> {

View File

@ -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';
}

View File

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

View File

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

View File

@ -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;
}
}
}

View File

@ -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]';
}

View File

@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/config/paths.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
@ -22,7 +21,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
@ -36,7 +34,7 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin {
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin, Loggable {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
@ -49,7 +47,7 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
logInfo('resetting comments in CommentCache');
Future<void>.delayed(
AppDurations.ms500,
locator.get<CommentCache>().resetComments,
@ -98,17 +96,6 @@ class _HomeScreenState extends State<HomeScreen>
tabController = TabController(length: tabLength, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
tabController.dispose();
@ -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]';
}

View File

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

View File

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

View File

@ -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: () {

View File

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

View File

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

View File

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

View File

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

View 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),
],
),
);
},
);
}
}

View File

@ -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.',

View File

@ -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);
}
});
}

View File

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

View File

@ -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'),

View File

@ -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),
);
}
}
});
}

View File

@ -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]';
}

View File

@ -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';

View File

@ -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),

View File

@ -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();
}
});

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -10,6 +11,7 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
@ -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,
);
},
);
}
}
}
}

View File

@ -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();
}
});
}

View File

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

View File

@ -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,
),
);
},
),
);
},
),
);
},
),
),
),
)

View File

@ -58,7 +58,7 @@ class OfflineBanner extends StatelessWidget {
);
},
).then((bool? value) {
if (value ?? false) {
if (context.mounted && (value ?? false)) {
context
.read<StoriesBloc>()
.add(StoriesExitOfflineMode());

View File

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

View File

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

View File

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

View File

@ -85,6 +85,7 @@ class WebAnalyzer {
RegExp('(title|icon|description|image)', caseSensitive: false);
static final RegExp _lineReg = RegExp(r'[\n\r]|&nbsp;|&gt;');
static final RegExp _spaceReg = RegExp(r'\s+');
static const String _logPrefix = '[WebAnalyzer]';
static bool isEmpty(String? str) {
return !isNotEmpty(str);
@ -120,7 +121,7 @@ class WebAnalyzer {
if (info != null) {
locator.get<Logger>().d('''
fetched mem cached metadata using key $key for $story:
$_logPrefix fetched mem cached metadata using key $key for $story:
${info.toJson()}
''');
return info;
@ -168,7 +169,7 @@ ${info.toJson()}
/// [5] If there is file cache, move it to mem cache for later retrieval.
if (info != null) {
locator.get<Logger>().d('''
fetched file cached metadata using key $key for $story:
$_logPrefix fetched file cached metadata using key $key for $story:
${info.toJson()}
''');
cacheMap[key] = info;
@ -189,7 +190,7 @@ ${info.toJson()}
if (info is WebInfo) {
locator
.get<Logger>()
.d('caching metadata using key $key for $story.');
.d('$_logPrefix caching metadata using key $key for $story.');
unawaited(
locator.get<SembastRepository>().cacheMetadata(
key: key,

View File

@ -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;

View File

@ -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 ==

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