Compare commits

...

64 Commits

Author SHA1 Message Date
fda0b8ede5 fix: android min sdk version. (#523) 2025-06-29 18:42:38 -07:00
1dc1cce12b fix: iOS share extension. (#522) 2025-06-29 17:26:00 -07:00
1c87d741cb fix: android build issue. (#521) 2025-06-28 23:29:00 -07:00
d3b01b97fd fix: offline stories limit. (#520) 2025-06-13 01:36:22 -07:00
f0413f99f0 feat: allow limiting how many stories to be downloaded. (#519) 2025-06-12 23:22:03 -07:00
2c213dee58 chore: upgrade to flutter 3.32.4 (#518) 2025-06-12 21:39:48 -07:00
1652de4c2d fix: ignore network error in parser verifier. (#517) 2025-06-12 21:36:29 -07:00
df807a4a11 feat: improve back gesture on comment details screen. (#516) 2025-06-12 21:11:12 -07:00
4f7a515490 fix: theming. (#508) 2025-01-25 12:46:50 -08:00
341e04d645 fix: slidable action icon color. (#506) 2025-01-21 23:10:42 -08:00
872c4359d4 fix: item page deep link route. (#505) 2025-01-20 21:55:05 -08:00
de1eac31da fix: item screen deeplink. (#504) 2025-01-20 21:37:25 -08:00
0dab102904 chore: update publish_ios.yml (#502) 2025-01-20 19:40:51 -08:00
9c616eb734 chore: update publish_ios.yml (#501) 2025-01-20 14:13:12 -08:00
691a0cb2ac chore: bump Xcode version. (#500) 2025-01-20 13:41:30 -08:00
6612227249 chore: bump macOS version. (#499) 2025-01-20 09:07:13 -08:00
78e022f3cb chore: update .ruby-version (#498) 2025-01-20 02:45:45 -08:00
677b9d4b7d chore: bump macOS version. (#497) 2025-01-20 02:23:14 -08:00
cc55913022 fix: iOS fastlane. (#496) 2025-01-20 02:06:22 -08:00
08973bb829 fix: widget config. (#495) 2025-01-20 01:36:59 -08:00
fdce94f2e7 feat: home screen widget for iOS. (#494) 2025-01-20 01:12:44 -08:00
0897abf27e chore: bump flutter version to 3.27.2. (#493) 2025-01-19 22:32:03 -08:00
f07254dbd4 fix: web parser. (#489) 2024-11-19 00:02:59 -08:00
1408b7343a chore: bump fastlane version. (#486) 2024-10-23 21:59:53 -07:00
bedc3b66ec fix comment parser. (#485) 2024-10-23 18:03:02 -07:00
3e3941380d feat: show msg if no favorites. (#481) 2024-09-22 23:07:55 -07:00
bbed4e0e75 fix: favorites screen. (#480) 2024-09-22 21:22:16 -07:00
a4ae6a20e1 feat: separate tabs for comments and stories in favorites screen. (#479) 2024-09-21 21:55:16 -07:00
3413b1686d fix: double tap instead of long press to open url. (#478) 2024-09-20 00:45:35 -07:00
c24670d5d8 fix: remove excessive network image error logs. (#477) 2024-09-19 23:11:44 -07:00
a50c456390 fix: empty page when data source is web. (#476) 2024-09-19 15:59:16 -07:00
915eb47ab6 fix: tablet mode center border not disappearing in full screen. (#474) 2024-09-16 01:07:02 -07:00
c442a5d2e7 chore: bump flutter version to 3.24.3. (#473) 2024-09-16 00:50:32 -07:00
fbedf327ee feat: resizable submission and story section on tablet. (#472) 2024-09-04 11:33:36 -07:00
45c684b774 fix: set target to iOS 14 (#465) 2024-09-03 18:23:06 -07:00
b6015ae6ca fix: incorrect LSMinimumSystemVersion (#464) 2024-08-28 18:23:22 -07:00
b240dccc8e fix: update podfile.lock. (#462) 2024-08-20 16:38:48 -07:00
949562a34a fix: remove siri suggestion plugin. (#461) 2024-08-20 16:34:51 -07:00
9d8af331c7 feat: enter offline mode automatically if no network connection detected on startup. (#460) 2024-08-20 12:28:55 -07:00
031ff7519d fix: dropdown menu theming. (#459) 2024-08-20 00:54:54 -07:00
62bab9d781 fix: offline toggle and data source dropdown. (#458) 2024-08-19 23:18:10 -07:00
b9ff92a27b fix: pagination when fetching from API. (#457) 2024-08-19 17:30:08 -07:00
0332cd531d feat: data source selection. (#456) 2024-08-16 03:47:02 -07:00
b76c5dd64c fix: manual pagination. (#455) 2024-08-16 01:29:30 -07:00
7325a08002 refactor: clean up logging. (#454) 2024-08-16 01:13:13 -07:00
78bb1c6a6c fix: empty data from share extension. (#453) 2024-08-14 21:12:24 -07:00
c34ffe22da fix: logging deeplink handling. (#452) 2024-08-14 17:14:09 -07:00
a621dc0291 fix: share extension. (#451) 2024-08-14 14:44:27 -07:00
88a12d3339 feat: add log screen. (#450) 2024-08-14 13:43:59 -07:00
50d4cdfad9 fix: home page pagination and duplicate stories. (#449) 2024-08-14 13:04:55 -07:00
366a461c96 feat: fetch stories from web directly instead of firebase api. (#448) 2024-08-13 16:20:51 -07:00
3f1e9d0fff fix: overflow bar alignment. (#447) 2024-08-13 10:11:02 -07:00
d09c10b3f8 chore: bump version to 2.8.3 (#446) 2024-08-13 08:10:03 -07:00
fd5730e189 fix: deprecated uikit code (#444)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-13 07:36:07 -07:00
c9cc6a5df0 fix: flutter deprecation (#445) 2024-08-13 07:31:52 -07:00
8d4b232097 fix: migrate uiapplicationmain to main (#443)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-12 15:00:24 -07:00
8af643e584 fix: swap wakelock to maintained one (#440)
Co-authored-by: Jojo Feng <georgefung78@live.com>
2024-08-12 13:31:34 -07:00
70a56f4ade update commit_check.yml (#442) 2024-08-12 13:25:05 -07:00
c685f33f99 fix download progress bar. (#439) 2024-07-28 21:51:57 -07:00
518608893d auto scroll improvements. (#438) 2024-07-28 16:39:56 -07:00
856efa7c14 bump flutter version to 3.22.3. (#434) 2024-07-19 21:11:40 -07:00
d1957ffb82 update stories bloc. (#428) 2024-06-06 21:19:15 -07:00
553a37961d bump Flutter to 3.22.2 (#427) 2024-06-06 15:08:36 -07:00
bade5b4356 remove comment. (#426) 2024-06-04 22:16:22 -07:00
172 changed files with 5402 additions and 1922 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

View File

@ -15,7 +15,7 @@ on:
jobs:
build_and_publish:
runs-on: macos-13
runs-on: macos-15
timeout-minutes: 30
env:
@ -27,7 +27,7 @@ jobs:
- name: Set XCode version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
xcode-version: '16.0'
- name: Check out from git
uses: actions/checkout@v3

1
.gitignore vendored
View File

@ -44,3 +44,4 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/build/reports

View File

@ -1 +1 @@
2.7.5
3.3.0

View File

@ -31,11 +31,15 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 34
namespace "com.jiaqifeng.hacki"
compileSdkVersion 35
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
// Flag to enable support for the new language APIs
coreLibraryDesugaringEnabled true
}
kotlinOptions {
@ -49,7 +53,7 @@ android {
defaultConfig {
applicationId "com.jiaqifeng.hacki"
minSdkVersion 25
targetSdkVersion 34
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@ -78,7 +82,8 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
kotlin.jvm.target.validation.mode = IGNORE

View File

@ -1,6 +1,7 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip

View File

@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
id "com.android.application" version "8.11.0" apply false
id "org.jetbrains.kotlin.android" version "2.2.0" apply false
}
include ":app"

View File

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

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

@ -25,6 +25,8 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
namespace "dev.britannio.in_app_review"
compileSdkVersion 31
compileOptions {

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

@ -1,19 +1,79 @@
//
// ActionViewController.swift
// Action Extension
//
// Created by Jiaqi Feng on 5/22/22.
//
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
public class SharedMediaFile: Codable {
var path: String
var mimeType: String?
var thumbnail: String? // video thumbnail
var duration: Double? // video duration in milliseconds
var message: String? // post message
var type: SharedMediaType
public init(
path: String,
mimeType: String? = nil,
thumbnail: String? = nil,
duration: Double? = nil,
message: String?=nil,
type: SharedMediaType) {
self.path = path
self.mimeType = mimeType
self.thumbnail = thumbnail
self.duration = duration
self.message = message
self.type = type
}
}
public enum SharedMediaType: String, Codable, CaseIterable {
case image
case video
case text
case file
case url
public var toUTTypeIdentifier: String {
if #available(iOS 14.0, *) {
switch self {
case .image:
return UTType.image.identifier
case .video:
return UTType.movie.identifier
case .text:
return UTType.text.identifier
case .file:
return UTType.fileURL.identifier
case .url:
return UTType.url.identifier
}
}
switch self {
case .image:
return "public.image"
case .video:
return "public.movie"
case .text:
return "public.text"
case .file:
return "public.file-url"
case .url:
return "public.url"
}
}
}
let kSchemePrefix = "ShareMedia"
let kUserDefaultsKey = "ShareKey"
let kUserDefaultsMessageKey = "ShareMessageKey"
let kAppGroupIdKey = "AppGroupId"
class ActionViewController: UIViewController {
let hostAppBundleIdentifier = "com.jiaqi.hacki"
let sharedKey = "ShareKey"
var hostAppBundleIdentifier = "com.jiaqi.hacki"
var appGroupId = "group.com.jiaqi.hacki"
var sharedText: [String] = []
let urlContentType = kUTTypeURL as String
var sharedMedia: [SharedMediaFile] = []
let urlContentType = UTType.url
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
@ -32,20 +92,18 @@ 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)
// If this is the last item, save imagesData in userDefaults and redirect to host app
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.sharedText, forKey: this.sharedKey)
userDefaults?.synchronize()
this.sharedMedia.removeAll()
this.sharedMedia.append(.init(path: item.absoluteString, type: .url))
print(this.sharedText)
this.redirectToHostApp()
this.saveAndRedirect()
}
} else {
self?.dismissWithError()
}
@ -65,25 +123,69 @@ class ActionViewController: UIViewController {
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
// Save shared media and redirect to host app
private func saveAndRedirect(message: String? = nil) {
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(toData(data: sharedMedia), forKey: kUserDefaultsKey)
userDefaults?.set(message, forKey: kUserDefaultsMessageKey)
userDefaults?.synchronize()
redirectToHostApp()
}
private func toData(data: [SharedMediaFile]) -> Data {
let encodedData = try? JSONEncoder().encode(data)
return encodedData!
}
private func redirectToHostApp() {
let url = URL(string: "ShareMedia://dataUrl=\(sharedKey)#text")
// ids may not loaded yet so we need loadIds here too
loadIds()
let url = URL(string: "\(kSchemePrefix)-\(hostAppBundleIdentifier):share")
var responder = self as UIResponder?
let selectorOpenURL = sel_registerName("openURL:")
while (responder != nil) {
if let application = responder as? UIApplication {
application.performSelector(inBackground: selectorOpenURL, with: url)
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url!, options: [:], completionHandler: nil)
}
responder = responder?.next
}
} else {
let selectorOpenURL = sel_registerName("openURL:")
responder = responder!.next
while (responder != nil) {
if (responder?.responds(to: selectorOpenURL))! {
_ = responder?.perform(selectorOpenURL, with: url)
}
responder = responder!.next
}
}
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
private func loadIds() {
// loading Share extension App Id
let shareExtensionAppBundleIdentifier = Bundle.main.bundleIdentifier!
// extract host app bundle id from ShareExtension id
// by default it's <hostAppBundleIdentifier>.<ShareExtension>
// for example: "com.kasem.sharing.Share-Extension" -> com.kasem.sharing
let lastIndexOfPoint = shareExtensionAppBundleIdentifier.lastIndex(of: ".")
hostAppBundleIdentifier = String(shareExtensionAppBundleIdentifier[..<lastIndexOfPoint!])
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
// loading custom AppGroupId from Build Settings or use group.<hostAppBundleIdentifier>
let customAppGroupId = Bundle.main.object(forInfoDictionaryKey: kAppGroupIdKey) as? String
appGroupId = customAppGroupId ?? defaultAppGroupId
}
@IBAction func done() {
// Return any edited content to the host app.
// This template doesn't do anything, so we just echo the passed in items.
self.extensionContext!.completeRequest(returningItems: self.extensionContext!.inputItems, completionHandler: nil)
}
}

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,7 +1,9 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
CFPropertyList (3.0.7)
base64
nkf
rexml
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
@ -9,30 +11,31 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.15)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.889.0)
aws-sdk-core (3.191.1)
aws-partitions (1.994.0)
aws-sdk-core (3.211.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.169.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
@ -87,7 +90,7 @@ GEM
ethon (0.15.0)
ffi (>= 1.15.0)
excon (0.109.0)
faraday (1.10.3)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -108,22 +111,22 @@ GEM
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.0)
fastlane (2.219.0)
fastimage (2.3.1)
fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
@ -132,6 +135,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@ -144,10 +148,10 @@ GEM
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
@ -156,7 +160,9 @@ GEM
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
@ -198,40 +204,42 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
json (2.7.2)
jwt (2.9.3)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.16.3)
molinillo (0.8.0)
multi_json (1.15.0)
multipart-post (2.4.0)
multipart-post (2.4.1)
nanaimo (0.3.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
optparse (0.4.0)
nkf (0.2.0)
optparse (0.5.0)
os (1.1.4)
plist (3.7.1)
public_suffix (4.0.7)
rake (13.1.0)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rexml (3.3.8)
rouge (2.0.7)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
security (0.1.5)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@ -240,6 +248,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -253,18 +262,16 @@ GEM
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
xcodeproj (1.25.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
@ -273,6 +280,7 @@ GEM
PLATFORMS
universal-darwin-21
universal-darwin-22
universal-darwin-23
x86_64-darwin-19

View File

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

View File

@ -1,7 +1,6 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@ -10,73 +9,74 @@ PODS:
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- OrderedSet (~> 6.0.3)
- flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- OrderedSet (~> 6.0.3)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- flutter_native_splash (2.4.3):
- Flutter
- flutter_siri_suggestions (0.0.1):
- flutter_secure_storage (6.0.0):
- Flutter
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1):
- Flutter
- MTBBarcodeScanner (5.0.11)
- OrderedSet (5.0.0)
- OrderedSet (6.0.3)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- qr_code_scanner (0.2.0):
- qr_code_scanner_plus (0.2.6):
- Flutter
- MTBBarcodeScanner
- receive_sharing_intent (1.5.3):
- receive_sharing_intent (1.8.1):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.3):
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- synced_shared_preferences (0.0.1):
- 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
- FlutterMacOS
- workmanager (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`)
- qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/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`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
- workmanager (from `.symlinks/plugins/workmanager/ios`)
SPEC REPOS:
@ -86,7 +86,7 @@ SPEC REPOS:
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/darwin"
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
@ -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:
@ -109,53 +109,53 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
qr_code_scanner:
:path: ".symlinks/plugins/qr_code_scanner/ios"
qr_code_scanner_plus:
:path: ".symlinks/plugins/qr_code_scanner_plus/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
synced_shared_preferences:
: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"
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
workmanager:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
in_app_review: 8efcf4a4d3ba72d5d776d29e5a268f1abf64d184
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
receive_sharing_intent: 753f808c6be5550247f6a20f2a14972466a5f33c
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
qr_code_scanner_plus: 7e087021bc69873140e0754750eb87d867bed755
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
synced_shared_preferences: 90a2b479df93a2f6b68e08443f865ed1633dbe6a
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2
workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
PODFILE CHECKSUM: 0957b955069bb512c22bae4cadad9f4c34161dbe
PODFILE CHECKSUM: f03c7c11cf2b623592c89c68c628682778bb78b4
COCOAPODS: 1.13.0
COCOAPODS: 1.16.2

View File

@ -22,7 +22,32 @@
E530B1AD283B54DA004E8EB6 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E530B1AC283B54DA004E8EB6 /* ActionViewController.swift */; };
E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; };
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E573DDF82D3E273F00831A51 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E573DDF72D3E273F00831A51 /* WidgetKit.framework */; };
E573DDFA2D3E273F00831A51 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E573DDF92D3E273F00831A51 /* SwiftUI.framework */; };
E573DE072D3E274000831A51 /* Story Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E573DDF62D3E273F00831A51 /* Story Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E573DE392D3E282700831A51 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = E573DE382D3E282700831A51 /* Alamofire */; };
E573DE3E2D3E28CD00831A51 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = E573DE3D2D3E28CD00831A51 /* SwiftSoup */; };
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
E5CE971A2D3E541A00430A81 /* ArrayExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97022D3E541A00430A81 /* ArrayExtension.swift */; };
E5CE971B2D3E541A00430A81 /* Date+TimeAgoString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97032D3E541A00430A81 /* Date+TimeAgoString.swift */; };
E5CE971C2D3E541A00430A81 /* Int+OrZero.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97042D3E541A00430A81 /* Int+OrZero.swift */; };
E5CE971D2D3E541A00430A81 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97052D3E541A00430A81 /* StringExtension.swift */; };
E5CE971E2D3E541A00430A81 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97072D3E541A00430A81 /* Comment.swift */; };
E5CE971F2D3E541A00430A81 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97082D3E541A00430A81 /* Item.swift */; };
E5CE97202D3E541A00430A81 /* SearchFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97092D3E541A00430A81 /* SearchFilter.swift */; };
E5CE97212D3E541A00430A81 /* SearchParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE970A2D3E541A00430A81 /* SearchParams.swift */; };
E5CE97222D3E541A00430A81 /* Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE970B2D3E541A00430A81 /* Story.swift */; };
E5CE97232D3E541A00430A81 /* StoryType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE970C2D3E541A00430A81 /* StoryType.swift */; };
E5CE97242D3E541A00430A81 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE970D2D3E541A00430A81 /* User.swift */; };
E5CE97252D3E541A00430A81 /* SelectStoryTypeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97112D3E541A00430A81 /* SelectStoryTypeIntent.swift */; };
E5CE97262D3E541A00430A81 /* StoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97122D3E541A00430A81 /* StoryEntry.swift */; };
E5CE97272D3E541A00430A81 /* StoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97132D3E541A00430A81 /* StoryRepository.swift */; };
E5CE97282D3E541A00430A81 /* StorySource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97142D3E541A00430A81 /* StorySource.swift */; };
E5CE97292D3E541A00430A81 /* StoryTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97152D3E541A00430A81 /* StoryTimelineProvider.swift */; };
E5CE972A2D3E541A00430A81 /* StoryWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97162D3E541A00430A81 /* StoryWidget.swift */; };
E5CE972B2D3E541A00430A81 /* StoryWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97172D3E541A00430A81 /* StoryWidgetBundle.swift */; };
E5CE972C2D3E541A00430A81 /* Timeline+Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CE97182D3E541A00430A81 /* Timeline+Placeholder.swift */; };
E5CE972D2D3E541A00430A81 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5CE970F2D3E541A00430A81 /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -40,12 +65,19 @@
remoteGlobalIDString = E530B1A5283B54DA004E8EB6;
remoteInfo = "Action Extension";
};
E573DE052D3E274000831A51 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = E573DDF52D3E273F00831A51;
remoteInfo = StoryWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
buildActionMask = 12;
dstPath = "";
dstSubfolderSpec = 10;
files = (
@ -60,6 +92,7 @@
dstSubfolderSpec = 13;
files = (
E51D52B7283B464E00FC8DD8 /* Share Extension.appex in Embed App Extensions */,
E573DE072D3E274000831A51 /* Story Widget Extension.appex in Embed App Extensions */,
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
@ -96,9 +129,33 @@
E530B1AF283B54DA004E8EB6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
E530B1B1283B54DA004E8EB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E530B1B9283B54E4004E8EB6 /* Action Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Action Extension.entitlements"; sourceTree = "<group>"; };
E573DDF62D3E273F00831A51 /* Story Widget Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Story Widget Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
E573DDF72D3E273F00831A51 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
E573DDF92D3E273F00831A51 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
E575B6EF27EBC6C6002B1508 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
E575B6F027EBC6DA002B1508 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
E59F28EE283B477D00512089 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
E5CE97022D3E541A00430A81 /* ArrayExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayExtension.swift; sourceTree = "<group>"; };
E5CE97032D3E541A00430A81 /* Date+TimeAgoString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgoString.swift"; sourceTree = "<group>"; };
E5CE97042D3E541A00430A81 /* Int+OrZero.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+OrZero.swift"; sourceTree = "<group>"; };
E5CE97052D3E541A00430A81 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = "<group>"; };
E5CE97072D3E541A00430A81 /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = "<group>"; };
E5CE97082D3E541A00430A81 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
E5CE97092D3E541A00430A81 /* SearchFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilter.swift; sourceTree = "<group>"; };
E5CE970A2D3E541A00430A81 /* SearchParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchParams.swift; sourceTree = "<group>"; };
E5CE970B2D3E541A00430A81 /* Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Story.swift; sourceTree = "<group>"; };
E5CE970C2D3E541A00430A81 /* StoryType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryType.swift; sourceTree = "<group>"; };
E5CE970D2D3E541A00430A81 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
E5CE970F2D3E541A00430A81 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
E5CE97102D3E541A00430A81 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E5CE97112D3E541A00430A81 /* SelectStoryTypeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectStoryTypeIntent.swift; sourceTree = "<group>"; };
E5CE97122D3E541A00430A81 /* StoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryEntry.swift; sourceTree = "<group>"; };
E5CE97132D3E541A00430A81 /* StoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryRepository.swift; sourceTree = "<group>"; };
E5CE97142D3E541A00430A81 /* StorySource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorySource.swift; sourceTree = "<group>"; };
E5CE97152D3E541A00430A81 /* StoryTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryTimelineProvider.swift; sourceTree = "<group>"; };
E5CE97162D3E541A00430A81 /* StoryWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryWidget.swift; sourceTree = "<group>"; };
E5CE97172D3E541A00430A81 /* StoryWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryWidgetBundle.swift; sourceTree = "<group>"; };
E5CE97182D3E541A00430A81 /* Timeline+Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timeline+Placeholder.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -126,6 +183,17 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E573DDF32D3E273F00831A51 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E573DE392D3E282700831A51 /* Alamofire in Frameworks */,
E573DDFA2D3E273F00831A51 /* SwiftUI.framework in Frameworks */,
E573DDF82D3E273F00831A51 /* WidgetKit.framework in Frameworks */,
E573DE3E2D3E28CD00831A51 /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -147,6 +215,7 @@
97C146F01CF9000F007C117D /* Runner */,
E51D52AE283B464E00FC8DD8 /* Share Extension */,
E530B1A9283B54DA004E8EB6 /* Action Extension */,
E5CE97192D3E541A00430A81 /* StoryWidget */,
97C146EF1CF9000F007C117D /* Products */,
D79CD63C88FF49EF451AFDDF /* Pods */,
B3F4F49CF582C662A01499C0 /* Frameworks */,
@ -159,6 +228,7 @@
97C146EE1CF9000F007C117D /* Runner.app */,
E51D52AD283B464E00FC8DD8 /* Share Extension.appex */,
E530B1A6283B54DA004E8EB6 /* Action Extension.appex */,
E573DDF62D3E273F00831A51 /* Story Widget Extension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -185,6 +255,8 @@
E575B6F027EBC6DA002B1508 /* CloudKit.framework */,
E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */,
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */,
E573DDF72D3E273F00831A51 /* WidgetKit.framework */,
E573DDF92D3E273F00831A51 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -222,6 +294,50 @@
path = "Action Extension";
sourceTree = "<group>";
};
E5CE97062D3E541A00430A81 /* Extensions */ = {
isa = PBXGroup;
children = (
E5CE97022D3E541A00430A81 /* ArrayExtension.swift */,
E5CE97032D3E541A00430A81 /* Date+TimeAgoString.swift */,
E5CE97042D3E541A00430A81 /* Int+OrZero.swift */,
E5CE97052D3E541A00430A81 /* StringExtension.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
E5CE970E2D3E541A00430A81 /* Models */ = {
isa = PBXGroup;
children = (
E5CE97072D3E541A00430A81 /* Comment.swift */,
E5CE97082D3E541A00430A81 /* Item.swift */,
E5CE97092D3E541A00430A81 /* SearchFilter.swift */,
E5CE970A2D3E541A00430A81 /* SearchParams.swift */,
E5CE970B2D3E541A00430A81 /* Story.swift */,
E5CE970C2D3E541A00430A81 /* StoryType.swift */,
E5CE970D2D3E541A00430A81 /* User.swift */,
);
path = Models;
sourceTree = "<group>";
};
E5CE97192D3E541A00430A81 /* StoryWidget */ = {
isa = PBXGroup;
children = (
E5CE97062D3E541A00430A81 /* Extensions */,
E5CE970E2D3E541A00430A81 /* Models */,
E5CE970F2D3E541A00430A81 /* Assets.xcassets */,
E5CE97102D3E541A00430A81 /* Info.plist */,
E5CE97112D3E541A00430A81 /* SelectStoryTypeIntent.swift */,
E5CE97122D3E541A00430A81 /* StoryEntry.swift */,
E5CE97132D3E541A00430A81 /* StoryRepository.swift */,
E5CE97142D3E541A00430A81 /* StorySource.swift */,
E5CE97152D3E541A00430A81 /* StoryTimelineProvider.swift */,
E5CE97162D3E541A00430A81 /* StoryWidget.swift */,
E5CE97172D3E541A00430A81 /* StoryWidgetBundle.swift */,
E5CE97182D3E541A00430A81 /* Timeline+Placeholder.swift */,
);
path = StoryWidget;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -231,11 +347,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 */,
);
@ -244,6 +360,7 @@
dependencies = (
E51D52B6283B464E00FC8DD8 /* PBXTargetDependency */,
E530B1B3283B54DA004E8EB6 /* PBXTargetDependency */,
E573DE062D3E274000831A51 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
@ -284,13 +401,34 @@
productReference = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
E573DDF52D3E273F00831A51 /* Story Widget Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = E573DE0C2D3E274000831A51 /* Build configuration list for PBXNativeTarget "Story Widget Extension" */;
buildPhases = (
E573DDF22D3E273F00831A51 /* Sources */,
E573DDF32D3E273F00831A51 /* Frameworks */,
E573DDF42D3E273F00831A51 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Story Widget Extension";
packageProductDependencies = (
E573DE382D3E282700831A51 /* Alamofire */,
E573DE3D2D3E28CD00831A51 /* SwiftSoup */,
);
productName = StoryWidgetExtension;
productReference = E573DDF62D3E273F00831A51 /* Story Widget Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1330;
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
@ -304,6 +442,9 @@
E530B1A5283B54DA004E8EB6 = {
CreatedOnToolsVersion = 13.3;
};
E573DDF52D3E273F00831A51 = {
CreatedOnToolsVersion = 16.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@ -315,6 +456,10 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
E573DE372D3E282700831A51 /* XCRemoteSwiftPackageReference "Alamofire" */,
E573DE3C2D3E28CD00831A51 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@ -322,6 +467,7 @@
97C146ED1CF9000F007C117D /* Runner */,
E51D52AC283B464E00FC8DD8 /* Share Extension */,
E530B1A5283B54DA004E8EB6 /* Action Extension */,
E573DDF52D3E273F00831A51 /* Story Widget Extension */,
);
};
/* End PBXProject section */
@ -355,6 +501,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E573DDF42D3E273F00831A51 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E5CE972D2D3E541A00430A81 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@ -372,7 +526,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
@ -387,7 +541,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;
@ -456,6 +610,32 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E573DDF22D3E273F00831A51 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E5CE971A2D3E541A00430A81 /* ArrayExtension.swift in Sources */,
E5CE971B2D3E541A00430A81 /* Date+TimeAgoString.swift in Sources */,
E5CE971C2D3E541A00430A81 /* Int+OrZero.swift in Sources */,
E5CE971D2D3E541A00430A81 /* StringExtension.swift in Sources */,
E5CE971E2D3E541A00430A81 /* Comment.swift in Sources */,
E5CE971F2D3E541A00430A81 /* Item.swift in Sources */,
E5CE97202D3E541A00430A81 /* SearchFilter.swift in Sources */,
E5CE97212D3E541A00430A81 /* SearchParams.swift in Sources */,
E5CE97222D3E541A00430A81 /* Story.swift in Sources */,
E5CE97232D3E541A00430A81 /* StoryType.swift in Sources */,
E5CE97242D3E541A00430A81 /* User.swift in Sources */,
E5CE97252D3E541A00430A81 /* SelectStoryTypeIntent.swift in Sources */,
E5CE97262D3E541A00430A81 /* StoryEntry.swift in Sources */,
E5CE97272D3E541A00430A81 /* StoryRepository.swift in Sources */,
E5CE97282D3E541A00430A81 /* StorySource.swift in Sources */,
E5CE97292D3E541A00430A81 /* StoryTimelineProvider.swift in Sources */,
E5CE972A2D3E541A00430A81 /* StoryWidget.swift in Sources */,
E5CE972B2D3E541A00430A81 /* StoryWidgetBundle.swift in Sources */,
E5CE972C2D3E541A00430A81 /* Timeline+Placeholder.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -469,6 +649,11 @@
target = E530B1A5283B54DA004E8EB6 /* Action Extension */;
targetProxy = E530B1B2283B54DA004E8EB6 /* PBXContainerItemProxy */;
};
E573DE062D3E274000831A51 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E573DDF52D3E273F00831A51 /* Story Widget Extension */;
targetProxy = E573DE052D3E274000831A51 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@ -537,6 +722,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 +734,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;
@ -561,24 +748,23 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
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;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(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 +805,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 +823,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 +862,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 +874,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;
@ -700,24 +890,23 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
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;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = "";
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -733,26 +922,25 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
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;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(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 +964,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 = 17.6;
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 +1005,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 +1013,13 @@
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
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 +1044,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 = 17.6;
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 +1083,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 = 17.6;
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 +1126,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 +1134,13 @@
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
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 +1167,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 = 17.6;
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)";
@ -1004,6 +1192,135 @@
};
name = Profile;
};
E573DE082D3E274000831A51 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StoryWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = StoryWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Widget-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
E573DE092D3E274000831A51 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StoryWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = StoryWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Widget-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.jiaqi.hacki.Widget-Extension";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
E573DE0A2D3E274000831A51 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StoryWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = StoryWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Widget-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -1047,7 +1364,49 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E573DE0C2D3E274000831A51 /* Build configuration list for PBXNativeTarget "Story Widget Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E573DE082D3E274000831A51 /* Debug */,
E573DE092D3E274000831A51 /* Release */,
E573DE0A2D3E274000831A51 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
E573DE372D3E282700831A51 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.10.2;
};
};
E573DE3C2D3E28CD00831A51 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.7.6;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
E573DE382D3E282700831A51 /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
package = E573DE372D3E282700831A51 /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
E573DE3D2D3E28CD00831A51 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = E573DE3C2D3E28CD00831A51 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E530B1A5283B54DA004E8EB6"
BuildableName = "Action Extension.appex"
BlueprintName = "Action Extension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -26,6 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
@ -45,11 +46,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E51D52AC283B464E00FC8DD8"
BuildableName = "Share Extension.appex"
BlueprintName = "Share Extension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E573DDF52D3E273F00831A51"
BuildableName = "Story Widget Extension.appex"
BlueprintName = "Story Widget Extension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "2"
BundleIdentifier = "com.apple.springboard">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E573DDF52D3E273F00831A51"
BuildableName = "Story Widget Extension.appex"
BlueprintName = "Story Widget Extension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,24 @@
{
"originHash" : "1002c245c0fdae6ca9c33705b8fc0eaeec1eff55818735c136a20ed23937d94f",
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "0837db354faf9c9deb710dc597046edaadf5360f",
"version" : "2.7.6"
}
}
],
"version" : 3
}

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,6 +8,8 @@
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>

View File

@ -3,186 +3,276 @@ import Social
import MobileCoreServices
import Photos
class ShareViewController: SLComposeServiceViewController {
let hostAppBundleIdentifier = "com.jiaqi.hacki"
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;
public class SharedMediaFile: Codable {
var path: String
var mimeType: String?
var thumbnail: String? // video thumbnail
var duration: Double? // video duration in milliseconds
var message: String? // post message
var type: SharedMediaType
override func isContentValid() -> Bool {
public init(
path: String,
mimeType: String? = nil,
thumbnail: String? = nil,
duration: Double? = nil,
message: String?=nil,
type: SharedMediaType) {
self.path = path
self.mimeType = mimeType
self.thumbnail = thumbnail
self.duration = duration
self.message = message
self.type = type
}
}
public enum SharedMediaType: String, Codable, CaseIterable {
case image
case video
case text
case file
case url
public var toUTTypeIdentifier: String {
if #available(iOS 14.0, *) {
switch self {
case .image:
return UTType.image.identifier
case .video:
return UTType.movie.identifier
case .text:
return UTType.text.identifier
case .file:
return UTType.fileURL.identifier
case .url:
return UTType.url.identifier
}
}
switch self {
case .image:
return "public.image"
case .video:
return "public.movie"
case .text:
return "public.text"
case .file:
return "public.file-url"
case .url:
return "public.url"
}
}
}
let kSchemePrefix = "ShareMedia"
let kUserDefaultsKey = "ShareKey"
let kUserDefaultsMessageKey = "ShareMessageKey"
let kAppGroupIdKey = "AppGroupId"
class ShareViewController: SLComposeServiceViewController {
var hostAppBundleIdentifier = "com.jiaqi.hacki"
var appGroupId = "group.com.jiaqi.hacki"
var sharedMedia: [SharedMediaFile] = []
/// Override this method to return false if you don't want to redirect to host app automatically
/// Default is true
open func shouldAutoRedirect() -> Bool {
return true
}
override func viewDidLoad() {
super.viewDidLoad();
open override func isContentValid() -> Bool {
return true
}
override func viewDidAppear(_ animated: Bool) {
open override func viewDidLoad() {
super.viewDidLoad()
// load group and app id from build info
loadIds()
}
// Redirect to host app when user click on Post
open override func didSelectPost() {
saveAndRedirect(message: contentText)
}
open override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.
if let content = extensionContext!.inputItems[0] as? NSExtensionItem {
if let contents = content.attachments {
for (index, attachment) in (contents).enumerated() {
if attachment.hasItemConformingToTypeIdentifier(imageContentType) {
handleImages(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(textContentType) {
handleText(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(fileURLType) {
handleFiles(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(urlContentType) {
handleUrl(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType) {
handleVideos(content: content, attachment: attachment, index: index)
for type in SharedMediaType.allCases {
if attachment.hasItemConformingToTypeIdentifier(type.toUTTypeIdentifier) {
attachment.loadItem(forTypeIdentifier: type.toUTTypeIdentifier) { [weak self] data, error in
guard let this = self, error == nil else {
self?.dismissWithError()
return
}
switch type {
case .text:
if let text = data as? String {
this.handleMedia(forLiteral: text,
type: type,
index: index,
content: content)
}
case .url:
if let url = data as? URL {
this.handleMedia(forLiteral: url.absoluteString,
type: type,
index: index,
content: content)
}
default:
if let url = data as? URL {
this.handleMedia(forFile: url,
type: type,
index: index,
content: content)
}
else if let image = data as? UIImage {
this.handleMedia(forUIImage: image,
type: type,
index: index,
content: content)
}
}
}
break
}
}
}
}
}
}
override func didSelectPost() {
print("didSelectPost");
}
override func configurationItems() -> [Any]! {
open override func configurationItems() -> [Any]! {
// To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
return []
}
private func handleText (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: textContentType, options: nil) { [weak self] data, error in
if error == nil, let item = data as? String, let this = self {
this.sharedText.append(item)
// If this is the last item, save imagesData in userDefaults and redirect to host app
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.sharedText, forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .text)
}
} else {
self?.dismissWithError()
private func loadIds() {
// loading Share extension App Id
let shareExtensionAppBundleIdentifier = Bundle.main.bundleIdentifier!
// extract host app bundle id from ShareExtension id
// by default it's <hostAppBundleIdentifier>.<ShareExtension>
// for example: "com.kasem.sharing.Share-Extension" -> com.kasem.sharing
let lastIndexOfPoint = shareExtensionAppBundleIdentifier.lastIndex(of: ".")
hostAppBundleIdentifier = String(shareExtensionAppBundleIdentifier[..<lastIndexOfPoint!])
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
// loading custom AppGroupId from Build Settings or use group.<hostAppBundleIdentifier>
let customAppGroupId = Bundle.main.object(forInfoDictionaryKey: kAppGroupIdKey) as? String
appGroupId = customAppGroupId ?? defaultAppGroupId
}
private func handleMedia(forLiteral item: String, type: SharedMediaType, index: Int, content: NSExtensionItem) {
sharedMedia.append(SharedMediaFile(
path: item,
mimeType: type == .text ? "text/plain": nil,
type: type
))
if index == (content.attachments?.count ?? 0) - 1 {
if shouldAutoRedirect() {
saveAndRedirect()
}
}
}
private func handleMedia(forUIImage image: UIImage, type: SharedMediaType, index: Int, content: NSExtensionItem){
let tempPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent("TempImage.png")
if self.writeTempFile(image, to: tempPath) {
let newPathDecoded = tempPath.absoluteString.removingPercentEncoding!
sharedMedia.append(SharedMediaFile(
path: newPathDecoded,
mimeType: type == .image ? "image/png": nil,
type: type
))
}
if index == (content.attachments?.count ?? 0) - 1 {
if shouldAutoRedirect() {
saveAndRedirect()
}
}
}
private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in
if error == nil, let item = data as? URL, let this = self {
this.sharedText.append(item.absoluteString)
// If this is the last item, save imagesData in userDefaults and redirect to host app
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.sharedText, forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .text)
private func handleMedia(forFile url: URL, type: SharedMediaType, index: Int, content: NSExtensionItem) {
let fileName = getFileName(from: url, type: type)
let newPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent(fileName)
if copyFile(at: url, to: newPath) {
// The path should be decoded because Flutter is not expecting url encoded file names
let newPathDecoded = newPath.absoluteString.removingPercentEncoding!;
if type == .video {
// Get video thumbnail and duration
if let videoInfo = getVideoInfo(from: url) {
let thumbnailPathDecoded = videoInfo.thumbnail?.removingPercentEncoding;
sharedMedia.append(SharedMediaFile(
path: newPathDecoded,
mimeType: url.mimeType(),
thumbnail: thumbnailPathDecoded,
duration: videoInfo.duration,
type: type
))
}
} else {
self?.dismissWithError()
sharedMedia.append(SharedMediaFile(
path: newPathDecoded,
mimeType: url.mimeType(),
type: type
))
}
}
if index == (content.attachments?.count ?? 0) - 1 {
if shouldAutoRedirect() {
saveAndRedirect()
}
}
}
private func handleImages (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: imageContentType, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from: url, type: .image)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if(copied) {
this.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .image))
}
// If this is the last item, save imagesData in userDefaults and redirect to host app
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .media)
}
} else {
self?.dismissWithError()
}
}
// Save shared media and redirect to host app
private func saveAndRedirect(message: String? = nil) {
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(toData(data: sharedMedia), forKey: kUserDefaultsKey)
userDefaults?.set(message, forKey: kUserDefaultsMessageKey)
userDefaults?.synchronize()
redirectToHostApp()
}
private func handleVideos (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: videoContentType, options: nil) { [weak self] data, error in
private func redirectToHostApp() {
// ids may not loaded yet so we need loadIds here too
loadIds()
let url = URL(string: "\(kSchemePrefix)-\(hostAppBundleIdentifier):share")
var responder = self as UIResponder?
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url!, options: [:], completionHandler: nil)
}
responder = responder?.next
}
} else {
let selectorOpenURL = sel_registerName("openURL:")
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from: url, type: .video)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if(copied) {
guard let sharedFile = this.getSharedMediaFile(forVideo: newPath) else {
return
}
this.sharedMedia.append(sharedFile)
while (responder != nil) {
if (responder?.responds(to: selectorOpenURL))! {
_ = responder?.perform(selectorOpenURL, with: url)
}
// If this is the last item, save imagesData in userDefaults and redirect to host app
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .media)
}
} else {
self?.dismissWithError()
}
}
}
private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: fileURLType, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from :url, type: .file)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if (copied) {
this.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .file))
}
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .file)
}
} else {
self?.dismissWithError()
responder = responder!.next
}
}
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
private func dismissWithError() {
@ -198,57 +288,38 @@ class ShareViewController: SLComposeServiceViewController {
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
private func redirectToHostApp(type: RedirectType) {
let url = URL(string: "ShareMedia://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)
}
responder = responder!.next
}
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
enum RedirectType {
case media
case text
case file
}
func getExtension(from url: URL, type: SharedMediaType) -> String {
let parts = url.lastPathComponent.components(separatedBy: ".")
var ex: String? = nil
if (parts.count > 1) {
ex = parts.last
}
if (ex == nil) {
private func getFileName(from url: URL, type: SharedMediaType) -> String {
var name = url.lastPathComponent
if name.isEmpty {
switch type {
case .image:
ex = "PNG"
name = UUID().uuidString + ".png"
case .video:
ex = "MP4"
case .file:
ex = "TXT"
name = UUID().uuidString + ".mp4"
case .text:
name = UUID().uuidString + ".txt"
default:
name = UUID().uuidString
}
}
return ex ?? "Unknown"
}
func getFileName(from url: URL, type: SharedMediaType) -> String {
var name = url.lastPathComponent
if (name.isEmpty) {
name = UUID().uuidString + "." + getExtension(from: url, type: type)
}
return name
}
private func writeTempFile(_ image: UIImage, to dstURL: URL) -> Bool {
do {
if FileManager.default.fileExists(atPath: dstURL.path) {
try FileManager.default.removeItem(at: dstURL)
}
let pngData = image.pngData();
try pngData?.write(to: dstURL);
return true;
} catch (let error){
print("Cannot write to temp file: \(error)");
return false;
}
}
func copyFile(at srcURL: URL, to dstURL: URL) -> Bool {
private func copyFile(at srcURL: URL, to dstURL: URL) -> Bool {
do {
if FileManager.default.fileExists(atPath: dstURL.path) {
try FileManager.default.removeItem(at: dstURL)
@ -261,13 +332,13 @@ class ShareViewController: SLComposeServiceViewController {
return true
}
private func getSharedMediaFile(forVideo: URL) -> SharedMediaFile? {
let asset = AVAsset(url: forVideo)
private func getVideoInfo(from url: URL) -> (thumbnail: String?, duration: Double)? {
let asset = AVAsset(url: url)
let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded()
let thumbnailPath = getThumbnailPath(for: forVideo)
let thumbnailPath = getThumbnailPath(for: url)
if FileManager.default.fileExists(atPath: thumbnailPath.path) {
return SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video)
return (thumbnail: thumbnailPath.absoluteString, duration: duration)
}
var saved = false
@ -276,59 +347,44 @@ class ShareViewController: SLComposeServiceViewController {
// let scale = UIScreen.main.scale
assetImgGenerate.maximumSize = CGSize(width: 360, height: 360)
do {
let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: Int32(1.0)), actualTime: nil)
try UIImage.pngData(UIImage(cgImage: img))()?.write(to: thumbnailPath)
let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: 1), actualTime: nil)
try UIImage(cgImage: img).pngData()?.write(to: thumbnailPath)
saved = true
} catch {
saved = false
}
return saved ? SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video) : nil
return saved ? (thumbnail: thumbnailPath.absoluteString, duration: duration): nil
}
private func getThumbnailPath(for url: URL) -> URL {
let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "")
let path = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(hostAppBundleIdentifier)")!
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!
.appendingPathComponent("\(fileName).jpg")
return path
}
class SharedMediaFile: Codable {
var path: String; // can be image, video or url path. It can also be text content
var thumbnail: String?; // video thumbnail
var duration: Double?; // video duration in milliseconds
var type: SharedMediaType;
init(path: String, thumbnail: String?, duration: Double?, type: SharedMediaType) {
self.path = path
self.thumbnail = thumbnail
self.duration = duration
self.type = type
}
// 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)")
}
}
enum SharedMediaType: Int, Codable {
case image
case video
case file
}
func toData(data: [SharedMediaFile]) -> Data {
private func toData(data: [SharedMediaFile]) -> Data {
let encodedData = try? JSONEncoder().encode(data)
return encodedData!
}
}
extension Array {
subscript (safe index: UInt) -> Element? {
return Int(index) < count ? self[Int(index)] : nil
extension URL {
public func mimeType() -> String {
if #available(iOS 14.0, *) {
if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
return mimeType
}
} else {
if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, self.pathExtension as NSString, nil)?.takeRetainedValue() {
if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() {
return mimetype as String
}
}
}
return "application/octet-stream"
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,36 @@
import Foundation
extension Optional where Wrapped: Collection {
var isMoreThanOne: Bool {
guard let unwrapped = self else {
return false
}
if unwrapped.count > 1 {
return true
} else {
return false
}
}
var isNullOrEmpty: Bool {
guard let unwrapped = self else {
return true
}
return unwrapped.isEmpty
}
var isNotNullOrEmpty: Bool {
return !isNullOrEmpty
}
var countOrZero: Int {
guard let unwrapped = self else {
return 0
}
return unwrapped.count
}
}

View File

@ -0,0 +1,9 @@
import Foundation
extension Date {
var timeAgoString: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: self, relativeTo: Date())
}
}

View File

@ -0,0 +1,10 @@
import Foundation
extension Int? {
var orZero: Int {
guard let unwrapped = self else {
return 0
}
return unwrapped
}
}

View File

@ -0,0 +1,88 @@
import Foundation
import UIKit
import SwiftSoup
public extension String {
var isNotEmpty: Bool {
!isEmpty
}
var htmlStripped: String {
do {
let pRegex = try Regex("<p>")
let iRegex = try Regex(#"\<i\>(.*?)\<\/i\>"#)
let codeRegex = try Regex(#"\<pre\>\<code\>(.*?)\<\/code\>\<\/pre\>"#)
.dotMatchesNewlines(true)
let linkRegex = try Regex(#"\<a href=\"(.*?)\".*?\>.*?\<\/a\>"#)
let res = try Entities.unescape(self)
.replacing(pRegex, with: { match in
"\n"
})
.replacing(iRegex, with: { match in
if let m = match[1].substring {
let matchedStr = String(m)
return "**\(matchedStr)**"
}
return String()
})
.replacing(linkRegex, with: { match in
if let m = match[1].substring {
let matchedStr = String(m)
return matchedStr
}
return String()
})
.withExtraLineBreak
.replacing(codeRegex, with: { match in
if let m = match[1].substring {
let matchedStr = String(m)
return "```" + String(matchedStr.replacing("\n\n", with: "``` \n ``` \n").dropLast(1)) + "```"
}
return String()
})
return res
} catch {
return error.localizedDescription
}
}
func toJSON() -> Any? {
guard let data = self.data(using: .utf8, allowLossyConversion: false) else { return nil }
return try? JSONSerialization.jsonObject(with: data, options: .mutableContainers)
}
private var withExtraLineBreak: String {
if isEmpty { return self }
let range = startIndex..<index(endIndex, offsetBy: -1)
var str = String(replacingOccurrences(of: "\n", with: "\n\n", range: range))
while str.last?.isWhitespace == true {
str = String(str.dropLast())
}
return str
}
}
public extension Optional where Wrapped == String {
var orEmpty: String {
guard let unwrapped = self else {
return ""
}
return unwrapped
}
var htmlStripped: String{
guard let unwrapped = self else {
return ""
}
return unwrapped.htmlStripped
}
var isNotNullOrEmpty: Bool {
guard let unwrapped = self else {
return false
}
return unwrapped.isNotEmpty
}
}

View File

@ -0,0 +1,11 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,54 @@
public struct Comment: Item {
public let id: Int
public let parent: Int?
public let text: String?
public let type: String?
public let by: String?
public let time: Int
public let kids: [Int]?
public let level: Int?
public var metadata: String {
if let count = kids?.count, count != 0 {
return "\(count) cmt\(count > 1 ? "s":"") | \(timeAgo) by \(by.orEmpty)"
} else {
return "\(timeAgo) by \(by.orEmpty)"
}
}
/// Values below will always be nil for `Comment`.
public let title: String?
public let url: String?
public let descendants: Int?
public let score: Int?
init(id: Int, parent: Int?, text: String?, by: String?, time: Int, kids: [Int]? = [Int](), level: Int? = 0) {
self.id = id
self.parent = parent
self.text = text
self.by = by
self.time = time
self.kids = kids
self.level = level
self.type = "comment"
self.title = nil
self.url = nil
self.descendants = nil
self.score = nil
}
// Empty initializer
init() {
self.init(id: 0, parent: 0, text: "", by: "", time: 0)
}
public func copyWith(text: String? = nil, level: Int? = nil) -> Comment {
Comment(id: id,
parent: parent,
text: text ?? self.text,
by: by,
time: time,
kids: kids ?? [Int](),
level: level ?? self.level)
}
}

View File

@ -0,0 +1,59 @@
import Foundation
public protocol Item: Codable, Identifiable, Hashable {
var id: Int { get }
var parent: Int? { get }
var title: String? { get }
var text: String? { get }
var url: String? { get }
var type: String? { get }
var by: String? { get }
var score: Int? { get }
var descendants: Int? { get }
var time: Int { get }
var kids: [Int]? { get }
var metadata: String { get }
}
public extension Item {
var createdAtDate: Date {
let date = Date(timeIntervalSince1970: Double(time))
return date
}
var createdAt: String {
let date = Date(timeIntervalSince1970: Double(time))
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM d, yyyy"
return dateFormatter.string(from: date)
}
var timeAgo: String {
let date = Date(timeIntervalSince1970: Double(time))
return date.timeAgoString
}
var formattedTime: String {
Date(timeIntervalSince1970: Double(time)).formatted()
}
var itemUrl: String {
"https://news.ycombinator.com/item?id=\(self.id)"
}
var readableUrl: String? {
if let url = self.url {
let domain = URL(string: url)?.host
return domain
}
return nil
}
var isJob: Bool {
return type == "job"
}
var isJobWithUrl: Bool {
return type == "job" && text.isNullOrEmpty && url.isNotNullOrEmpty
}
}

View File

@ -0,0 +1,48 @@
import Foundation
public enum SearchFilter: Equatable, Hashable {
case story
case comment
case dateRange(Date, Date)
var query: String {
switch(self){
case .story:
return "story"
case .comment:
return "comment"
case .dateRange(let startDate, let endDate):
let startTimestamp = Int(startDate.timeIntervalSince1970.rounded())
let endTimestamp = Int(endDate.timeIntervalSince1970.rounded())
if startTimestamp != endTimestamp {
return "created_at_i>=\(startTimestamp),created_at_i<=\(endTimestamp)"
} else {
let updatedStartDate = Calendar.current.date(
byAdding: .hour,
value: -24,
to: startDate)
let updatedStartTimestamp = updatedStartDate?.timeIntervalSince1970
if let updatedStartTimestamp = updatedStartTimestamp?.rounded() {
return "created_at_i>=\(Int(updatedStartTimestamp)),created_at_i<=\(endTimestamp)"
}
return .init()
}
}
}
var isNumericFilter: Bool {
switch(self){
case .story, .comment:
return false
case .dateRange:
return true
}
}
var isTagFilter: Bool {
!isNumericFilter
}
}

View File

@ -0,0 +1,62 @@
import Foundation
public class SearchParams: Equatable {
public let page: Int
public let query: String
public let sorted: Bool
public let filters: Set<SearchFilter>
public var filteredQuery: String {
var buffer = String()
if sorted {
buffer.append("search_by_date?query=\(query)")
} else {
buffer.append("search?query=\(query)")
}
if !filters.isEmpty {
let numericFilters = filters.filter({ $0.isNumericFilter })
let tagFilters = filters.filter({ $0.isTagFilter })
if !numericFilters.isEmpty {
buffer.append("&numericFilters=")
for filter in filters.filter({ $0.isNumericFilter }) {
buffer.append(filter.query)
buffer.append(",")
}
buffer = String(buffer.dropLast(1))
}
if !tagFilters.isEmpty {
buffer.append("&tags=(")
for filter in filters.filter({ $0.isTagFilter }) {
buffer.append(filter.query)
buffer.append(",")
}
buffer = String(buffer.dropLast(1))
buffer.append(")")
}
}
buffer.append("&page=\(page)");
return buffer
}
public init(page: Int, query: String, sorted: Bool, filters: Set<SearchFilter>) {
self.page = page
self.query = query
self.sorted = sorted
self.filters = filters
}
public func copyWith(page: Int? = nil, query: String? = nil, sorted: Bool? = nil, filters: Set<SearchFilter>? = nil) -> SearchParams {
return SearchParams(page: page ?? self.page, query: query ?? self.query, sorted: sorted ?? self.sorted, filters: filters ?? self.filters)
}
public static func == (lhs: SearchParams, rhs: SearchParams) -> Bool {
return lhs.page == rhs.page && lhs.query == rhs.query && lhs.sorted == rhs.sorted && lhs.filters == rhs.filters
}
}

View File

@ -0,0 +1,98 @@
public extension Story {
var shortMetadata: String {
if isJob {
return "\(timeAgo)"
} else {
return "\(score.orZero) | \(descendants.orZero) | \(timeAgo)"
}
}
}
public struct Story: Item {
public let id: Int
public let parent: Int?
public let title: String?
public let text: String?
public let url: String?
public let type: String?
public let by: String?
public let score: Int?
public let descendants: Int?
public let time: Int
public let kids: [Int]?
public var metadata: String {
if isJob {
return "\(timeAgo) by \(by.orEmpty)"
} else {
return "\(score.orZero) pt\(score.orZero > 1 ? "s":"") | \(descendants.orZero) cmt\(descendants.orZero > 1 ? "s":"") | \(timeAgo) by \(by.orEmpty)"
}
}
public init(id: Int,
parent: Int? = nil,
title: String?,
text: String?,
url: String?,
type: String?,
by: String?,
score: Int?,
descendants: Int?,
time: Int,
kids: [Int]? = [Int]()) {
self.id = id
self.parent = parent
self.title = title
self.text = text
self.url = url
self.type = type
self.score = score
self.by = by
self.descendants = descendants
self.time = time
self.kids = kids
}
// Empty initializer
public init() {
self.init(
id: 0,
parent: 0,
title: "",
text: "",
url: "",
type: "story",
by: "",
score: 0,
descendants: 0,
time: 0
)
}
func copyWith(text: String?) -> Story {
.init(
id: id,
parent: parent,
title: title,
text: text,
url: url,
type: type,
by: by,
score: score,
descendants: descendants,
time: time,
kids: kids
)
}
public static let errorPlaceholder: Story = .init(
id: 0,
title: "Something went wrong...",
text: nil,
url: "retrying...",
type: "story",
by: nil,
score: nil,
descendants: nil,
time: 0
)
}

View File

@ -0,0 +1,56 @@
import AppIntents
import SwiftData
public enum StoryType: String, Equatable, CaseIterable, AppEnum, Codable {
case top = "top"
case best = "best"
case new = "new"
case ask = "ask"
case show = "show"
case jobs = "job"
public var icon: String {
switch self {
case .top:
return "flame"
case .best:
return "medal"
case .new:
return "rectangle.dashed"
case .ask:
return "questionmark.bubble"
case .show:
return "sparkles.tv"
case .jobs:
return "briefcase"
}
}
public var label: String {
switch self {
case .jobs:
return "jobs"
default:
return self.rawValue
}
}
public var isDownloadable: Bool {
switch self {
case .top, .ask, .best:
return true
default:
return false
}
}
public static var typeDisplayRepresentation: TypeDisplayRepresentation = "Story Type"
public static var caseDisplayRepresentations: [StoryType : DisplayRepresentation] = [
.top: "Top",
.best: "Best",
.new: "New",
.ask: "Ask HN",
.show: "Show HN",
.jobs: "YC Jobs"
]
}

View File

@ -0,0 +1,53 @@
import Foundation
public struct User: Decodable, Equatable {
public let id: String?
public let about: String?
public let created: Int?
public let delay: Int?
public let karma: Int?
public let submitted: [Int]?
public init() {
self.id = .init()
self.about = .init()
self.created = .init()
self.delay = .init()
self.karma = .init()
self.submitted = .init()
}
/// If a user does not have any activity, the user endpoint will not return anything.
/// in that case, we create a user with only username.
public init(id: String) {
self.id = id
self.about = .init()
self.created = .init()
self.delay = .init()
self.karma = .init()
self.submitted = .init()
}
init(id: String?, about: String?, created: Int?, delay: Int?, karma: Int?, submitted: [Int]?) {
self.id = id
self.about = about
self.created = created
self.delay = delay
self.karma = karma
self.submitted = submitted
}
func copyWith(about: String? = nil) -> User {
return User(id: id, about: about ?? self.about, created: created, delay: delay, karma: karma, submitted: submitted)
}
}
public extension User {
var createdAt: String? {
guard let created = created else { return nil }
let date = Date(timeIntervalSince1970: Double(created))
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM d, yyyy"
return dateFormatter.string(from: date)
}
}

View File

@ -0,0 +1,17 @@
import AppIntents
struct SelectStoryTypeIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Story Type"
static var description = IntentDescription("Select the type of story you want to see.")
@Parameter(title: "Story Type", default: StorySource.top)
var source: StorySource
init(source: StorySource) {
self.source = source
}
init() {
self.source = .top
}
}

View File

@ -0,0 +1,14 @@
import WidgetKit
import Foundation
struct StoryEntry: TimelineEntry {
let date: Date
let story: Story
let source: StorySource
static let errorPlaceholder: StoryEntry = StoryEntry(
date: .now,
story: .errorPlaceholder,
source: .top
)
}

View File

@ -0,0 +1,130 @@
import Foundation
import Alamofire
public class StoryRepository {
public static let shared: StoryRepository = .init()
private let baseUrl: String = "https://hacker-news.firebaseio.com/v0/"
private init() {}
// MARK: - Story related.
public func fetchAllStories(from storyType: StoryType, onStoryFetched: @escaping (Story) -> Void) async -> Void {
let storyIds = await fetchStoryIds(from: storyType)
for id in storyIds {
let story = await self.fetchStory(id)
if let story = story {
onStoryFetched(story)
}
}
}
public func fetchStoryIds(from storyType: StoryType) async -> [Int] {
let response = await AF.request("\(self.baseUrl)\(storyType.rawValue)stories.json").serializingString().response
guard response.data != nil else { return [Int]() }
let storyIds = try? JSONDecoder().decode([Int].self, from: response.data!)
return storyIds ?? [Int]()
}
public func fetchStoryIds(from storyType: String) async -> [Int] {
let response = await AF.request("\(self.baseUrl)\(storyType)stories.json").serializingString().response
guard response.data != nil else { return [Int]() }
let storyIds = try? JSONDecoder().decode([Int].self, from: response.data!)
return storyIds ?? [Int]()
}
public func fetchStories(ids: [Int], onStoryFetched: @escaping (Story) -> Void) async -> Void {
for id in ids {
let story = await fetchStory(id)
if let story = story {
onStoryFetched(story)
}
}
}
public func fetchStory(_ id: Int) async -> Story?{
let response = await AF.request("\(self.baseUrl)item/\(id).json").serializingString().response
if let data = response.data,
var story = try? JSONDecoder().decode(Story.self, from: data) {
let formattedText = story.text.htmlStripped
story = story.copyWith(text: formattedText)
return story
} else {
return nil
}
}
// MARK: - Comment related.
public func fetchComments(ids: [Int], onCommentFetched: @escaping (Comment) -> Void) async -> Void {
for id in ids {
let comment = await fetchComment(id)
if let comment = comment {
onCommentFetched(comment)
}
}
}
public func fetchComment(_ id: Int) async -> Comment? {
let response = await AF.request("\(self.baseUrl)item/\(id).json").serializingString().response
if let data = response.data,
var comment = try? JSONDecoder().decode(Comment.self, from: data) {
let formattedText = comment.text.htmlStripped
comment = comment.copyWith(text: formattedText)
return comment
} else {
return nil
}
}
// MARK: - Item related.
public func fetchItems(ids: [Int], filtered: Bool = true, onItemFetched: @escaping (any Item) -> Void) async -> Void {
for id in ids {
let item = await fetchItem(id)
guard let item = item else { continue }
if let story = item as? Story {
onItemFetched(story)
} else if let cmt = item as? Comment {
onItemFetched(cmt)
}
}
}
public func fetchItem(_ id: Int) async -> (any Item)? {
let response = await AF.request("\(self.baseUrl)item/\(id).json").serializingString().response
if let data = response.data,
let result = try? response.result.get(),
let map = result.toJSON() as? [String: AnyObject],
let type = map["type"] as? String {
switch type {
case "story":
let story = try? JSONDecoder().decode(Story.self, from: data)
let formattedText = story?.text.htmlStripped
return story?.copyWith(text: formattedText)
case "comment":
let comment = try? JSONDecoder().decode(Comment.self, from: data)
let formattedText = comment?.text.htmlStripped
return comment?.copyWith(text: formattedText)
default:
return nil
}
} else {
return nil
}
}
// MARK: - User related.
public func fetchUser(_ id: String) async -> User? {
let response = await AF.request("\(self.baseUrl)/user/\(id).json").serializingString().response
if let data = response.data,
let user = try? JSONDecoder().decode(User.self, from: data) {
let formattedText = user.about.orEmpty.htmlStripped
return user.copyWith(about: formattedText)
} else {
return nil
}
}
}

View File

@ -0,0 +1,21 @@
import AppIntents
enum StorySource: String, AppEnum {
case top
case best
case new
case ask
case show
case job
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Story Source"
static var caseDisplayRepresentations: [StorySource : DisplayRepresentation] = [
.top: "Top",
.best: "Best",
.new: "New",
.ask: "Ask",
.show: "Show",
.job: "Jobs"
]
}

View File

@ -0,0 +1,42 @@
import WidgetKit
struct StoryTimelineProvider: AppIntentTimelineProvider {
func snapshot(for configuration: SelectStoryTypeIntent, in context: Context) async -> StoryEntry {
let ids = await StoryRepository.shared.fetchStoryIds(from: configuration.source.rawValue)
guard let first = ids.first else { return .errorPlaceholder }
let story = await StoryRepository.shared.fetchStory(first)
guard let story = story else { return .errorPlaceholder }
let entry = StoryEntry(date: Date(), story: story, source: configuration.source)
return entry
}
func placeholder(in context: Context) -> StoryEntry {
let story = Story(
id: 0,
title: "This is a placeholder story",
text: "text",
url: "",
type: "story",
by: "Hacki",
score: 100,
descendants: 24,
time: Int(Date().timeIntervalSince1970)
)
return StoryEntry(date: Date(), story: story, source: .top)
}
func timeline(for configuration: SelectStoryTypeIntent, in context: Context) async -> Timeline<StoryEntry> {
let ids = await StoryRepository.shared.fetchStoryIds(from: configuration.source.rawValue)
guard let first = ids.first else {
return Timeline(entries: [.errorPlaceholder], policy: .atEnd)
}
let story = await StoryRepository.shared.fetchStory(first)
guard let story = story else {
return Timeline(entries: [.errorPlaceholder], policy: .atEnd)
}
let entry = StoryEntry(date: Date(), story: story, source: configuration.source)
let timeline = Timeline(entries: [entry], policy: .atEnd)
return timeline
}
}

View File

@ -0,0 +1,84 @@
import WidgetKit
import SwiftUI
import AppIntents
struct StoryWidgetView : View {
@Environment(\.widgetFamily) var family
@Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
var story: Story
var source: StorySource
var body: some View {
switch family {
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 0) {
Text(story.shortMetadata)
.font(.caption)
Text(story.title.orEmpty)
.font(.caption).fontWeight(.bold)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
Spacer(minLength: 0)
}
.containerBackground(for: .widget) {
Color(UIColor.secondarySystemBackground)
}
.widgetURL(URL(string: "/item/\(story.id)"))
default:
HStack {
VStack {
Text(story.title.orEmpty)
.font(family == .systemSmall ? .system(size: 14) : .body)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
if let text = story.text, text.isNotEmpty {
HStack {
Text(text.replacingOccurrences(of: "\n", with: " "))
.font(.footnote)
.lineLimit(3)
.foregroundColor(.gray)
Spacer()
}
}
Spacer()
HStack {
if let url = story.readableUrl {
Text(url)
.font(family == .systemSmall ? .system(size: 8) : .footnote)
.foregroundColor(.orange)
}
Spacer()
}
Divider().frame(maxWidth: .infinity)
HStack(alignment: .center) {
Text("\(source.rawValue.uppercased()) | \(story.metadata)")
.font(family == .systemSmall ? showsWidgetContainerBackground ? .system(size: 10) : .system(size: 8) : .caption)
.padding(.top, showsWidgetContainerBackground ? 2 : .zero)
Spacer()
}
}
}
.padding(.all, showsWidgetContainerBackground ? .zero : nil)
.containerBackground(for: .widget) {
Color(UIColor.secondarySystemBackground)
}
.widgetURL(URL(string: "/item/\(story.id)"))
}
}
}
struct StoryWidget: Widget {
let kind: String = "StoryWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectStoryTypeIntent.self,
provider: StoryTimelineProvider()) { entry in
StoryWidgetView(story: entry.story, source: entry.source)
}
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
.configurationDisplayName("Story on Hacker News")
.description("Watch out. It's hot.")
}
}

View File

@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct StoryWidgetBundle: WidgetBundle {
var body: some Widget {
StoryWidget()
}
}

View File

@ -0,0 +1,8 @@
import WidgetKit
extension Timeline where EntryType == StoryEntry {
static let errorPlaceholder: Timeline<StoryEntry> = .init(
entries: [.errorPlaceholder],
policy: .atEnd
)
}

View File

@ -0,0 +1,18 @@
import AppIntents
import HackerNewsKit
struct SelectStoryTypeIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Story Type"
static var description = IntentDescription("Select the type of story you want to see.")
@Parameter(title: "Story Type", default: StorySource.top)
var source: StorySource
init(source: StorySource) {
self.source = source
}
init() {
self.source = .top
}
}

View File

@ -0,0 +1,21 @@
import AppIntents
enum StorySource: String, AppEnum {
case top
case best
case new
case ask
case show
case job
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Story Source"
static var caseDisplayRepresentations: [StorySource : DisplayRepresentation] = [
.top: "Top",
.best: "Best",
.new: "New",
.ask: "Ask",
.show: "Show",
.job: "Jobs"
]
}

View File

@ -1,5 +1,5 @@
app_identifier("com.jiaqi.hacki") # The bundle identifier of your app
apple_id("georgefung78@Live.com") # Your Apple Developer Portal username
apple_id("georgefung78@live.com") # Your Apple Developer Portal username
itc_team_id("120097292") # App Store Connect Team ID
team_id("QMWX3X2NF7") # Developer Portal Team ID

View File

@ -34,7 +34,7 @@ platform :ios do
# Download code signing certificates using `match` (and the `MATCH_PASSWORD` secret)
sync_code_signing(
type: "appstore",
app_identifier: [APP_IDENTIFIER, "#{APP_IDENTIFIER}.Share-Extension", "#{APP_IDENTIFIER}.Action-Extension"],
app_identifier: [APP_IDENTIFIER, "#{APP_IDENTIFIER}.Share-Extension", "#{APP_IDENTIFIER}.Action-Extension", "#{APP_IDENTIFIER}.Widget-Extension"],
readonly: true
)

View File

@ -3,40 +3,41 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
import 'package:responsive_builder/responsive_builder.dart';
import 'package:rxdart/rxdart.dart';
part 'stories_event.dart';
part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
StoriesBloc({
required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: sequential(),
transformer: concurrent(),
);
on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh);
@ -52,53 +53,55 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoryDownloaded>(onStoryDownloaded);
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
on<StoriesExitOfflineMode>(onExitOfflineMode);
on<StoriesPageSizeChanged>(onPageSizeChanged);
on<ClearAllReadStories>(onClearAllReadStories);
on<UpdateMaxOfflineStoriesCount>(onUpdateMaxOfflineStoriesCount);
on<ClearMaxOfflineStoriesCount>(onClearMaxOfflineStoriesCount);
_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.twoSeconds)
.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));
}
@ -108,41 +111,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));
}
}
@ -172,62 +204,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)));
}
}
@ -243,8 +312,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);
@ -279,12 +351,16 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
final Set<int> prioritizedIds = <int>{};
List<int> prioritizedIds = <int>[];
/// Prioritizing all types of stories except StoryType.latest since
/// new stories tend to have less or no comment at all.
final List<StoryType> prioritizedTypes = <StoryType>[...StoryType.values]
..remove(StoryType.latest);
final List<StoryType> prioritizedTypes = <StoryType>[
StoryType.top,
StoryType.best,
StoryType.ask,
StoryType.show,
];
for (final StoryType type in prioritizedTypes) {
final List<int> ids =
@ -293,6 +369,14 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
prioritizedIds.addAll(ids);
}
prioritizedIds = prioritizedIds.toSet().toList().sublist(
0,
min(
state.maxOfflineStoriesCount?.count ?? prioritizedIds.length,
prioritizedIds.length,
),
);
emit(
state.copyWith(
storiesDownloaded: 0,
@ -348,20 +432,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) {
@ -381,7 +465,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);
}
@ -398,18 +482,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));
@ -452,13 +536,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,
@ -475,6 +552,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
add(StoriesInitialize());
}
Future<void> onUpdateMaxOfflineStoriesCount(
UpdateMaxOfflineStoriesCount event,
Emitter<StoriesState> emit,
) async {
emit(state.copyWith(maxOfflineStoriesCount: event.count));
}
Future<void> onClearMaxOfflineStoriesCount(
ClearMaxOfflineStoriesCount event,
Emitter<StoriesState> emit,
) async {
emit(state.copyWithMaxOfflineStoriesCountReset());
}
Future<void> onStoryRead(
StoryRead event,
Emitter<StoriesState> emit,
@ -516,19 +607,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,13 +104,20 @@ class StoriesEnterOfflineMode extends StoriesEvent {
List<Object?> get props => <Object?>[];
}
class StoriesPageSizeChanged extends StoriesEvent {
StoriesPageSizeChanged({required this.pageSize});
class UpdateMaxOfflineStoriesCount extends StoriesEvent {
UpdateMaxOfflineStoriesCount({required this.count});
final int pageSize;
final MaxOfflineStoriesCount count;
@override
List<Object?> get props => <Object?>[pageSize];
List<Object?> get props => <Object?>[count];
}
class ClearMaxOfflineStoriesCount extends StoriesEvent {
ClearMaxOfflineStoriesCount();
@override
List<Object?> get props => <Object?>[];
}
class StoryLoaded extends StoriesEvent {

View File

@ -17,9 +17,10 @@ class StoriesState extends Equatable {
required this.readStoriesIds,
required this.isOfflineReading,
required this.downloadStatus,
required this.currentPageSize,
required this.storiesDownloaded,
required this.storiesToBeDownloaded,
required this.maxOfflineStoriesCount,
required this.dataSource,
});
const StoriesState.init({
@ -53,10 +54,11 @@ class StoriesState extends Equatable {
},
}) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.idle,
currentPageSize = 0,
readStoriesIds = const <int>{},
storiesDownloaded = 0,
storiesToBeDownloaded = 0;
storiesToBeDownloaded = 0,
maxOfflineStoriesCount = null,
dataSource = null;
final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType;
@ -65,9 +67,10 @@ class StoriesState extends Equatable {
final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus;
final bool isOfflineReading;
final int currentPageSize;
final int storiesDownloaded;
final int storiesToBeDownloaded;
final MaxOfflineStoriesCount? maxOfflineStoriesCount;
final HackerNewsDataSource? dataSource;
StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType,
@ -77,9 +80,10 @@ class StoriesState extends Equatable {
Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus,
bool? isOfflineReading,
int? currentPageSize,
int? storiesDownloaded,
int? storiesToBeDownloaded,
MaxOfflineStoriesCount? maxOfflineStoriesCount,
HackerNewsDataSource? dataSource,
}) {
return StoriesState(
storiesByType: storiesByType ?? this.storiesByType,
@ -89,10 +93,28 @@ 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,
maxOfflineStoriesCount:
maxOfflineStoriesCount ?? this.maxOfflineStoriesCount,
);
}
StoriesState copyWithMaxOfflineStoriesCountReset() {
return StoriesState(
storiesByType: storiesByType,
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: currentPageByType,
readStoriesIds: readStoriesIds,
isOfflineReading: isOfflineReading,
downloadStatus: downloadStatus,
storiesDownloaded: storiesDownloaded,
storiesToBeDownloaded: storiesToBeDownloaded,
dataSource: dataSource,
maxOfflineStoriesCount: null,
);
}
@ -179,8 +201,9 @@ class StoriesState extends Equatable {
readStoriesIds,
isOfflineReading,
downloadStatus,
currentPageSize,
storiesDownloaded,
storiesToBeDownloaded,
dataSource,
maxOfflineStoriesCount,
];
}

View File

@ -43,7 +43,7 @@ abstract class Constants {
'٩(˘◡˘)۶',
'(─‿‿─)',
'(¬‿¬)',
].pickRandomly()!;
].randomlyPicked!;
static final String sadFace = <String>[
'ಥ_ಥ',
@ -55,13 +55,13 @@ abstract class Constants {
'(ㆆ_ㆆ)',
'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)',
].pickRandomly()!;
].randomlyPicked!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
].randomlyPicked!;
static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
@ -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

@ -3,7 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item/item.dart';
import 'package:hacki/repositories/hacker_news_repository.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/dimens.dart';
final GoRouter router = GoRouter(
observers: <NavigatorObserver>[
@ -25,52 +29,74 @@ final GoRouter router = GoRouter(
return ItemScreen.phone(args);
},
),
GoRoute(
path: '${ItemScreen.routeName}/:itemId',
builder: (BuildContext context, GoRouterState state) {
final String? itemIdStr = state.pathParameters['itemId'];
final int? itemId = itemIdStr?.itemId;
if (itemId == null) {
throw GoError("item id can't be null");
}
return FutureBuilder<Item?>(
future: locator.get<HackerNewsRepository>().fetchItem(id: itemId),
builder: (BuildContext context, AsyncSnapshot<Item?> snapshot) {
if (snapshot.hasData) {
final ItemScreenArgs args =
ItemScreenArgs(item: snapshot.data!);
return ItemScreen.phone(args);
} else {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
),
);
}
},
);
},
),
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),
);
@ -500,7 +499,7 @@ class CommentsCubit extends Cubit<CommentsState> {
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.nonNulls
.toList();
if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
@ -521,7 +520,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final int firstVisibleRootCommentIndex =
state.comments.indexOf(firstVisibleRootComment);
startIndex = min(firstVisibleRootCommentIndex + 1, totalComments);
} else {
} else if (onScreenComments.isNotEmpty) {
final int lastVisibleCommentIndex =
state.comments.indexOf(onScreenComments.last);
startIndex = min(lastVisibleCommentIndex + 1, totalComments);
@ -558,7 +557,7 @@ class CommentsCubit extends Cubit<CommentsState> {
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.nonNulls
.toList();
/// The index of first comment visible on screen.
@ -732,4 +731,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
await super.close();
}
@override
String get logIdentifier => '[CommentsCubit]';
}

View File

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

View File

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

View File

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

@ -52,9 +52,9 @@ class PreferenceState extends Equatable {
bool get isComplexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get isFaviconEnabled => _isOn<FaviconModePreference>();
bool get isDividerEnabled => _isOn<DividerPreference>();
bool get isEyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get isFaviconEnabled => _isOn<FaviconModePreference>();
bool get isReaderEnabled => _isOn<ReaderModePreference>();
@ -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();
@ -128,9 +129,11 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
}
if (linkToShare != null) {
await Share.share(
linkToShare,
sharePositionOrigin: rect,
await SharePlus.instance.share(
ShareParams(
uri: Uri.parse(linkToShare),
sharePositionOrigin: rect,
),
);
}
}
@ -164,7 +167,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 +205,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

@ -1,7 +1,7 @@
import 'dart:math';
extension ListExtension<T> on List<T> {
T? pickRandomly() {
T? get randomlyPicked {
if (isEmpty) return null;
final Random random = Random(DateTime.now().millisecondsSinceEpoch);
final int luckyNumber = random.nextInt(length);

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