From fdce94f2e73b6e945fd1a5838b98e44e91501646 Mon Sep 17 00:00:00 2001 From: Jojo Feng Date: Mon, 20 Jan 2025 01:12:44 -0800 Subject: [PATCH] feat: home screen widget for iOS. (#494) --- ios/Runner.xcodeproj/project.pbxproj | 295 +++++++++++++++++- .../xcschemes/Action Extension.xcscheme | 96 ++++++ .../xcschemes/Share Extension.xcscheme | 97 ++++++ .../xcschemes/StoryWidgetExtension.xcscheme | 124 ++++++++ .../xcshareddata/swiftpm/Package.resolved | 24 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 +++ ios/StoryWidget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../Extensions/ArrayExtension.swift | 36 +++ .../Extensions/Date+TimeAgoString.swift | 9 + ios/StoryWidget/Extensions/Int+OrZero.swift | 10 + .../Extensions/StringExtension.swift | 88 ++++++ ios/StoryWidget/Info.plist | 11 + ios/StoryWidget/Models/Comment.swift | 54 ++++ ios/StoryWidget/Models/Item.swift | 59 ++++ ios/StoryWidget/Models/SearchFilter.swift | 48 +++ ios/StoryWidget/Models/SearchParams.swift | 62 ++++ ios/StoryWidget/Models/Story.swift | 98 ++++++ ios/StoryWidget/Models/StoryType.swift | 56 ++++ ios/StoryWidget/Models/User.swift | 53 ++++ ios/StoryWidget/SelectStoryTypeIntent.swift | 17 + ios/StoryWidget/StoryEntry.swift | 14 + ios/StoryWidget/StoryRepository.swift | 130 ++++++++ ios/StoryWidget/StorySource.swift | 21 ++ ios/StoryWidget/StoryTimelineProvider.swift | 42 +++ ios/StoryWidget/StoryWidget.swift | 84 +++++ ios/StoryWidget/StoryWidgetBundle.swift | 9 + ios/StoryWidget/Timeline+Placeholder.swift | 8 + ios/Widget/SelectStoryTypeIntent.swift | 18 ++ ios/Widget/StorySource.swift | 21 ++ ios/fastlane/Appfile | 2 +- ios/fastlane/Fastfile | 2 +- pubspec.lock | 11 +- pubspec.yaml | 5 +- 35 files changed, 1642 insertions(+), 25 deletions(-) create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Action Extension.xcscheme create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/StoryWidgetExtension.xcscheme create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/StoryWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/StoryWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/StoryWidget/Assets.xcassets/Contents.json create mode 100644 ios/StoryWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 ios/StoryWidget/Extensions/ArrayExtension.swift create mode 100644 ios/StoryWidget/Extensions/Date+TimeAgoString.swift create mode 100644 ios/StoryWidget/Extensions/Int+OrZero.swift create mode 100644 ios/StoryWidget/Extensions/StringExtension.swift create mode 100644 ios/StoryWidget/Info.plist create mode 100644 ios/StoryWidget/Models/Comment.swift create mode 100644 ios/StoryWidget/Models/Item.swift create mode 100644 ios/StoryWidget/Models/SearchFilter.swift create mode 100644 ios/StoryWidget/Models/SearchParams.swift create mode 100644 ios/StoryWidget/Models/Story.swift create mode 100644 ios/StoryWidget/Models/StoryType.swift create mode 100644 ios/StoryWidget/Models/User.swift create mode 100644 ios/StoryWidget/SelectStoryTypeIntent.swift create mode 100644 ios/StoryWidget/StoryEntry.swift create mode 100644 ios/StoryWidget/StoryRepository.swift create mode 100644 ios/StoryWidget/StorySource.swift create mode 100644 ios/StoryWidget/StoryTimelineProvider.swift create mode 100644 ios/StoryWidget/StoryWidget.swift create mode 100644 ios/StoryWidget/StoryWidgetBundle.swift create mode 100644 ios/StoryWidget/Timeline+Placeholder.swift create mode 100644 ios/Widget/SelectStoryTypeIntent.swift create mode 100644 ios/Widget/StorySource.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1c3b0bb..7475fb1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -22,6 +22,11 @@ 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 */; }; /* End PBXBuildFile section */ @@ -40,12 +45,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 +72,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,11 +109,28 @@ E530B1AF283B54DA004E8EB6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; E530B1B1283B54DA004E8EB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E530B1B9283B54E4004E8EB6 /* Action Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Action Extension.entitlements"; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E573DE0B2D3E274000831A51 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E573DDF52D3E273F00831A51 /* Story Widget Extension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E573DDFB2D3E273F00831A51 /* StoryWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E573DE0B2D3E274000831A51 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = StoryWidget; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -126,6 +156,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 +188,7 @@ 97C146F01CF9000F007C117D /* Runner */, E51D52AE283B464E00FC8DD8 /* Share Extension */, E530B1A9283B54DA004E8EB6 /* Action Extension */, + E573DDFB2D3E273F00831A51 /* StoryWidget */, 97C146EF1CF9000F007C117D /* Products */, D79CD63C88FF49EF451AFDDF /* Pods */, B3F4F49CF582C662A01499C0 /* Frameworks */, @@ -159,6 +201,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, E51D52AD283B464E00FC8DD8 /* Share Extension.appex */, E530B1A6283B54DA004E8EB6 /* Action Extension.appex */, + E573DDF62D3E273F00831A51 /* Story Widget Extension.appex */, ); name = Products; sourceTree = ""; @@ -185,6 +228,8 @@ E575B6F027EBC6DA002B1508 /* CloudKit.framework */, E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */, 8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */, + E573DDF72D3E273F00831A51 /* WidgetKit.framework */, + E573DDF92D3E273F00831A51 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -244,6 +289,7 @@ dependencies = ( E51D52B6283B464E00FC8DD8 /* PBXTargetDependency */, E530B1B3283B54DA004E8EB6 /* PBXTargetDependency */, + E573DE062D3E274000831A51 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -284,13 +330,37 @@ 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 = ( + ); + fileSystemSynchronizedGroups = ( + E573DDFB2D3E273F00831A51 /* StoryWidget */, + ); + 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 +374,9 @@ E530B1A5283B54DA004E8EB6 = { CreatedOnToolsVersion = 13.3; }; + E573DDF52D3E273F00831A51 = { + CreatedOnToolsVersion = 16.1; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -315,6 +388,10 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + E573DE372D3E282700831A51 /* XCRemoteSwiftPackageReference "Alamofire" */, + E573DE3C2D3E28CD00831A51 /* XCRemoteSwiftPackageReference "SwiftSoup" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -322,6 +399,7 @@ 97C146ED1CF9000F007C117D /* Runner */, E51D52AC283B464E00FC8DD8 /* Share Extension */, E530B1A5283B54DA004E8EB6 /* Action Extension */, + E573DDF52D3E273F00831A51 /* Story Widget Extension */, ); }; /* End PBXProject section */ @@ -355,6 +433,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E573DDF42D3E273F00831A51 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -372,7 +457,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 +541,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E573DDF22D3E273F00831A51 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -469,6 +561,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 */ @@ -575,7 +672,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", @@ -718,7 +815,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", @@ -753,7 +850,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 +886,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 +928,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 +966,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 +1005,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 +1049,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 +1089,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 +1107,136 @@ }; name = Profile; }; + E573DE082D3E274000831A51 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + 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 +1280,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 */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Action Extension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Action Extension.xcscheme new file mode 100644 index 0000000..e12e674 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Action Extension.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme new file mode 100644 index 0000000..e730c44 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/StoryWidgetExtension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/StoryWidgetExtension.xcscheme new file mode 100644 index 0000000..a81c03e --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/StoryWidgetExtension.xcscheme @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..961337f --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/ios/StoryWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/StoryWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/StoryWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StoryWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/StoryWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/StoryWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/ios/StoryWidget/Assets.xcassets/Contents.json b/ios/StoryWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/StoryWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StoryWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/StoryWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/StoryWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StoryWidget/Extensions/ArrayExtension.swift b/ios/StoryWidget/Extensions/ArrayExtension.swift new file mode 100644 index 0000000..09f9b80 --- /dev/null +++ b/ios/StoryWidget/Extensions/ArrayExtension.swift @@ -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 + } +} diff --git a/ios/StoryWidget/Extensions/Date+TimeAgoString.swift b/ios/StoryWidget/Extensions/Date+TimeAgoString.swift new file mode 100644 index 0000000..9218d88 --- /dev/null +++ b/ios/StoryWidget/Extensions/Date+TimeAgoString.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + var timeAgoString: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: self, relativeTo: Date()) + } +} diff --git a/ios/StoryWidget/Extensions/Int+OrZero.swift b/ios/StoryWidget/Extensions/Int+OrZero.swift new file mode 100644 index 0000000..ac27c51 --- /dev/null +++ b/ios/StoryWidget/Extensions/Int+OrZero.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Int? { + var orZero: Int { + guard let unwrapped = self else { + return 0 + } + return unwrapped + } +} diff --git a/ios/StoryWidget/Extensions/StringExtension.swift b/ios/StoryWidget/Extensions/StringExtension.swift new file mode 100644 index 0000000..9a96f92 --- /dev/null +++ b/ios/StoryWidget/Extensions/StringExtension.swift @@ -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("

") + let iRegex = try Regex(#"\(.*?)\<\/i\>"#) + let codeRegex = try Regex(#"\\(.*?)\<\/code\>\<\/pre\>"#) + .dotMatchesNewlines(true) + let linkRegex = try Regex(#"\.*?\<\/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.. + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/StoryWidget/Models/Comment.swift b/ios/StoryWidget/Models/Comment.swift new file mode 100644 index 0000000..b2a5695 --- /dev/null +++ b/ios/StoryWidget/Models/Comment.swift @@ -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) + } +} diff --git a/ios/StoryWidget/Models/Item.swift b/ios/StoryWidget/Models/Item.swift new file mode 100644 index 0000000..86178cb --- /dev/null +++ b/ios/StoryWidget/Models/Item.swift @@ -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 + } +} diff --git a/ios/StoryWidget/Models/SearchFilter.swift b/ios/StoryWidget/Models/SearchFilter.swift new file mode 100644 index 0000000..373c9a1 --- /dev/null +++ b/ios/StoryWidget/Models/SearchFilter.swift @@ -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 + } +} diff --git a/ios/StoryWidget/Models/SearchParams.swift b/ios/StoryWidget/Models/SearchParams.swift new file mode 100644 index 0000000..0cc173b --- /dev/null +++ b/ios/StoryWidget/Models/SearchParams.swift @@ -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 + + 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) { + 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? = 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 + } +} diff --git a/ios/StoryWidget/Models/Story.swift b/ios/StoryWidget/Models/Story.swift new file mode 100644 index 0000000..e27fb9b --- /dev/null +++ b/ios/StoryWidget/Models/Story.swift @@ -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 + ) +} diff --git a/ios/StoryWidget/Models/StoryType.swift b/ios/StoryWidget/Models/StoryType.swift new file mode 100644 index 0000000..0d83bf1 --- /dev/null +++ b/ios/StoryWidget/Models/StoryType.swift @@ -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" + ] +} diff --git a/ios/StoryWidget/Models/User.swift b/ios/StoryWidget/Models/User.swift new file mode 100644 index 0000000..e95ad69 --- /dev/null +++ b/ios/StoryWidget/Models/User.swift @@ -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) + } +} diff --git a/ios/StoryWidget/SelectStoryTypeIntent.swift b/ios/StoryWidget/SelectStoryTypeIntent.swift new file mode 100644 index 0000000..c0ea77a --- /dev/null +++ b/ios/StoryWidget/SelectStoryTypeIntent.swift @@ -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 + } +} diff --git a/ios/StoryWidget/StoryEntry.swift b/ios/StoryWidget/StoryEntry.swift new file mode 100644 index 0000000..c229e7c --- /dev/null +++ b/ios/StoryWidget/StoryEntry.swift @@ -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 + ) +} diff --git a/ios/StoryWidget/StoryRepository.swift b/ios/StoryWidget/StoryRepository.swift new file mode 100644 index 0000000..9d2362d --- /dev/null +++ b/ios/StoryWidget/StoryRepository.swift @@ -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 + } + } +} diff --git a/ios/StoryWidget/StorySource.swift b/ios/StoryWidget/StorySource.swift new file mode 100644 index 0000000..f15b71b --- /dev/null +++ b/ios/StoryWidget/StorySource.swift @@ -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" + ] +} diff --git a/ios/StoryWidget/StoryTimelineProvider.swift b/ios/StoryWidget/StoryTimelineProvider.swift new file mode 100644 index 0000000..ab75c2d --- /dev/null +++ b/ios/StoryWidget/StoryTimelineProvider.swift @@ -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 { + 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 + } +} diff --git a/ios/StoryWidget/StoryWidget.swift b/ios/StoryWidget/StoryWidget.swift new file mode 100644 index 0000000..4c65720 --- /dev/null +++ b/ios/StoryWidget/StoryWidget.swift @@ -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: "\(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: "\(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.") + } +} diff --git a/ios/StoryWidget/StoryWidgetBundle.swift b/ios/StoryWidget/StoryWidgetBundle.swift new file mode 100644 index 0000000..001b782 --- /dev/null +++ b/ios/StoryWidget/StoryWidgetBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct StoryWidgetBundle: WidgetBundle { + var body: some Widget { + StoryWidget() + } +} diff --git a/ios/StoryWidget/Timeline+Placeholder.swift b/ios/StoryWidget/Timeline+Placeholder.swift new file mode 100644 index 0000000..e3824a0 --- /dev/null +++ b/ios/StoryWidget/Timeline+Placeholder.swift @@ -0,0 +1,8 @@ +import WidgetKit + +extension Timeline where EntryType == StoryEntry { + static let errorPlaceholder: Timeline = .init( + entries: [.errorPlaceholder], + policy: .atEnd + ) +} diff --git a/ios/Widget/SelectStoryTypeIntent.swift b/ios/Widget/SelectStoryTypeIntent.swift new file mode 100644 index 0000000..1a1d1db --- /dev/null +++ b/ios/Widget/SelectStoryTypeIntent.swift @@ -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 + } +} diff --git a/ios/Widget/StorySource.swift b/ios/Widget/StorySource.swift new file mode 100644 index 0000000..f15b71b --- /dev/null +++ b/ios/Widget/StorySource.swift @@ -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" + ] +} diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile index 6d3f0d8..46f8618 100644 --- a/ios/fastlane/Appfile +++ b/ios/fastlane/Appfile @@ -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 diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 945653f..6b16581 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -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 ) diff --git a/pubspec.lock b/pubspec.lock index 38f359f..dcb3177 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -297,12 +297,11 @@ packages: feature_discovery: dependency: "direct main" description: - path: "." - ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b - resolved-ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b - url: "https://github.com/livinglist/feature_discovery" - source: git - version: "0.14.1" + name: feature_discovery + sha256: "54c2e0c7cc229c4a7149ef21d933fa7115cf034b985926477488aa2d0619321b" + url: "https://pub.dev" + source: hosted + version: "0.14.2" ffi: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 268aaf5..055a0fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,10 +21,7 @@ dependencies: dio_smart_retry: ^7.0.1 equatable: ^2.0.5 fast_gbk: ^1.0.0 - feature_discovery: - git: - url: https://github.com/livinglist/feature_discovery - ref: bcf4ef28542acb0c98ec7dfa9d20d8fa7a0a594b + feature_discovery: ^0.14.2 flutter: sdk: flutter flutter_bloc: ^9.0.0