Compare commits

..

39 Commits

Author SHA1 Message Date
7747aa6b0e Merge branch 'master' into jf/android-build-fix 2025-06-29 18:44:36 -07:00
fda0b8ede5 fix: android min sdk version. (#523) 2025-06-29 18:42:38 -07:00
bb51b3b0cb update podfile.lock. 2025-06-29 17:58:05 -07:00
3ff8e82966 fix: android min sdk version. 2025-06-29 17:47:23 -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
113 changed files with 3662 additions and 1082 deletions

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]

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

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

@ -63,18 +63,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev"
source: hosted
version: "10.0.4"
version: "10.0.5"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.5"
leak_tracker_testing:
dependency: transitive
description:
@ -95,18 +95,18 @@ packages:
dependency: transitive
description:
name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.15.0"
path:
dependency: transitive
description:
@ -164,10 +164,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
version: "0.7.2"
vector_math:
dependency: transitive
description:
@ -180,10 +180,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.1"
version: "14.2.5"
sdks:
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

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,18 +1,78 @@
//
// 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] = []
var sharedMedia: [SharedMediaFile] = []
let urlContentType = UTType.url
@IBOutlet weak var imageView: UIImageView!
@ -39,13 +99,11 @@ class ActionViewController: UIViewController {
// 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-\(hostAppBundleIdentifier)://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,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,7 +1,6 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@ -10,13 +9,13 @@ 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_native_splash (0.0.1):
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
@ -25,23 +24,23 @@ PODS:
- 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):
@ -52,11 +51,12 @@ PODS:
- 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`)
@ -68,15 +68,15 @@ DEPENDENCIES:
- 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_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/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:
@ -109,16 +109,16 @@ 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:
@ -126,36 +126,36 @@ EXTERNAL SOURCES:
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_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
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_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
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: f03c7c11cf2b623592c89c68c628682778bb78b4
COCOAPODS: 1.15.2
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 */
@ -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;
@ -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 */
@ -563,7 +748,6 @@
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;
@ -575,7 +759,7 @@
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",
@ -706,7 +890,6 @@
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;
@ -718,7 +901,7 @@
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",
@ -739,7 +922,6 @@
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;
@ -753,7 +935,7 @@
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",
@ -789,7 +971,7 @@
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -831,7 +1013,7 @@
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -869,7 +1051,7 @@
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -908,7 +1090,7 @@
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -952,7 +1134,7 @@
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -992,7 +1174,7 @@
INFOPLIST_FILE = "Action Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Hacki";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1010,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 */
@ -1053,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

@ -46,7 +46,7 @@
<string>mailto</string>
</array>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<string>14.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>

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,187 +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 = UTType.image
let videoContentType = UTType.movie
let textContentType = UTType.text
let urlContentType = UTType.url
let fileURLType = UTType.fileURL
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.identifier) {
handleImages(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(textContentType.identifier) {
handleText(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(fileURLType.identifier) {
handleFiles(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(urlContentType.identifier) {
handleUrl(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType.identifier) {
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.identifier, options: nil) { [weak self] data, error in
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()
}
}
}
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 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.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.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.identifier, 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.identifier, 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: .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)
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)
}
// 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)
responder = responder?.next
}
} else {
let selectorOpenURL = sel_registerName("openURL:")
while (responder != nil) {
if (responder?.responds(to: selectorOpenURL))! {
_ = responder?.perform(selectorOpenURL, with: url)
}
} else {
self?.dismissWithError()
responder = responder!.next
}
}
}
private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: fileURLType.identifier, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
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()
}
}
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
private func dismissWithError() {
@ -199,57 +288,38 @@ class ShareViewController: SLComposeServiceViewController {
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
private func redirectToHostApp(type: RedirectType) {
let url = URL(string: "ShareMedia-\(hostAppBundleIdentifier)://dataUrl=\(sharedKey)#\(type)")
var responder = self as UIResponder?
let selectorOpenURL = sel_registerName("openURL:")
while (responder != nil) {
if (responder?.responds(to: selectorOpenURL))! {
let _ = responder?.perform(selectorOpenURL, with: url)
}
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)
@ -262,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
@ -277,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: \(String(describing: self.thumbnail))\n\tduration: \(String(describing: 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

@ -14,7 +14,6 @@ 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> with Loggable {
@ -55,6 +54,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
on<StoriesEnterOfflineMode>(onEnterOfflineMode);
on<StoriesExitOfflineMode>(onExitOfflineMode);
on<ClearAllReadStories>(onClearAllReadStories);
on<UpdateMaxOfflineStoriesCount>(onUpdateMaxOfflineStoriesCount);
on<ClearMaxOfflineStoriesCount>(onClearMaxOfflineStoriesCount);
_preferenceSubscription = _preferenceCubit.stream
.distinct((PreferenceState lhs, PreferenceState rhs) {
@ -126,7 +127,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
.copyWithStatusUpdated(type: type, to: Status.inProgress),
);
_offlineRepository
.getCachedStoriesStream(ids: ids.sublist(0, _pageSize))
.getCachedStoriesStream(
ids: ids.sublist(0, min(_pageSize, ids.length)),
)
.listen((Story story) => add(StoryLoaded(story: story, type: type)))
.onDone(() => add(StoryLoadingCompleted(type: type)));
} else if (event.useApi || state.dataSource == HackerNewsDataSource.api) {
@ -142,7 +145,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
await _hackerNewsRepository
.fetchStoriesStream(
ids: ids.sublist(0, _pageSize),
ids: ids.sublist(0, min(_pageSize, ids.length)),
sequential: true,
)
.listen((Story story) {
@ -348,12 +351,16 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
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 =
@ -362,6 +369,14 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
prioritizedIds.addAll(ids);
}
prioritizedIds = prioritizedIds.toSet().toList().sublist(
0,
min(
state.maxOfflineStoriesCount?.count ?? prioritizedIds.length,
prioritizedIds.length,
),
);
emit(
state.copyWith(
storiesDownloaded: 0,
@ -537,6 +552,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
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,

View File

@ -104,6 +104,22 @@ class StoriesEnterOfflineMode extends StoriesEvent {
List<Object?> get props => <Object?>[];
}
class UpdateMaxOfflineStoriesCount extends StoriesEvent {
UpdateMaxOfflineStoriesCount({required this.count});
final MaxOfflineStoriesCount count;
@override
List<Object?> get props => <Object?>[count];
}
class ClearMaxOfflineStoriesCount extends StoriesEvent {
ClearMaxOfflineStoriesCount();
@override
List<Object?> get props => <Object?>[];
}
class StoryLoaded extends StoriesEvent {
StoryLoaded({required this.story, required this.type});

View File

@ -19,6 +19,7 @@ class StoriesState extends Equatable {
required this.downloadStatus,
required this.storiesDownloaded,
required this.storiesToBeDownloaded,
required this.maxOfflineStoriesCount,
required this.dataSource,
});
@ -56,6 +57,7 @@ class StoriesState extends Equatable {
readStoriesIds = const <int>{},
storiesDownloaded = 0,
storiesToBeDownloaded = 0,
maxOfflineStoriesCount = null,
dataSource = null;
final Map<StoryType, List<Story>> storiesByType;
@ -67,6 +69,7 @@ class StoriesState extends Equatable {
final bool isOfflineReading;
final int storiesDownloaded;
final int storiesToBeDownloaded;
final MaxOfflineStoriesCount? maxOfflineStoriesCount;
final HackerNewsDataSource? dataSource;
StoriesState copyWith({
@ -79,6 +82,7 @@ class StoriesState extends Equatable {
bool? isOfflineReading,
int? storiesDownloaded,
int? storiesToBeDownloaded,
MaxOfflineStoriesCount? maxOfflineStoriesCount,
HackerNewsDataSource? dataSource,
}) {
return StoriesState(
@ -93,6 +97,24 @@ class StoriesState extends Equatable {
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,
);
}
@ -182,5 +204,6 @@ class StoriesState extends Equatable {
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 =

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,6 +29,34 @@ 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(),

View File

@ -499,7 +499,7 @@ class CommentsCubit extends Cubit<CommentsState> with Loggable {
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.nonNulls
.toList();
if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
@ -520,7 +520,7 @@ class CommentsCubit extends Cubit<CommentsState> with Loggable {
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);
@ -557,7 +557,7 @@ class CommentsCubit extends Cubit<CommentsState> with Loggable {
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.nonNulls
.toList();
/// The index of first comment visible on screen.

View File

@ -19,6 +19,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
SembastRepository? sembastRepository,
}) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository =
@ -27,6 +28,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
super(FavState.init()) {
init();
}
@ -36,8 +39,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
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
@ -55,6 +59,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
getFromCache: (int id) =>
_sembastRepository.getCachedItem(id: id),
)
.listen(_onItemLoaded)
.onDone(() {
@ -97,7 +103,10 @@ class FavCubit extends Cubit<FavState> with Loggable {
void removeFav(int id) {
_preferenceRepository
..removeFav(username: username, id: id)
..removeFav(username: '', id: id);
..removeFav(
username: '',
id: id,
);
emit(
state.copyWith(
@ -200,6 +209,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
}
void _onItemLoaded(Item item) {
_sembastRepository.cacheItem(item);
emit(
state.copyWith(
favItems: List<Item>.from(state.favItems)..add(item),
@ -207,6 +217,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
);
}
void switchTab() =>
emit(state.copyWith(isDisplayingStories: !state.isDisplayingStories));
@override
Future<void> close() {
_usernameSubscription?.cancel();

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

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

View File

@ -1,13 +1,14 @@
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:hydrated_bloc/hydrated_bloc.dart';
part 'split_view_state.dart';
class SplitViewCubit extends Cubit<SplitViewState> with Loggable {
class SplitViewCubit extends HydratedCubit<SplitViewState> with Loggable {
SplitViewCubit({
CommentCache? commentCache,
}) : _commentCache = commentCache ?? locator.get<CommentCache>(),
@ -25,8 +26,36 @@ class SplitViewCubit extends Cubit<SplitViewState> with Loggable {
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

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

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

View File

@ -5,7 +5,6 @@ import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@ -54,9 +53,7 @@ Future<void> main({bool testing = false}) async {
Hive.init(tempPath);
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
: await getTemporaryDirectory(),
storageDirectory: HydratedStorageDirectory(tempPath),
);
HydratedBloc.storage = storage;
@ -289,6 +286,7 @@ class HackiApp extends StatelessWidget {
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
);
return FeatureDiscovery(
child: MediaQuery(
@ -314,15 +312,16 @@ class HackiApp extends StatelessWidget {
? Palette.black
: null,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
color: Palette.grey.withValues(alpha: 0.2),
),
switchTheme: SwitchThemeData(
trackColor: WidgetStateProperty.resolveWith(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary.withOpacity(0.6);
return colorScheme.primary
.withValues(alpha: 0.6);
} else {
return Palette.grey.withOpacity(0.2);
return Palette.grey.withValues(alpha: 0.2);
}
},
),
@ -345,13 +344,13 @@ class HackiApp extends StatelessWidget {
color: (isDarkModeEnabled
? Palette.white
: Palette.black)
.withOpacity(0.4),
.withValues(alpha: 0.4),
),
),
),
sliderTheme: SliderThemeData(
inactiveTrackColor:
colorScheme.primary.withOpacity(0.5),
colorScheme.primary.withValues(alpha: 0.5),
activeTrackColor: colorScheme.primary,
thumbColor: colorScheme.primary,
),

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
enum MaxOfflineStoriesCount {
ten(20, '20'),
fifty(50, '50'),
hundred(100, '100'),
twoHundred(200, '200'),
all(null, 'All');
const MaxOfflineStoriesCount(this.count, this.label);
final int? count;
final String label;
}

View File

@ -8,6 +8,7 @@ export 'font.dart';
export 'font_size.dart';
export 'hacker_news_data_source.dart';
export 'item/item.dart';
export 'max_offline_stories_count.dart';
export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';

View File

@ -37,6 +37,7 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
const FaviconModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const DividerPreference(),
/// Divider.
const MarkReadStoriesModePreference(),
@ -50,7 +51,6 @@ abstract final class Preference<T> extends Equatable with SettingsDisplayable {
const ManualPaginationPreference(),
const SwipeGesturePreference(),
const HapticFeedbackPreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
const DevMode(),
],
@ -110,11 +110,11 @@ final class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode';
@override
String get title => 'Swipe Gesture';
String get title => 'Swipe Gesture for Switching Tabs';
@override
String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu and double tap to open the url (if complex tile is disabled).''';
}
final class NotificationModePreference extends BooleanPreference {
@ -270,6 +270,27 @@ final class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.''';
}
final class DividerPreference extends BooleanPreference {
const DividerPreference({bool? val})
: super(val: val ?? _dividerDefaultValue);
static const bool _dividerDefaultValue = true;
@override
DividerPreference copyWith({required bool? val}) {
return DividerPreference(val: val);
}
@override
String get key => 'dividerPreference';
@override
String get title => 'Divider';
@override
String get subtitle => '''show divider between story tiles.''';
}
final class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue);
@ -316,27 +337,6 @@ final class MarkReadStoriesModePreference extends BooleanPreference {
String get subtitle => 'grey out stories you have read.';
}
final class EyeCandyModePreference extends BooleanPreference {
const EyeCandyModePreference({bool? val})
: super(val: val ?? _eyeCandyModeDefaultValue);
static const bool _eyeCandyModeDefaultValue = false;
@override
EyeCandyModePreference copyWith({required bool? val}) {
return EyeCandyModePreference(val: val);
}
@override
String get key => 'eyeCandyMode';
@override
String get title => 'Eye Candy';
@override
String get subtitle => 'some sort of magic.';
}
final class ManualPaginationPreference extends BooleanPreference {
const ManualPaginationPreference({bool? val})
: super(val: val ?? _paginationModeDefaultValue);

View File

@ -302,24 +302,32 @@ class HackerNewsRepository with Loggable {
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
Stream<Item> fetchItemsStream({
required List<int> ids,
Future<Item?> Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Item? cachedItem = await getFromCache?.call(id);
if (cachedItem != null) {
yield cachedItem;
} else {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
if (json.isStory) {
final Story story = Story.fromJson(json);
return story;
} else if (json.isComment) {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
return null;
});
if (item != null) {
yield item;
}
}
}

View File

@ -5,10 +5,12 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/hacker_news_repository.dart';
import 'package:hacki/utils/utils.dart';
@ -17,13 +19,18 @@ import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
class HackerNewsWebRepository with Loggable {
HackerNewsWebRepository({
RemoteConfigCubit? remoteConfigCubit,
HackerNewsRepository? hackerNewsRepository,
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
}) : _dio = dio ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
],
),
_dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll(
<Interceptor>[
@ -34,10 +41,18 @@ class HackerNewsWebRepository {
_remoteConfigCubit =
remoteConfigCubit ?? locator.get<RemoteConfigCubit>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>();
hackerNewsRepository ?? locator.get<HackerNewsRepository>() {
_dio.interceptors.add(RetryInterceptor(dio: _dio));
}
/// The client for fetching comments. We should be careful
/// while fetching comments because it will easily trigger
/// 503 from the server.
final Dio _dioWithCache;
/// The client for fetching stories.
final Dio _dio;
final RemoteConfigCubit _remoteConfigCubit;
final HackerNewsRepository _hackerNewsRepository;
@ -66,6 +81,10 @@ class HackerNewsWebRepository {
String get _moreLinkSelector => _remoteConfigCubit.state.moreLinkSelector;
static final Map<int, int> _next = <int, int>{};
static const List<int> _rateLimitedStatusCode = <int>[
HttpStatus.forbidden,
HttpStatus.serviceUnavailable,
];
Stream<Story> fetchStoriesStream(
StoryType storyType, {
@ -84,6 +103,7 @@ class HackerNewsWebRepository {
StoryType.latest =>
'$_storiesBaseUrl/${storyType.webPathParam}?next=${_next[page]}'
};
final Uri url = Uri.parse(urlStr);
final Options option = Options(
headers: _headers,
@ -125,8 +145,9 @@ class HackerNewsWebRepository {
(elements.elementAt(index), subtextElements.elementAt(index)),
);
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
logError('error fetching stories on page $page: $e');
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
throw RateLimitedWithFallbackException(e.response?.statusCode);
}
throw GenericException();
}
@ -154,7 +175,8 @@ class HackerNewsWebRepository {
subtextElement.querySelector(_ageSelector) ??
subtextElement.querySelector('.age');
final String? dateStr = postDateElement?.attributes['title'];
final String? dateStr =
postDateElement?.attributes['title']?.split(' ').firstOrNull;
final int? timestamp = dateStr == null
? null
: DateTime.parse(dateStr)
@ -257,11 +279,12 @@ class HackerNewsWebRepository {
final List<Element> elements =
document.querySelectorAll(_aThingSelector);
final Iterable<int> parsedIds =
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
elements.map((Element e) => int.tryParse(e.id)).nonNulls;
return parsedIds;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedException();
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
logError('error fetching favorites on page $page: $e');
throw RateLimitedException(e.response?.statusCode);
}
throw GenericException();
}
@ -338,8 +361,9 @@ class HackerNewsWebRepository {
document.querySelectorAll(_athingComtrSelector);
return elements;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
logError('error fetching comments on page $page: $e');
throw RateLimitedWithFallbackException(e.response?.statusCode);
}
throw GenericException();
}
@ -378,7 +402,8 @@ class HackerNewsWebRepository {
/// Get comment age.
final Element? cmtAgeElement =
element.querySelector(_commentAgeSelector);
final String? ageString = cmtAgeElement?.attributes['title'];
final String? ageString =
cmtAgeElement?.attributes['title']?.split(' ').firstOrNull;
final int? timestamp = ageString == null
? null
@ -484,4 +509,7 @@ class HackerNewsWebRepository {
)
.trim();
}
@override
String get logIdentifier => 'HackerNewsWebRepository';
}

View File

@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/utils/debouncer.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart';
@ -402,10 +404,20 @@ class PreferenceRepository with Loggable {
);
}
final List<String> _storiesIdQueue = <String>[];
final Debouncer _debouncer = Debouncer(delay: AppDurations.tenSeconds);
Future<void> addHasRead(int storyId) async {
final String key = _getHasReadKey(storyId);
if (Platform.isIOS) {
await _syncedPrefs.setBool(key: key, val: true);
_storiesIdQueue.add(key);
_debouncer.run(() {
for (final String key in _storiesIdQueue) {
_syncedPrefs.setBool(key: key, val: true);
}
_storiesIdQueue.clear();
});
} else {
final SharedPreferences prefs = await _prefs;

View File

@ -1,15 +1,17 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading.
/// This is currently used by [TimeMachineCubit], [NotificationCubit] and
/// [FavCubit].
///
/// Sembast [Database] is used as its database and is being stored in the
/// documents directory assigned by host system which you can retrieve
@ -67,7 +69,7 @@ class SembastRepository with Loggable {
return db;
}
//#region Cached comments for time machine feature.
//#region Cached comments for time machine feature and favorites screen.
Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
@ -89,7 +91,34 @@ class SembastRepository with Loggable {
}
}
Future<int> deleteAllCachedComments() async {
Future<Map<String, Object?>> cacheItem(Item item) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
return store.record(item.id).put(db, item.toJson());
}
Future<Item?> getCachedItem({required int id}) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await store.record(id).getSnapshot(db);
if (snapshot != null) {
final bool isStory = snapshot['type'] == 'story';
if (isStory) {
final Story story = Story.fromJson(snapshot.value);
return story;
} else {
final Comment comment = Comment.fromJson(snapshot.value);
return comment;
}
} else {
return null;
}
}
Future<int> deleteAllCachedItems() async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);

View File

@ -36,7 +36,8 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin, Loggable {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<List<SharedMediaFile>>
intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
@ -59,10 +60,13 @@ class _HomeScreenState extends State<HomeScreen>
void initState() {
super.initState();
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
ReceiveSharingIntent.instance
.getInitialMedia()
.then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
intentDataStreamSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
@ -113,7 +117,8 @@ class _HomeScreenState extends State<HomeScreen>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isSwipeGestureEnabled != current.isSwipeGestureEnabled,
previous.isSwipeGestureEnabled != current.isSwipeGestureEnabled ||
previous.isDividerEnabled != current.isDividerEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return DefaultTabController(
length: tabLength,
@ -222,12 +227,12 @@ class _HomeScreenState extends State<HomeScreen>
}
}
void onShareExtensionTapped(String? event) {
void onShareExtensionTapped(List<SharedMediaFile>? event) {
logInfo('share intent received: $event');
if (event == null) return;
final int? id = event.itemId;
final int? id = event.firstOrNull?.path.itemId;
if (id != null) {
locator.get<HackerNewsRepository>().fetchItem(id: id).then((Item? item) {

View File

@ -45,8 +45,9 @@ class PinnedStories extends StatelessWidget {
],
),
child: ColoredBox(
color:
Theme.of(context).colorScheme.primary.withOpacity(0.2),
color: Theme.of(context).colorScheme.primary.withValues(
alpha: 0.2,
),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
@ -59,11 +60,15 @@ class PinnedStories extends StatelessWidget {
),
),
),
if (state.pinnedStories.isNotEmpty)
if (state.pinnedStories.isNotEmpty &&
!preferenceState.isDividerEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.8),
),
),
],

View File

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

View File

@ -309,7 +309,7 @@ class _ItemScreenState extends State<ItemScreen>
context: context,
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
.withValues(alpha: 0.6),
foregroundColor:
Theme.of(context).iconTheme.color,
item: widget.item,
@ -351,7 +351,7 @@ class _ItemScreenState extends State<ItemScreen>
appBar: CustomAppBar(
context: context,
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
Theme.of(context).canvasColor.withValues(alpha: 0.6),
foregroundColor: Theme.of(context).iconTheme.color,
item: widget.item,
onFontSizeTap: onFontSizeTapped,

View File

@ -33,9 +33,9 @@ class CustomFloatingActionButton extends StatelessWidget {
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpUpButton,
contentLocation: ContentLocation.above,
tapTarget: const Icon(
tapTarget: Icon(
Icons.keyboard_arrow_up,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
child: InkWell(
onLongPress: () =>
@ -59,9 +59,9 @@ class CustomFloatingActionButton extends StatelessWidget {
),
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpDownButton,
tapTarget: const Icon(
tapTarget: Icon(
Icons.keyboard_arrow_down,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
child: InkWell(
onLongPress: () {

View File

@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class FavIconButton extends StatelessWidget {
@ -24,7 +23,7 @@ class FavIconButton extends StatelessWidget {
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
feature: DiscoverableFeature.addStoryToFavList,
child: Icon(

View File

@ -22,9 +22,9 @@ class InThreadSearchIconButton extends StatelessWidget {
transitionType: ContainerTransitionType.fadeThrough,
closedBuilder: (BuildContext context, void Function() action) {
return CustomDescribedFeatureOverlay(
tapTarget: const Icon(
tapTarget: Icon(
Icons.search,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
feature: DiscoverableFeature.searchInThread,
child: IconButton(

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget {
@ -17,13 +16,13 @@ class LinkIconButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Open this story in browser',
icon: const CustomDescribedFeatureOverlay(
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
Icons.stream,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
feature: DiscoverableFeature.openStoryInWebView,
child: Icon(
child: const Icon(
Icons.stream,
),
),

View File

@ -6,7 +6,6 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class PinIconButton extends StatelessWidget {
@ -31,7 +30,7 @@ class PinIconButton extends StatelessWidget {
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
feature: DiscoverableFeature.pinToTop,
child: Icon(

View File

@ -245,6 +245,8 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
hintStyle: TextStyle(
color: Palette.grey,
),
enabledBorder: InputBorder.none,
disabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
border: InputBorder.none,
),

View File

@ -17,7 +17,9 @@ class LogScreen extends StatelessWidget {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Theme.of(context).canvasColor.withOpacity(0.6),
backgroundColor: Theme.of(context).canvasColor.withValues(
alpha: 0.6,
),
elevation: 0,
actions: <Widget>[
if (snapshot.data != null)
@ -38,10 +40,12 @@ class LogScreen extends StatelessWidget {
),
],
),
body: ListView(
children: <Widget>[
...?snapshot.data?.map(Text.new),
],
body: Scrollbar(
child: ListView(
children: <Widget>[
...?snapshot.data?.map(Text.new),
],
),
),
);
},

View File

@ -1,7 +1,5 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
@ -130,124 +128,12 @@ class _ProfileScreenState extends State<ProfileScreen>
top: Dimens.pt50,
child: Visibility(
visible: pageType == PageType.fav,
child: BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == Status.success) {
refreshControllerFav
..refreshCompleted()
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length,
builder: (BuildContext context, FavState favState) {
Widget? header() => authState.isLoggedIn
? BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () =>
context.read<FavCubit>().merge(
onError: (AppException e) =>
showErrorSnackBar(e.message),
onSuccess: () => showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child:
CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text('Sync from Hacker News'),
);
},
)
: null;
if (favState.favItems.isEmpty &&
favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled !=
current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile:
prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile:
prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshControllerFav,
items: favState.favItems,
onRefresh: () {
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item),
),
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context
.read<FavCubit>()
.removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
child: FavoritesScreen(
refreshController: refreshControllerFav,
authState: authState,
onItemTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item),
),
),
),
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/styles/styles.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
class QrCodeScannerScreen extends StatefulWidget {
const QrCodeScannerScreen({super.key});
@ -64,10 +64,4 @@ class _QrCodeScannerScreenState extends State<QrCodeScannerScreen> {
}
});
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/profile/widgets/centered_message_view.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({
required this.refreshController,
required this.authState,
required this.onItemTap,
super.key,
});
final RefreshController refreshController;
final AuthState authState;
final void Function(Item) onItemTap;
@override
Widget build(BuildContext context) {
return BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == Status.success) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length ||
previous.isDisplayingStories != current.isDisplayingStories,
builder: (BuildContext context, FavState favState) {
Widget? header() => Column(
children: <Widget>[
if (authState.isLoggedIn)
BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () => context.read<FavCubit>().merge(
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
onSuccess: () => context.showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'Sync from Hacker News',
),
);
},
),
Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: favState.isDisplayingStories,
label: 'Story',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: !favState.isDisplayingStories,
label: 'Comment',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
],
),
],
);
if (favState.favItems.isEmpty && favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
} else {
if (favState.isDisplayingStories && favState.favStories.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite story.',
),
],
);
} else if (!favState.isDisplayingStories &&
favState.favComments.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite comment.',
),
],
);
}
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile: prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile: prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshController,
items: favState.isDisplayingStories
? favState.favStories
: favState.favComments,
onRefresh: () {
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: onItemTap,
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context.read<FavCubit>().removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
);
}
}

View File

@ -88,7 +88,7 @@ class InboxView extends StatelessWidget {
child: LinearProgressIndicator(
color: Theme.of(context)
.primaryColor
.withOpacity(0.1),
.withValues(alpha: 0.1),
),
),
FadeIn(
@ -130,10 +130,11 @@ class InboxView extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(
unreadCommentsIds.contains(e.id)
? 1
: 0.6,
.withValues(
alpha:
unreadCommentsIds.contains(e.id)
? 1
: 0.6,
),
),
maxLines: 4,

View File

@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class OfflineListTile extends StatelessWidget {
@ -94,41 +96,48 @@ class OfflineListTile extends StatelessWidget {
}
});
} else {
context.read<StoriesBloc>().add(ClearMaxOfflineStoriesCount());
Connectivity()
.checkConnectivity()
.then((List<ConnectivityResult> res) {
if (!res.contains(ConnectivityResult.none) && context.mounted) {
showDialog<bool>(
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => context.pop(false),
child: const Text('No'),
),
TextButton(
onPressed: () => context.pop(true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
WakelockPlus.enable();
builder: (BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBoxes.pt12,
const Text(
'How many stories do you want to download?',
),
for (final MaxOfflineStoriesCount count
in MaxOfflineStoriesCount.values)
ListTile(
title: Text(count.label),
onTap: () {
HapticFeedbackUtil.selection();
if (context.mounted) {
context.read<StoriesBloc>().add(
StoriesDownload(includingWebPage: includeWebPage),
);
}
}
});
context.pop();
final StoriesBloc storiesBloc =
context.read<StoriesBloc>()
..add(
UpdateMaxOfflineStoriesCount(
count: count,
),
);
showConfirmationDialog(
context,
storiesBloc,
);
},
),
],
),
);
},
);
}
});
}
@ -137,4 +146,38 @@ class OfflineListTile extends StatelessWidget {
},
);
}
void showConfirmationDialog(BuildContext context, StoriesBloc storiesBloc) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => context.pop(false),
child: const Text('No'),
),
TextButton(
onPressed: () => context.pop(true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
WakelockPlus.enable();
storiesBloc.add(
StoriesDownload(
includingWebPage: includeWebPage,
),
);
}
});
}
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@ -62,6 +63,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
child: Column(
children: <Widget>[
ListTile(
leading: Icon(
Icons.person,
color: widget.authState.isLoggedIn
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
widget.authState.isLoggedIn ? 'Log Out' : 'Log In',
),
@ -221,9 +228,6 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
bool isInProgress,
) {
return DropdownMenu<HackerNewsDataSource>(
/// Make sure no stories are being fetched
/// before switching data source.
enabled: !isInProgress,
initialSelection: preferenceState.dataSource,
dropdownMenuEntries:
HackerNewsDataSource.values
@ -343,7 +347,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
),
const Divider(),
],
if (preference is StoryUrlModePreference) const Divider(),
if (preference is DividerPreference) const Divider(),
],
ListTile(
title: const Text(
@ -395,7 +399,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
),
onTap: showClearCacheDialog,
),
if (preferenceState.isDevModeEnabled)
if (preferenceState.isDevModeEnabled) ...<Widget>[
ListTile(
title: const Text(
'Logs',
@ -404,6 +408,20 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
context.go(Paths.log.landing);
},
),
ListTile(
title: const Text(
'Reset Feature Discovery',
),
onTap: () {
HapticFeedbackUtil.light();
FeatureDiscovery.clearPreferences(
context,
DiscoverableFeature.values
.map((DiscoverableFeature f) => f.featureId),
);
},
),
],
ListTile(
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),
@ -606,7 +624,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
context.pop();
locator
.get<SembastRepository>()
.deleteAllCachedComments()
.deleteAllCachedItems()
.whenComplete(
locator.get<OfflineRepository>().deleteAll,
)
@ -831,10 +849,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
try {
final File originalFile = await LogUtil.exportLog();
final XFile file = XFile(originalFile.path);
final ShareResult result = await Share.shareXFiles(
<XFile>[file],
subject: 'hacki_log',
sharePositionOrigin: rect,
final ShareResult result = await SharePlus.instance.share(
ShareParams(
files: <XFile>[file],
subject: 'hacki_log',
sharePositionOrigin: rect,
),
);
if (result.status == ShareResultStatus.success) {

View File

@ -1,5 +1,6 @@
export 'centered_message_view.dart';
export 'enter_offline_mode_list_tile.dart';
export 'favorites_screen.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';
export 'settings.dart';

View File

@ -75,11 +75,6 @@ class CommentTile extends StatelessWidget {
final Color primaryColor = Theme.of(context).colorScheme.primary;
final Brightness brightness = Theme.of(context).brightness;
final Color color = _getColor(
level,
primaryColor: primaryColor,
brightness: brightness,
);
final Widget child = DeviceGestureWrapper(
child: Column(
@ -224,7 +219,7 @@ class CommentTile extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.8),
.withValues(alpha: 0.8),
)
else if (comment.hidden)
const CenteredText.hidden()
@ -307,12 +302,7 @@ class CommentTile extends StatelessWidget {
),
);
final double commentBackgroundColorOpacity =
Theme.of(context).canvasColor != Palette.white ? 0.03 : 0.15;
final Color commentColor = prefState.isEyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
const Color commentColor = Palette.transparent;
final bool isMyComment = comment.deleted == false &&
context.read<AuthBloc>().state.username == comment.by;
@ -322,7 +312,9 @@ class CommentTile extends StatelessWidget {
return Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
color: Theme.of(context).colorScheme.primary.withValues(
alpha: 0.2,
),
),
child: wrapper,
);
@ -349,13 +341,30 @@ class CommentTile extends StatelessWidget {
)
: null,
color: shouldHighlight
? primaryColor.withOpacity(0.2)
? primaryColor.withValues(alpha: 0.2)
: commentColor,
),
child: wrapper,
);
}
if (<int>[0, 1, 2, 3].contains(level)) {
wrapper = Stack(
children: <Widget>[
wrapper,
Positioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: Dimens.pt24,
child: Container(
color: Colors.transparent,
),
),
],
);
}
return wrapper;
},
),
@ -387,7 +396,7 @@ class CommentTile extends StatelessWidget {
}
final double opacity = ((10 - level) / 10).clamp(0.3, 1);
final Color color = primaryColor.withOpacity(opacity);
final Color color = primaryColor.withValues(alpha: opacity);
levelToBorderColors[cacheKey] = color;
return color;

View File

@ -390,7 +390,7 @@ TextSpan buildTextSpan(
return TextSpan(
text: element.text,
style: style?.copyWith(
backgroundColor: primaryColor.withOpacity(0.3),
backgroundColor: primaryColor.withValues(alpha: 0.3),
),
);
} else if (element is EmphasisElement) {

View File

@ -84,10 +84,10 @@ class _CustomTabBarState extends State<CustomTabBar> {
Tab(
child: CustomDescribedFeatureOverlay(
onComplete: showOnboarding,
tapTarget: const Icon(
tapTarget: Icon(
Icons.person,
size: TextDimens.pt16,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
feature: DiscoverableFeature.login,
child: BlocBuilder<NotificationCubit, NotificationState>(

View File

@ -69,8 +69,9 @@ class _DownloadProgressReminderState extends State<DownloadProgressReminder>
const Spacer(),
LinearProgressIndicator(
value: progress,
color:
Theme.of(context).colorScheme.primary.withOpacity(0.5),
color: Theme.of(context).colorScheme.primary.withValues(
alpha: 0.5,
),
),
],
),

View File

@ -20,6 +20,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
required this.items,
required this.onTap,
required this.refreshController,
this.showDivider = false,
super.key,
this.showAuthor = true,
this.useSimpleTileForStory = false,
@ -38,6 +39,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
});
final bool showAuthor;
final bool showDivider;
final bool useSimpleTileForStory;
final bool showWebPreviewOnStoryTile;
final bool showMetadataOnStoryTile;
@ -78,10 +80,32 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool swipeGestureEnabled =
context.read<PreferenceCubit>().state.isSwipeGestureEnabled;
return <Widget>[
if (showDivider)
Padding(
padding: EdgeInsetsGeometry.only(
bottom:
showWebPreviewOnStoryTile ? Dimens.pt8 : Dimens.zero,
),
child: const Divider(
height: Dimens.zero,
),
)
else if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: Dimens.pt6,
color: Palette.transparent,
),
if (useSimpleTileForStory)
FadeIn(
child: InkWell(
onTap: () => onTap(e),
/// If swipe gesture is enabled on home screen, use
/// long press instead of slide action to trigger
/// the action menu.
onLongPress: swipeGestureEnabled
? () => onMoreTapped?.call(e, context.rect)
: null,
child: Padding(
padding: const EdgeInsets.only(
top: Dimens.pt8,
@ -127,7 +151,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
),
),
)
else
else ...<Widget>[
GestureDetector(
/// If swipe gesture is enabled on home screen, use long press
/// instead of slide action to trigger the action menu.
@ -147,15 +171,11 @@ class ItemsListView<T extends Item> extends StatelessWidget {
),
),
),
if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
const Divider(
height: Dimens.zero,
)
else if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: Dimens.pt6,
color: Palette.transparent,
),
if (showDivider && showWebPreviewOnStoryTile)
const SizedBox(
height: Dimens.pt8,
),
],
];
} else if (e is Comment) {
return <Widget>[

View File

@ -115,37 +115,74 @@ class LinkView extends StatelessWidget {
child: SizedBox(
height: layoutHeight,
width: layoutHeight,
child: CachedNetworkImage(
imageUrl: imageUri ?? '',
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri,
errorWidget: (_, __, ___) {
if (url.isEmpty) {
return FadeIn(
child: imageUri == null && url.isEmpty
? FadeIn(
child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
)
: () {
if (imageUri?.isNotEmpty ?? false) {
return CachedNetworkImage(
imageUrl: imageUri!,
fit:
isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri,
errorWidget: (_, __, ___) {
if (url.isEmpty) {
return FadeIn(
child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
);
},
),
);
},
);
} else if (url.isNotEmpty) {
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
);
},
),
);
},
),
);
},
),
} else {
return FadeIn(
child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
}(),
),
),
)

View File

@ -29,7 +29,7 @@ class OfflineBanner extends StatelessWidget {
textAlign: showExitButton ? TextAlign.left : TextAlign.center,
),
backgroundColor:
Theme.of(context).colorScheme.primary.withOpacity(0.3),
Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
actions: <Widget>[
if (showExitButton)
TextButton(

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