diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..2161736 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,140 @@ +excluded: + - .build + - ./*.generated.swift + - Package.swift + - "**/Generated" + - "**/.build" + - "**/checkouts" + - "**/Package.swift" + - "./RSSReaderKit/.build" + - "./RSSReaderKit/Package.swift" + - "Dependencies" + +disabled_rules: + - attributes # Disable attribute placement rules since we use SwiftUI style + - force_unwrapping # Disable in tests + - function_body_length # Tests can be longer + - type_body_length # Debug views can be longer + - line_length # Use warning instead of error + +opt_in_rules: + - array_init + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - convenience_type + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - implicitly_unwrapped_optional + - last_where + - legacy_random + - literal_expression_end_indentation + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - operator_usage_whitespace + - overridden_super_call + - private_action + - prohibited_super_call + - redundant_nil_coalescing + - redundant_type_annotation + - strict_fileprivate + - toggle_bool + - unowned_variable_capture + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + +analyzer_rules: + - unused_declaration + - unused_import + +line_length: + warning: 120 + error: 200 + ignores_urls: true + ignores_comments: true + ignores_interpolated_strings: true + +type_name: + min_length: 2 + max_length: 50 + excluded: + - UI + - ID + +identifier_name: + min_length: 2 + excluded: + - id + - up + - url + - dx + - dy + - x + - y + +file_types: + test: + - "**/*Tests.swift" + preview: + - "**/*Preview.swift" + debug: + - "**/*Debug*.swift" + +function_body_length: + warning: 60 + error: 100 + ignore_comment_only_lines: true + +type_body_length: + warning: 250 + error: 400 + ignore_comment_only_lines: true + +cyclomatic_complexity: + warning: 15 + error: 20 + +function_parameter_count: + warning: 6 + error: 8 + +trailing_whitespace: + ignores_empty_lines: true + ignores_comments: true + +attributes: + always_on_same_line: + - "@IBAction" + - "@NSManaged" + - "@objc" + always_on_line_above: + - "@available" + - "@discardableResult" + - "@UIApplicationMain" + - "@Environment" + - "@State" + - "@Binding" + - "@ObservedObject" + - "@StateObject" + - "@Published" + - "@MainActor" + +included: + - RSSReaderKit/Sources + - RSSReaderKit/Tests + +custom_rules: + test_force_unwrap: + name: "Force Unwrap in Tests" + regex: "!" + match_kinds: + - forced_value + included: ".*Tests.swift" + severity: ignore diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ffc0c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +SWIFT_PACKAGE := swift package +SWIFT_BUILD := $(SWIFT_PACKAGE) build +SWIFT_TEST := $(SWIFT_PACKAGE) test +SWIFT_LINT := swiftlint +SWIFT_FORMAT := swift-format + +.PHONY: all +all: lint build test + +.PHONY: build +build: + $(SWIFT_BUILD) + +.PHONY: test +test: + $(SWIFT_TEST) + +.PHONY: lint +lint: + $(SWIFT_LINT) lint --config .swiftlint.yml + +.PHONY: lint-fix +lint-fix: + $(SWIFT_LINT) --fix --config .swiftlint.yml + +.PHONY: format +format: + $(SWIFT_FORMAT) format --in-place --recursive ./Sources ./Tests + +.PHONY: clean +clean: + rm -rf .build + $(SWIFT_PACKAGE) clean + +.PHONY: xcodeproj +xcodeproj: + $(SWIFT_PACKAGE) generate-xcodeproj + +.PHONY: install-tools +install-tools: + brew install swiftlint + brew install swift-format \ No newline at end of file diff --git a/RSSReader.xcodeproj/project.pbxproj b/RSSReader.xcodeproj/project.pbxproj index a3a8167..8752a99 100644 --- a/RSSReader.xcodeproj/project.pbxproj +++ b/RSSReader.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 382CFC172DAFA4F600CFBB7D /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 382CFC162DAFA4F600CFBB7D /* Common */; }; + 382CFC192DAFA4F600CFBB7D /* FeedItemsFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 382CFC182DAFA4F600CFBB7D /* FeedItemsFeature */; }; + 382CFC1B2DAFA4F600CFBB7D /* PersistenceClient in Frameworks */ = {isa = PBXBuildFile; productRef = 382CFC1A2DAFA4F600CFBB7D /* PersistenceClient */; }; + 386201412DB137FF00613CA2 /* NotificationClient in Frameworks */ = {isa = PBXBuildFile; productRef = 386201402DB137FF00613CA2 /* NotificationClient */; }; + 3882B1C32DAFE47B0069A7E1 /* TabBarFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 3882B1C22DAFE47B0069A7E1 /* TabBarFeature */; }; 388B93BF2DAD202C0096CB2E /* FeedListFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 388B93BE2DAD202C0096CB2E /* FeedListFeature */; }; 38C352E32DABF64F008FA670 /* RSSClient in Frameworks */ = {isa = PBXBuildFile; productRef = 38C352E22DABF64F008FA670 /* RSSClient */; }; 38C352E52DABF64F008FA670 /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 38C352E42DABF64F008FA670 /* SharedModels */; }; @@ -37,9 +42,22 @@ 38C352CA2DABC4F1008FA670 /* RSSClient.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = RSSClient.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 386201472DB1469800613CA2 /* Exceptions for "RSSReader" folder in "RSSReader" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 386A8D1D2DAA864900BCD162 /* RSSReader */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 386A8D202DAA864900BCD162 /* RSSReader */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 386201472DB1469800613CA2 /* Exceptions for "RSSReader" folder in "RSSReader" target */, + ); path = RSSReader; sourceTree = ""; }; @@ -60,9 +78,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 386201412DB137FF00613CA2 /* NotificationClient in Frameworks */, 38C352E32DABF64F008FA670 /* RSSClient in Frameworks */, + 3882B1C32DAFE47B0069A7E1 /* TabBarFeature in Frameworks */, 388B93BF2DAD202C0096CB2E /* FeedListFeature in Frameworks */, 38C352E52DABF64F008FA670 /* SharedModels in Frameworks */, + 382CFC1B2DAFA4F600CFBB7D /* PersistenceClient in Frameworks */, + 382CFC192DAFA4F600CFBB7D /* FeedItemsFeature in Frameworks */, + 382CFC172DAFA4F600CFBB7D /* Common in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -136,6 +159,11 @@ 38C352E22DABF64F008FA670 /* RSSClient */, 38C352E42DABF64F008FA670 /* SharedModels */, 388B93BE2DAD202C0096CB2E /* FeedListFeature */, + 382CFC162DAFA4F600CFBB7D /* Common */, + 382CFC182DAFA4F600CFBB7D /* FeedItemsFeature */, + 382CFC1A2DAFA4F600CFBB7D /* PersistenceClient */, + 3882B1C22DAFE47B0069A7E1 /* TabBarFeature */, + 386201402DB137FF00613CA2 /* NotificationClient */, ); productName = RSSReader; productReference = 386A8D1E2DAA864900BCD162 /* RSSReader.app */; @@ -148,6 +176,7 @@ 386A8D2B2DAA864A00BCD162 /* Sources */, 386A8D2C2DAA864A00BCD162 /* Frameworks */, 386A8D2D2DAA864A00BCD162 /* Resources */, + 388A693E2DB2E7B60036C864 /* Swiftlint */, ); buildRules = ( ); @@ -255,6 +284,27 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 388A693E2DB2E7B60036C864 /* Swiftlint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = Swiftlint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# \nType a script or drag a script file from your workspace to insert its path.\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 386A8D1A2DAA864900BCD162 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -418,6 +468,7 @@ DEVELOPMENT_ASSET_PATHS = "\"RSSReader/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RSSReader/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -426,9 +477,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.2; @@ -436,10 +486,11 @@ PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReader; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; XROS_DEPLOYMENT_TARGET = 2.2; }; name = Debug; @@ -455,6 +506,7 @@ DEVELOPMENT_ASSET_PATHS = "\"RSSReader/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RSSReader/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -463,9 +515,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.2; @@ -473,10 +524,11 @@ PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReader; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; XROS_DEPLOYMENT_TARGET = 2.2; }; name = Release; @@ -488,16 +540,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReaderTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RSSReader.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RSSReader"; XROS_DEPLOYMENT_TARGET = 2.2; }; @@ -510,16 +563,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReaderTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RSSReader.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RSSReader"; XROS_DEPLOYMENT_TARGET = 2.2; }; @@ -531,16 +585,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReaderUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = RSSReader; XROS_DEPLOYMENT_TARGET = 2.2; }; @@ -552,16 +607,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = hr.maminjo.RSSReaderUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = RSSReader; XROS_DEPLOYMENT_TARGET = 2.2; }; @@ -609,6 +665,26 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 382CFC162DAFA4F600CFBB7D /* Common */ = { + isa = XCSwiftPackageProductDependency; + productName = Common; + }; + 382CFC182DAFA4F600CFBB7D /* FeedItemsFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = FeedItemsFeature; + }; + 382CFC1A2DAFA4F600CFBB7D /* PersistenceClient */ = { + isa = XCSwiftPackageProductDependency; + productName = PersistenceClient; + }; + 386201402DB137FF00613CA2 /* NotificationClient */ = { + isa = XCSwiftPackageProductDependency; + productName = NotificationClient; + }; + 3882B1C22DAFE47B0069A7E1 /* TabBarFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = TabBarFeature; + }; 388B93BE2DAD202C0096CB2E /* FeedListFeature */ = { isa = XCSwiftPackageProductDependency; productName = FeedListFeature; diff --git a/RSSReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RSSReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 64d1a7a..6dfd35d 100644 --- a/RSSReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RSSReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "668224849311d450feeb637c5ad5edc236f5e33c7226713b43a7f6457a4950d6", + "originHash" : "0160e15472b91a4f5e0af8d0caef2a6a49da1518417c63e1be9cf19f1e8e709f", "pins" : [ { "identity" : "combine-schedulers", diff --git a/RSSReader.xcodeproj/xcshareddata/xcschemes/RSSReader.xcscheme b/RSSReader.xcodeproj/xcshareddata/xcschemes/RSSReader.xcscheme new file mode 100644 index 0000000..0656b21 --- /dev/null +++ b/RSSReader.xcodeproj/xcshareddata/xcschemes/RSSReader.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RSSReader.xcodeproj/xcuserdata/martino.mamic.xcuserdatad/xcschemes/xcschememanagement.plist b/RSSReader.xcodeproj/xcuserdata/martino.mamic.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 13bf36b..0000000 --- a/RSSReader.xcodeproj/xcuserdata/martino.mamic.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - RSSReader.xcscheme_^#shared#^_ - - orderHint - 2 - - - - diff --git a/RSSReader/AppDelegate.swift b/RSSReader/AppDelegate.swift new file mode 100644 index 0000000..f7543bb --- /dev/null +++ b/RSSReader/AppDelegate.swift @@ -0,0 +1,39 @@ +// +// AppDelegate.swift +// RSSReader +// +// Created by Martino Mamić on 17.04.25. +// + +import UIKit +import NotificationClient +import Dependencies + +class AppDelegate: NSObject, UIApplicationDelegate { + @Dependency(\.notificationClient) private var notificationClient + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + NotificationDelegate.shared.setup() + BackgroundRefreshClient.shared.registerBackgroundTasks() + + // Request notification permissions at launch + Task { + do { + try await notificationClient.requestPermissions() + print("Notification permissions requested successfully") + } catch { + print("Notification permissions denied: \(error)") + } + } + + return true + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Schedule next background refresh when app enters background + BackgroundRefreshClient.shared.scheduleAppRefresh() + } +} diff --git a/RSSReader/Assets.xcassets/AccentColor.colorset/Contents.json b/RSSReader/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..263625e 100644 --- a/RSSReader/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/RSSReader/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "8", + "green" : "88", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, "idiom" : "universal" } ], diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/1024.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..9ad5ce9 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/114.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..7a73b43 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/120.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..4c7875a Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/180.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..93de67e Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/29.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..5ba1a32 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/40.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..73ea3e8 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/57.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..9a7f93f Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/58.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..3cafff5 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/60.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..1bcd49d Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/80.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..aad75e1 Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/87.png b/RSSReader/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..c13833a Binary files /dev/null and b/RSSReader/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/RSSReader/Assets.xcassets/AppIcon.appiconset/Contents.json b/RSSReader/Assets.xcassets/AppIcon.appiconset/Contents.json index ffdfe15..af727e0 100644 --- a/RSSReader/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/RSSReader/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,81 +1,76 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" }, { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" }, { - "idiom" : "mac", + "filename" : "29.png", + "idiom" : "iphone", "scale" : "1x", - "size" : "16x16" + "size" : "29x29" }, { - "idiom" : "mac", + "filename" : "58.png", + "idiom" : "iphone", "scale" : "2x", - "size" : "16x16" + "size" : "29x29" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" }, { - "idiom" : "mac", + "filename" : "80.png", + "idiom" : "iphone", "scale" : "2x", - "size" : "32x32" + "size" : "40x40" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" }, { - "idiom" : "mac", + "filename" : "120.png", + "idiom" : "iphone", "scale" : "2x", - "size" : "256x256" + "size" : "60x60" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { diff --git a/RSSReader/Info.plist b/RSSReader/Info.plist new file mode 100644 index 0000000..5000506 --- /dev/null +++ b/RSSReader/Info.plist @@ -0,0 +1,16 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + fetch + processing + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/RSSReader/RSSReaderApp.swift b/RSSReader/RSSReaderApp.swift index 4f616cf..3145bd1 100644 --- a/RSSReader/RSSReaderApp.swift +++ b/RSSReader/RSSReaderApp.swift @@ -5,14 +5,16 @@ // Created by Martino Mamić on 12.04.25. // -import FeedListFeature import SwiftUI +import TabBarFeature @main struct RSSReaderApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { - FeedListView() + TabBarView() } } } diff --git a/RSSReader/Resources/feeds.json b/RSSReader/Resources/feeds.json new file mode 100644 index 0000000..a07b893 --- /dev/null +++ b/RSSReader/Resources/feeds.json @@ -0,0 +1,256 @@ +{ + "feeds": [ + { + "name": "BBC World News", + "url": "https://feeds.bbci.co.uk/news/world/rss.xml" + }, + { + "name": "NBC News World", + "url": "https://feeds.nbcnews.com/nbcnews/public/world" + }, + { + "name": "CBS News World", + "url": "https://www.cbsnews.com/latest/rss/world" + }, + { + "name": "France 24", + "url": "https://www.france24.com/en/rss" + }, + { + "name": "Washington Post World News", + "url": "https://feeds.washingtonpost.com/rss/world" + }, + { + "name": "TIME World News", + "url": "https://feeds.feedburner.com/time/world" + }, + { + "name": "NPR World News", + "url": "https://feeds.npr.org/1004/rss.xml" + }, + { + "name": "Yahoo World News", + "url": "https://www.yahoo.com/news/rss/world" + }, + { + "name": "Sky News World", + "url": "https://feeds.skynews.com/feeds/rss/world.xml" + }, + { + "name": "Global News World", + "url": "https://globalnews.ca/world/feed/" + }, + { + "name": "Global Press Journal", + "url": "https://globalpressjournal.com/feed/" + }, + { + "name": "Headlines of Today", + "url": "https://www.headlinesoftoday.com/feed" + }, + { + "name": "IgorB News", + "url": "https://www.igorbnews.com/feeds/posts/default?alt=rss" + }, + { + "name": "Internewscast", + "url": "https://internewscast.com/feed/" + }, + { + "name": "SPIEGEL International", + "url": "https://www.spiegel.de/international/index.rss" + }, + { + "name": "The Cipher Brief", + "url": "https://www.thecipherbrief.com/feed" + }, + { + "name": "The Daniels Post World", + "url": "https://danielspost.news.blog/feed/" + }, + { + "name": "The Guardian World News", + "url": "https://www.theguardian.com/world/rss" + }, + { + "name": "The Sun World News", + "url": "https://www.thesun.co.uk/news/worldnews/feed/" + }, + { + "name": "The Union Journal", + "url": "https://www.theunionjournal.com/feed/" + }, + { + "name": "Times of India World News", + "url": "https://timesofindia.indiatimes.com/rssfeeds/296589292.cms" + }, + { + "name": "TimeSpek", + "url": "https://timespek.com/feed/" + }, + { + "name": "USNN World News", + "url": "https://www.usnn.news/feed/" + }, + { + "name": "Vox World", + "url": "https://www.vox.com/rss/world/index.xml" + }, + { + "name": "Reuters World News", + "url": "https://feeds.reuters.com/reuters/worldnews" + }, + { + "name": "Al Jazeera English", + "url": "https://www.aljazeera.com/xml/rss/all.xml" + }, + { + "name": "Associated Press (AP) World News", + "url": "https://www.apnews.com/feeds/apf-topnews" + }, + { + "name": "The New York Times International", + "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml" + }, + { + "name": "DW (Deutsche Welle) English", + "url": "https://rss.dw.com/rdf/rss-en-world" + }, + { + "name": "South China Morning Post", + "url": "https://www.scmp.com/rss/91/feed" + }, + { + "name": "The Straits Times Asia", + "url": "https://www.straitstimes.com/news/asia/rss.xml" + }, + { + "name": "The Economist - The World This Week", + "url": "https://www.economist.com/the-world-this-week/rss.xml" + }, + { + "name": "Foreign Policy", + "url": "https://foreignpolicy.com/feed/" + }, + { + "name": "Christian Science Monitor World", + "url": "https://rss.csmonitor.com/feeds/world" + }, + { + "name": "CNBC World News", + "url": "https://www.cnbc.com/id/100727362/device/rss/rss.html" + }, + { + "name": "War on the Rocks", + "url": "https://warontherocks.com/feed/" + }, + { + "name": "247 News Around The World", + "url": "https://247newsaroundtheworld.com/feed/" + }, + { + "name": "AkinBlog", + "url": "https://www.akinblog.com/feed" + }, + { + "name": "CTV News World", + "url": "https://www.ctvnews.ca/rss/world/ctvnews-ca-world-public-rss-1.822289" + }, + { + "name": "Ppapi blogue", + "url": "https://ppapiblogue.blogspot.com/feeds/posts/default?alt=rss" + }, + { + "name": "Public Radio International", + "url": "https://www.pri.org/stories/feed/everything" + }, + { + "name": "Quicktrendnews", + "url": "https://quicktrendnews.wordpress.com/feed/" + }, + { + "name": "QuintDaily", + "url": "https://quintdaily.com/feed/" + }, + { + "name": "Radarr Africa", + "url": "https://radarr.africa/feed/" + }, + { + "name": "RT News", + "url": "https://www.rt.com/rss/news/" + }, + { + "name": "Small Wars Journal", + "url": "https://smallwarsjournal.com/rss/blogs" + }, + { + "name": "The Local Spain", + "url": "https://feeds.thelocal.com/rss/es" + }, + { + "name": "The Next Hint", + "url": "https://www.thenexthint.com/feed/" + }, + { + "name": "The Statehood DC", + "url": "https://statehooddc.com/feed/" + }, + { + "name": "The Local Germany", + "url": "https://feeds.thelocal.com/rss/de" + }, + { + "name": "The Local Italy", + "url": "https://feeds.thelocal.com/rss/it" + }, + { + "name": "The Local France", + "url": "https://feeds.thelocal.com/rss/fr" + }, + { + "name": "The Local Sweden", + "url": "https://feeds.thelocal.com/rss/se" + }, + { + "name": "The Local Norway", + "url": "https://feeds.thelocal.com/rss/no" + }, + { + "name": "The Local Denmark", + "url": "https://feeds.thelocal.com/rss/dk" + }, + { + "name": "The Local Switzerland", + "url": "https://feeds.thelocal.com/rss/ch" + }, + { + "name": "Xinhua News Agency", + "url": "https://www.xinhuanet.com/english/rss/worldrss.xml" + }, + { + "name": "The Diplomat", + "url": "https://thediplomat.com/feed/" + }, + { + "name": "PBS NewsHour World", + "url": "https://www.pbs.org/newshour/feed/" + }, + { + "name": "The Japan Times", + "url": "https://www.japantimes.co.jp/feed/topstories/" + }, + { + "name": "The Sydney Morning Herald World", + "url": "https://www.smh.com.au/rss/world.xml" + }, + { + "name": "The South African", + "url": "https://www.thesouthafrican.com/feed/" + }, + { + "name": "Buenos Aires Herald", + "url": "https://buenosairesherald.com/rss" + } + ] +} diff --git a/RSSReaderKit/Package.resolved b/RSSReaderKit/Package.resolved new file mode 100644 index 0000000..9f4ee0c --- /dev/null +++ b/RSSReaderKit/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "80d18bdaf1bc99f494fecd1288e71509ce8b9169a8006257eac2e1414cc1ee1b", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "fee6aa29908a75437506ddcbe7434c460605b7e6", + "version" : "1.9.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + } + ], + "version" : 3 +} diff --git a/RSSReaderKit/Package.swift b/RSSReaderKit/Package.swift index 1dd0300..c47073e 100644 --- a/RSSReaderKit/Package.swift +++ b/RSSReaderKit/Package.swift @@ -9,11 +9,15 @@ let package = Package( ], products: [ .library(name: "Common", targets: ["Common"]), + .library(name: "ExploreClient", targets: ["ExploreClient"]), + .library(name: "ExploreFeature", targets: ["ExploreFeature"]), .library(name: "FeedItemsFeature", targets: ["FeedItemsFeature"]), .library(name: "FeedListFeature", targets: ["FeedListFeature"]), + .library(name: "NotificationClient", targets: ["NotificationClient"]), .library(name: "PersistenceClient", targets: ["PersistenceClient"]), .library(name: "RSSClient", targets: ["RSSClient"]), .library(name: "SharedModels", targets: ["SharedModels"]), + .library(name: "TabBarFeature", targets: ["TabBarFeature"]), ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.3.1"), @@ -31,6 +35,9 @@ let package = Package( name: "RSSClientTests", dependencies: [ "RSSClient", + ], + resources: [ + .copy("Resources/bbc.xml") ] ), .target( @@ -43,6 +50,24 @@ let package = Package( "RSSClient" ] ), + .target( + name: "ExploreClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + "PersistenceClient", + "RSSClient", + "SharedModels" + ] + ), + .target( + name: "ExploreFeature", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + "Common", + "ExploreClient", + "SharedModels" + ] + ), .target( name: "FeedListFeature", dependencies: [ @@ -63,6 +88,30 @@ let package = Package( "SharedModels" ] ), + .target( + name: "NotificationClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + "Common", + "PersistenceClient", + "RSSClient", + "SharedModels", + ] + ), + .testTarget( + name: "NotificationClientTests", + dependencies: [ + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "NotificationClient", + ] + ), + .target( + name: "TabBarFeature", + dependencies: [ + "ExploreFeature", + "FeedListFeature" + ] + ), .target( name: "PersistenceClient", dependencies: [ diff --git a/RSSReaderKit/Sources/Common/Accessibility/AccessibilityIdentifier.swift b/RSSReaderKit/Sources/Common/Accessibility/AccessibilityIdentifier.swift new file mode 100644 index 0000000..65e63ff --- /dev/null +++ b/RSSReaderKit/Sources/Common/Accessibility/AccessibilityIdentifier.swift @@ -0,0 +1,29 @@ +import SwiftUI + +public enum AccessibilityIdentifier { + public enum TabBar { + public static let navigationTabs = "navigationTabs" + } + + public enum FeedList { + public static let addFeedButton = "addFeedButton" + } + + public enum FeedView { + public static let loadingView = "loadingFeedView" + } + + public enum AddFeed { + public static let urlTextField = "feedUrlTextField" + } + + public enum FeedItems { + public static let itemTitle = "feedItemTitle" + public static let loadingView = "loadingItemsView" + } + + public enum Explore { + public static let addButton = "addExploreFeedButton" + public static let loadingView = "loadingExploreView" + } +} diff --git a/RSSReaderKit/Sources/Common/Accessibility/View+Accessibility.swift b/RSSReaderKit/Sources/Common/Accessibility/View+Accessibility.swift new file mode 100644 index 0000000..133dc05 --- /dev/null +++ b/RSSReaderKit/Sources/Common/Accessibility/View+Accessibility.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public extension View { + func testId(_ id: String) -> some View { + accessibilityIdentifier(id) + } +} diff --git a/RSSReaderKit/Sources/Common/Constants.swift b/RSSReaderKit/Sources/Common/Constants.swift index f3f745f..31921f3 100644 --- a/RSSReaderKit/Sources/Common/Constants.swift +++ b/RSSReaderKit/Sources/Common/Constants.swift @@ -15,27 +15,45 @@ public enum Constants { public static let feedDescriptionLineLimit: Int = 2 public static let cornerRadius: CGFloat = 6 public static let verticalPadding: CGFloat = 4 - + public static let feedTitleLineLimit: Int = 1 + // Feed list public static let feedListItemSpacing: CGFloat = 8 - + // Add feed view public static let footerSpacing: CGFloat = 10 public static let exampleButtonSpacing: CGFloat = 8 - + // Feed item row public static let feedItemSpacing: CGFloat = 8 public static let feedItemImageHeight: CGFloat = 160 public static let feedItemCornerRadius: CGFloat = 8 public static let feedItemVerticalPadding: CGFloat = 6 public static let feedItemDescriptionLineLimit: Int = 3 + + // Explore feed row + public static let exploreFeedRowSpacing: CGFloat = 4 + public static let exploreFeedButtonHorizontalPadding: CGFloat = 12 + public static let exploreFeedButtonVerticalPadding: CGFloat = 6 + public static let exploreFeedButtonCornerRadius: CGFloat = 8 + public static let exploreFeedUrlLineLimit: Int = 1 + + // Notification debug view + public static let debugViewSpacing: CGFloat = 24 + public static let debugSectionSpacing: CGFloat = 8 + public static let debugActionSpacing: CGFloat = 16 + public static let debugCornerRadius: CGFloat = 10 + public static let debugIconSize: CGFloat = 30 + public static let debugBackgroundOpacity: CGFloat = 0.1 + public static let debugDelayedNotificationTime: TimeInterval = 5.0 + public static let debugUIUpdateDelay: UInt64 = 500_000_000 } - + public enum URLs { public static let bbcNews = "https://feeds.bbci.co.uk/news/world/rss.xml" public static let nbcNews = "https://feeds.nbcnews.com/nbcnews/public/news" } - + public enum Images { public static let placeholderFeedIcon = "newspaper.fill" public static let loadingIcon = "ellipsis.circle" @@ -44,5 +62,17 @@ public enum Constants { public static let addIcon = "plus" public static let noItemsIcon = "tray.fill" public static let failedToLoadIcon = "exclamationmark.triangle" + public static let notificationEnabledIcon = "bell.fill" + public static let notificationDisabledIcon = "bell" + } + + public enum Storage { + public static let lastNotificationCheckKey = "lastNotificationCheck" + public static let notifiedItemsKey = "notifiedItems" + } + + public enum Notifications { + public static let maxStoredNotificationIDs = 100 + public static let pruneToCount = 50 } } diff --git a/RSSReaderKit/Sources/Common/Errors/RSSErrorMapper.swift b/RSSReaderKit/Sources/Common/Errors/RSSErrorMapper.swift index b7539ca..e629f72 100644 --- a/RSSReaderKit/Sources/Common/Errors/RSSErrorMapper.swift +++ b/RSSReaderKit/Sources/Common/Errors/RSSErrorMapper.swift @@ -7,20 +7,26 @@ import RSSClient -public struct RSSErrorMapper { - public static func mapToViewError(_ error: Error) -> RSSViewError { - if let rssError = error as? RSSError { - switch rssError { - case .invalidURL: - return .invalidURL - case .networkError(let underlyingError): - return .networkError(underlyingError.localizedDescription) - case .parsingError(let underlyingError): - return .parsingError(underlyingError.localizedDescription) - case .unknown: - return .unknown("An unknown error occurred") - } +public enum RSSErrorMapper { + public static func map(_ error: Error) -> RSSViewError { + switch error { + case let rssError as RSSError: + return mapRSSError(rssError) + default: + return .unknown(error.localizedDescription) + } + } + + private static func mapRSSError(_ error: RSSError) -> RSSViewError { + switch error { + case .invalidURL: + return .invalidURL + case .networkError(let underlyingError): + return .networkError(underlyingError.localizedDescription) + case .parsingError(let underlyingError): + return .parsingError(underlyingError.localizedDescription) + case .unknown: + return .unknown("An unknown error occurred") } - return .unknown(error.localizedDescription) } } diff --git a/RSSReaderKit/Sources/Common/Errors/RSSViewError.swift b/RSSReaderKit/Sources/Common/Errors/RSSViewError.swift index 6becfd0..4a97c4d 100644 --- a/RSSReaderKit/Sources/Common/Errors/RSSViewError.swift +++ b/RSSReaderKit/Sources/Common/Errors/RSSViewError.swift @@ -7,13 +7,15 @@ import Foundation -public enum RSSViewError: Error, Equatable, LocalizedError { +public enum RSSViewError: Error, Equatable, LocalizedError, Identifiable { + public var id: String { errorDescription } + case invalidURL case duplicateFeed case networkError(String) case parsingError(String) case unknown(String) - + public var errorDescription: String { switch self { case .invalidURL: diff --git a/RSSReaderKit/Sources/ExploreClient/ExploreClient.swift b/RSSReaderKit/Sources/ExploreClient/ExploreClient.swift new file mode 100644 index 0000000..00ccdee --- /dev/null +++ b/RSSReaderKit/Sources/ExploreClient/ExploreClient.swift @@ -0,0 +1,23 @@ +// +// ExploreClient.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation +import Dependencies +import SharedModels + +public struct ExploreClient: Sendable { + public var loadExploreFeeds: @Sendable () async throws -> [ExploreFeed] + public var addFeed: @Sendable (ExploreFeed) async throws -> Feed + + public init( + loadExploreFeeds: @escaping @Sendable () async throws -> [ExploreFeed], + addFeed: @escaping @Sendable (ExploreFeed) async throws -> Feed + ) { + self.loadExploreFeeds = loadExploreFeeds + self.addFeed = addFeed + } +} diff --git a/RSSReaderKit/Sources/ExploreClient/ExploreClientDependency.swift b/RSSReaderKit/Sources/ExploreClient/ExploreClientDependency.swift new file mode 100644 index 0000000..2a281d7 --- /dev/null +++ b/RSSReaderKit/Sources/ExploreClient/ExploreClientDependency.swift @@ -0,0 +1,43 @@ +// +// ExploreClientDependency.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Dependencies +import Foundation +import SharedModels + +extension ExploreClient: DependencyKey { + public static var liveValue: ExploreClient { .live() } + + public static var testValue: ExploreClient { + ExploreClient( + loadExploreFeeds: { + [ + ExploreFeed(name: "Test Feed", url: "https://example.com/feed"), + ExploreFeed(name: "Another Feed", url: "https://example.org/rss") + ] + }, + addFeed: { exploreFeed in + guard let url = URL(string: exploreFeed.url) else { + throw ExploreError.invalidURL + } + + return Feed( + url: url, + title: exploreFeed.name, + description: "Test feed description" + ) + } + ) + } +} + +extension DependencyValues { + public var exploreClient: ExploreClient { + get { self[ExploreClient.self] } + set { self[ExploreClient.self] = newValue } + } +} diff --git a/RSSReaderKit/Sources/ExploreClient/ExploreClientLive.swift b/RSSReaderKit/Sources/ExploreClient/ExploreClientLive.swift new file mode 100644 index 0000000..c51ee8c --- /dev/null +++ b/RSSReaderKit/Sources/ExploreClient/ExploreClientLive.swift @@ -0,0 +1,53 @@ +// +// ExploreClientLive.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation +import SharedModels +import Dependencies +import RSSClient +import PersistenceClient + +extension ExploreClient { + public static func live() -> ExploreClient { + @Dependency(\.rssClient.fetchFeed) var fetchFeed + @Dependency(\.persistenceClient.addFeed) var addFeed + + return ExploreClient( + loadExploreFeeds: { + guard let url = Bundle.main.url(forResource: "feeds", withExtension: "json") else { + throw ExploreError.fileNotFound + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + let feedList = try decoder.decode(ExploreFeedList.self, from: data) + return feedList.feeds + } catch { + throw ExploreError.decodingFailed(error.localizedDescription) + } + }, + addFeed: { exploreFeed in + guard let url = URL(string: exploreFeed.url) else { + throw ExploreError.invalidURL + } + + do { + let feed = try await fetchFeed(url) + + try await addFeed(feed) + + return feed + } catch let error as RSSError { + throw ExploreError.feedFetchFailed(error.localizedDescription) + } catch { + throw error + } + } + ) + } +} diff --git a/RSSReaderKit/Sources/ExploreClient/ExploreError.swift b/RSSReaderKit/Sources/ExploreClient/ExploreError.swift new file mode 100644 index 0000000..6330f79 --- /dev/null +++ b/RSSReaderKit/Sources/ExploreClient/ExploreError.swift @@ -0,0 +1,28 @@ +// +// ExploreFeedError.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation + +public enum ExploreError: Error, LocalizedError { + case fileNotFound + case decodingFailed(String) + case invalidURL + case feedFetchFailed(String) + + public var errorDescription: String? { + switch self { + case .fileNotFound: + return "Feeds file not found" + case .decodingFailed(let message): + return "Failed to decode feeds: \(message)" + case .invalidURL: + return "Invalid feed URL" + case .feedFetchFailed(let message): + return "Failed to fetch feed: \(message)" + } + } +} diff --git a/RSSReaderKit/Sources/ExploreFeature/ExploreFeedRow.swift b/RSSReaderKit/Sources/ExploreFeature/ExploreFeedRow.swift new file mode 100644 index 0000000..cd031d2 --- /dev/null +++ b/RSSReaderKit/Sources/ExploreFeature/ExploreFeedRow.swift @@ -0,0 +1,79 @@ +// +// ExploreFeedRow.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import SwiftUI +import SharedModels +import Common + +struct ExploreFeedRow: View { + let feed: ExploreFeed + let isAdded: Bool + let onAddTapped: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: Constants.UI.exploreFeedRowSpacing) { + Text(feed.name) + .font(.headline) + + Text(feed.url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(Constants.UI.exploreFeedUrlLineLimit) + } + + Spacer() + + if isAdded { + Text("Added") + .font(.caption) + .padding(.horizontal, Constants.UI.exploreFeedButtonHorizontalPadding) + .padding(.vertical, Constants.UI.exploreFeedButtonVerticalPadding) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(Constants.UI.exploreFeedButtonCornerRadius) + } else { + Button(action: onAddTapped) { + Text("Add") + .font(.caption) + .padding(.horizontal, Constants.UI.exploreFeedButtonHorizontalPadding) + .padding(.vertical, Constants.UI.exploreFeedButtonVerticalPadding) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(Constants.UI.exploreFeedButtonCornerRadius) + } + .testId(AccessibilityIdentifier.Explore.addButton) + } + } + .padding(.vertical, Constants.UI.verticalPadding) + .testId(AccessibilityIdentifier.Explore.feedRow) + } +} + +#Preview("Not Added") { + ExploreFeedRow( + feed: ExploreFeed( + name: "BBC News", + url: "https://feeds.bbci.co.uk/news/world/rss.xml" + ), + isAdded: false, + onAddTapped: {} + ) + .padding() +} + +#Preview("Added") { + ExploreFeedRow( + feed: ExploreFeed( + name: "BBC News", + url: "https://feeds.bbci.co.uk/news/world/rss.xml" + ), + isAdded: true, + onAddTapped: {} + ) + .padding() +} diff --git a/RSSReaderKit/Sources/ExploreFeature/ExploreView.swift b/RSSReaderKit/Sources/ExploreFeature/ExploreView.swift new file mode 100644 index 0000000..dfe2970 --- /dev/null +++ b/RSSReaderKit/Sources/ExploreFeature/ExploreView.swift @@ -0,0 +1,86 @@ +// +// ExploreView.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import SwiftUI +import Common +import SharedModels + +public struct ExploreView: View { + @State var viewModel = ExploreViewModel() + + public init() {} + + public var body: some View { + Group { + switch viewModel.state { + case .loading: + ProgressView() + .testId(AccessibilityIdentifier.Explore.loadingView) + + case .loaded(let feeds): + if feeds.isEmpty { + ContentUnavailableView { + Label("No Feeds Found", systemImage: Constants.Images.noItemsIcon) + } description: { + Text("No feeds available") + } + .testId(AccessibilityIdentifier.Explore.emptyView) + } else { + List { + ForEach(feeds) { feed in + ExploreFeedRow( + feed: feed, + isAdded: viewModel.isFeedAdded(feed), + onAddTapped: { + viewModel.addFeed(feed) + } + ) + } + } + .testId(AccessibilityIdentifier.Explore.feedsList) + } + + case .error(let error): + ContentUnavailableView { + Label("Failed to Load", systemImage: Constants.Images.failedToLoadIcon) + } description: { + Text(error.errorDescription) + } actions: { + Button { + viewModel.loadExploreFeeds() + } label: { + Text("Try Again") + } + .buttonStyle(.bordered) + } + .testId(AccessibilityIdentifier.Explore.errorView) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Explore Feeds") + .alert(item: .init( + get: { viewModel.feedError }, + set: { if $0 == nil { viewModel.clearError() } } + )) { error in + Alert( + title: Text("Error Adding Feed"), + message: Text(error.errorDescription), + dismissButton: .default(Text("OK")) { + viewModel.clearError() + } + ) + } + .overlay { + if viewModel.isAddingFeed { + ProgressView() + } + } + .task { + viewModel.loadExploreFeeds() + } + } +} diff --git a/RSSReaderKit/Sources/ExploreFeature/ExploreViewModel.swift b/RSSReaderKit/Sources/ExploreFeature/ExploreViewModel.swift new file mode 100644 index 0000000..4e25064 --- /dev/null +++ b/RSSReaderKit/Sources/ExploreFeature/ExploreViewModel.swift @@ -0,0 +1,103 @@ +// +// ExploreViewModel.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation +import Dependencies +import SharedModels +import Common +import ExploreClient +import PersistenceClient +import Observation + +@MainActor +@Observable public class ExploreViewModel { + @ObservationIgnored + @Dependency(\.exploreClient) private var exploreClient + + @ObservationIgnored + @Dependency(\.persistenceClient) private var persistenceClient + + enum State: Equatable { + case loading + case loaded([ExploreFeed]) + case error(RSSViewError) + } + + var state: State = .loading + var isAddingFeed = false + var selectedFeed: ExploreFeed? + var feedError: RSSViewError? + var addedFeedURLs: Set = [] + + private var loadTask: Task? + private var addTask: Task? + + public init() {} + + func loadExploreFeeds() { + loadTask?.cancel() + state = .loading + + loadTask = Task { + do { + // Load feeds from JSON + let feeds = try await exploreClient.loadExploreFeeds() + + // Load saved feeds to check which ones are already added + let savedFeeds = try await persistenceClient.loadFeeds() + let savedURLs = Set(savedFeeds.map { $0.url.absoluteString }) + + self.addedFeedURLs = savedURLs + self.state = .loaded(feeds) + } catch { + state = .error(RSSErrorMapper.map(error)) + } + } + } + + func isFeedAdded(_ feed: ExploreFeed) -> Bool { + return addedFeedURLs.contains(feed.url) + } + + func selectFeed(_ feed: ExploreFeed) { + selectedFeed = feed + } + + func clearSelectedFeed() { + selectedFeed = nil + } + + func addSelectedFeed() { + guard let feed = selectedFeed else { return } + addFeed(feed) + } + + func addFeed(_ exploreFeed: ExploreFeed) { + isAddingFeed = true + feedError = nil + + addTask?.cancel() + addTask = Task { + do { + _ = try await exploreClient.addFeed(exploreFeed) + isAddingFeed = false + // Mark this feed as added + addedFeedURLs.insert(exploreFeed.url) + } catch let error as RSSViewError { + isAddingFeed = false + feedError = error + } catch { + isAddingFeed = false + feedError = .unknown(error.localizedDescription) + } + } + } + + func clearError() { + feedError = nil + } +} diff --git a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemRow.swift b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemRow.swift index a4c89fe..871dd6c 100644 --- a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemRow.swift +++ b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemRow.swift @@ -7,37 +7,72 @@ import SwiftUI import SharedModels +import Common struct FeedItemRow: View { let item: FeedItem - + var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Constants.UI.feedItemSpacing) { if let imageURL = item.imageURL { AsyncImage(url: imageURL) { image in image.resizable().aspectRatio(contentMode: .fill) } placeholder: { Rectangle().foregroundStyle(.gray.opacity(0.2)) } - .frame(height: 160) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(height: Constants.UI.feedItemImageHeight) + .clipShape(RoundedRectangle(cornerRadius: Constants.UI.feedItemCornerRadius)) } - + Text(item.title) .font(.headline) - + .testId(AccessibilityIdentifier.FeedItems.itemTitle) + if let description = item.description { Text(description) .font(.subheadline) - .lineLimit(3) + .lineLimit(Constants.UI.feedItemDescriptionLineLimit) + .testId(AccessibilityIdentifier.FeedItems.itemDescription) } - + if let pubDate = item.pubDate { Text(pubDate, style: .date) .font(.caption) .foregroundStyle(.secondary) + .testId(AccessibilityIdentifier.FeedItems.itemDate) } } - .padding(.vertical, 6) + .padding(.vertical, Constants.UI.feedItemVerticalPadding) } } + +#if DEBUG +// swiftlint:disable line_length force_unwrapping +#Preview("With Image") { + FeedItemRow( + item: FeedItem( + feedID: UUID(), + title: "Breaking News: Important Event", + link: URL(string: "https://example.com")!, + pubDate: Date(), + description: "This is a detailed description of the important event that just occurred. It contains multiple lines of text to demonstrate how the layout handles longer content.", + imageURL: URL(string: "https://picsum.photos/800/400") + ) + ) + .padding() +} + +#Preview("Without Image") { + FeedItemRow( + item: FeedItem( + feedID: UUID(), + title: "Text Only News Item", + link: URL(string: "https://example.com")!, + pubDate: Date(), + description: "This is a news item without an image to show how the layout adapts." + ) + ) + .padding() +} +// swiftlint:enable line_length force_unwrapping +#endif diff --git a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsState.swift b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsState.swift index e98d0cd..31bcbb7 100644 --- a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsState.swift +++ b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsState.swift @@ -13,7 +13,7 @@ enum FeedItemsState: Equatable { case loaded([FeedItem]) case error(RSSViewError) case empty - + static func == (lhs: FeedItemsState, rhs: FeedItemsState) -> Bool { switch (lhs, rhs) { case (.loading, .loading), (.empty, .empty): diff --git a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsView.swift b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsView.swift index ff992bb..d99331e 100644 --- a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsView.swift +++ b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsView.swift @@ -12,17 +12,18 @@ import SwiftUI public struct FeedItemsView: View { @State var viewModel: FeedItemsViewModel - + public init(viewModel: FeedItemsViewModel) { self.viewModel = viewModel } - + public var body: some View { Group { switch viewModel.state { case .loading: ProgressView() - + .testId(AccessibilityIdentifier.FeedItems.loadingView) + case .loaded(let items): List { ForEach(items) { item in @@ -34,25 +35,113 @@ public struct FeedItemsView: View { .buttonStyle(.plain) } } - + .testId(AccessibilityIdentifier.FeedItems.itemsList) + case .error(let error): ContentUnavailableView { Label("Failed to Load", systemImage: Constants.Images.failedToLoadIcon) } description: { Text(error.errorDescription) + } actions: { + Button { + viewModel.loadItems() + } label: { + Text("Try Again") + } + .buttonStyle(.bordered) } - + .testId(AccessibilityIdentifier.FeedItems.errorView) + case .empty: ContentUnavailableView { Label("No Items", systemImage: Constants.Images.noItemsIcon) } description: { Text("This feed contains no items") } + .testId(AccessibilityIdentifier.FeedItems.emptyView) } } .navigationTitle(viewModel.feedTitle) .task { - await viewModel.loadItems() + viewModel.loadItems() + } + } +} + +#if DEBUG +import Dependencies + +private extension FeedItem { + static var preview: FeedItem { + FeedItem( + feedID: UUID(), + title: "Example News Story", + link: URL(string: Constants.URLs.bbcNews)!, + pubDate: Date(), + description: "This is an example news story with all the details you might expect.", + imageURL: URL(string: Constants.URLs.bbcNews) + ) + } + + static var previewNoImage: FeedItem { + FeedItem( + feedID: UUID(), + title: "Text-Only Story", + link: URL(string: Constants.URLs.nbcNews)!, + pubDate: Date().addingTimeInterval(-3600), + description: "This is a text-only story without an image." + ) + } +} + +#Preview("With Items") { + withDependencies { + $0.rssClient.fetchFeedItems = { _ in + return [.preview, .previewNoImage] + } + } operation: { + NavigationStack { + FeedItemsView( + viewModel: FeedItemsViewModel( + feedURL: URL(string: Constants.URLs.bbcNews)!, + feedTitle: "BBC News" + ) + ) + } + } +} + +#Preview("Empty") { + withDependencies { + $0.rssClient.fetchFeedItems = { _ in + return [] + } + } operation: { + NavigationStack { + FeedItemsView( + viewModel: FeedItemsViewModel( + feedURL: URL(string: Constants.URLs.bbcNews)!, + feedTitle: "BBC News" + ) + ) + } + } +} + +#Preview("Error") { + withDependencies { + $0.rssClient.fetchFeedItems = { _ in + throw RSSError.networkError(NSError(domain: "test", code: -1)) + } + } operation: { + NavigationStack { + FeedItemsView( + viewModel: FeedItemsViewModel( + feedURL: URL(string: Constants.URLs.bbcNews)!, + feedTitle: "BBC News" + ) + ) } } } +#endif diff --git a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsViewModel.swift b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsViewModel.swift index 555e62d..074cf11 100644 --- a/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsViewModel.swift +++ b/RSSReaderKit/Sources/FeedItemsFeature/FeedItemsViewModel.swift @@ -16,36 +16,43 @@ import UIKit public class FeedItemsViewModel: Identifiable { @ObservationIgnored @Dependency(\.rssClient) private var rssClient - + @ObservationIgnored + @Dependency(\.openURL) private var openURL + let feedURL: URL let feedTitle: String - + var state: FeedItemsState = .loading - + + private var loadTask: Task? + public init(feedURL: URL, feedTitle: String) { self.feedURL = feedURL self.feedTitle = feedTitle } - - @MainActor - func loadItems() async { + + func loadItems() { + loadTask?.cancel() state = .loading - - do { - let items = try await rssClient.fetchFeedItems(feedURL) - - if items.isEmpty { - state = .empty - } else { - state = .loaded(items) + + loadTask = Task { + do { + let items = try await rssClient.fetchFeedItems(feedURL) + + if items.isEmpty { + state = .empty + } else { + state = .loaded(items) + } + } catch { + state = .error(RSSErrorMapper.map(error)) } - } catch { - state = .error(RSSErrorMapper.mapToViewError(error)) } } - - @MainActor + func openLink(for item: FeedItem) { - UIApplication.shared.open(item.link) + Task { + await openURL(item.link) + } } } diff --git a/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedView.swift b/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedView.swift index b640df3..c89503d 100644 --- a/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedView.swift +++ b/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedView.swift @@ -11,11 +11,11 @@ import SwiftUI struct AddFeedView: View { @Environment(\.dismiss) private var dismiss @State private var viewModel: AddFeedViewModel - + init(feeds: Binding<[FeedViewModel]>) { _viewModel = State(initialValue: AddFeedViewModel(feeds: feeds)) } - + var body: some View { NavigationStack { Form { @@ -24,6 +24,7 @@ struct AddFeedView: View { .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) + .testId(AccessibilityIdentifier.AddFeed.urlTextField) } header: { Text("Enter RSS feed URL") } footer: { @@ -31,17 +32,17 @@ struct AddFeedView: View { Text("Examples (tap to use):") .font(.footnote) .foregroundColor(.secondary) - + VStack(alignment: .leading, spacing: Constants.UI.exampleButtonSpacing) { Button("BBC News") { viewModel.urlString = Constants.URLs.bbcNews } - .font(.footnote) - + .testId(AccessibilityIdentifier.AddFeed.bbcExampleButton) + Button("NBC News") { viewModel.urlString = Constants.URLs.nbcNews } - .font(.footnote) + .testId(AccessibilityIdentifier.AddFeed.nbcExampleButton) } } } @@ -53,13 +54,15 @@ struct AddFeedView: View { Button("Cancel") { dismiss() } + .testId(AccessibilityIdentifier.AddFeed.cancelButton) } - + ToolbarItem(placement: .confirmationAction) { Button("Add") { viewModel.addFeed() } .disabled(!viewModel.isValidURL || viewModel.state == .adding) + .testId(AccessibilityIdentifier.AddFeed.addButton) } } .overlay { diff --git a/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedViewModel.swift b/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedViewModel.swift index c50d1d8..c822d5a 100644 --- a/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedViewModel.swift +++ b/RSSReaderKit/Sources/FeedListFeature/AddFeed/AddFeedViewModel.swift @@ -20,60 +20,58 @@ enum AddFeedState: Equatable { case success } -@MainActor -@Observable class AddFeedViewModel { - +@MainActor @Observable +class AddFeedViewModel { @ObservationIgnored @Dependency(\.persistenceClient) private var persistenceClient - + @ObservationIgnored @Dependency(\.rssClient) private var rssClient - + private var feeds: Binding<[FeedViewModel]> private var addFeedTask: Task? - + var urlString: String = "" var state: AddFeedState = .idle - + init(feeds: Binding<[FeedViewModel]>) { self.feeds = feeds } - + var isValidURL: Bool { guard !urlString.isEmpty else { return false } return URL(string: urlString) != nil } - + func addFeed() { guard let url = URL(string: urlString) else { state = .error(.invalidURL) return } - + guard !feeds.wrappedValue.contains(where: { $0.url == url }) else { state = .error(.duplicateFeed) return } - + addFeedTask?.cancel() - + state = .adding - + addFeedTask = Task { do { let feed = try await rssClient.fetchFeed(url) - + let feedViewModel = FeedViewModel(url: url, feed: feed) feedViewModel.state = .loaded(feed) - - feeds.wrappedValue.insert(feedViewModel, at: 0) - - let feedsToSave = feeds.wrappedValue.map { $0.feed } - try await persistenceClient.saveFeeds(feedsToSave) - + + feeds.wrappedValue.insert(feedViewModel, at: 0) + + try await persistenceClient.addFeed(feed) + state = .success } catch { - state = .error(RSSErrorMapper.mapToViewError(error)) + state = .error(RSSErrorMapper.map(error)) } } } diff --git a/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListView.swift b/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListView.swift index a80af59..e4cb6d2 100644 --- a/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListView.swift +++ b/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListView.swift @@ -14,54 +14,77 @@ import SwiftUI public struct FeedListView: View { @State private var viewModel = FeedListViewModel() @State private var showingAddFeed = false - - public init() {} - + private let showOnlyFavorites: Bool + + public init(showOnlyFavorites: Bool = false) { + self.showOnlyFavorites = showOnlyFavorites + } + + var displayedFeeds: [FeedViewModel] { + showOnlyFavorites ? viewModel.favoriteFeeds : viewModel.feeds + } + public var body: some View { - NavigationStack { - List { - ForEach(viewModel.feeds) { feed in - NavigationLink(value: feed) { - FeedView(viewModel: feed) + List { + let displayedFeeds = showOnlyFavorites ? viewModel.favoriteFeeds : viewModel.feeds + + ForEach(displayedFeeds) { feed in + FeedView(viewModel: feed) + .background { + NavigationLink(value: feed) {} + .opacity(0) } - } - .onDelete { indexSet in - viewModel.removeFeed(at: indexSet) - } } - .navigationTitle("RSS Feeds") - .navigationDestination(for: FeedViewModel.self) { feed in - FeedItemsView( - viewModel: FeedItemsViewModel( - feedURL: feed.url, - feedTitle: feed.feed.title ?? "Unnamed feed" - ) - ) + .onDelete { indexSet in + viewModel.removeFeed(at: indexSet, fromFavorites: showOnlyFavorites) } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showingAddFeed = true - } label: { - Label("Add Feed", systemImage: Constants.Images.addIcon) - } - } - if !viewModel.feeds.isEmpty { - ToolbarItem(placement: .navigationBarLeading) { - EditButton() - } + } + .testId(showOnlyFavorites ? + AccessibilityIdentifier.FeedList.favoritesList : + AccessibilityIdentifier.FeedList.feedsList) + .onAppear { + viewModel.loadFeeds() + } + .navigationTitle(showOnlyFavorites ? "Favorite Feeds" : "RSS Feeds") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: FeedViewModel.self) { feed in + FeedItemsView( + viewModel: FeedItemsViewModel( + feedURL: feed.url, + feedTitle: feed.feed.title ?? "Unnamed feed" + ) + ) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingAddFeed = true + } label: { + Label("Add Feed", systemImage: Constants.Images.addIcon) } + .testId(AccessibilityIdentifier.FeedList.addFeedButton) } - .sheet(isPresented: $showingAddFeed) { - AddFeedView(feeds: $viewModel.feeds) + if !viewModel.feeds.isEmpty { + ToolbarItem(placement: .navigationBarLeading) { + EditButton() + .testId(AccessibilityIdentifier.FeedList.editButton) + } } - .overlay { - if viewModel.feeds.isEmpty { - ContentUnavailableView { - Label("No Feeds", systemImage: Constants.Images.noItemsIcon) - } description: { - Text("Add an RSS feed to get started") - } actions: { + } + .sheet(isPresented: $showingAddFeed) { + AddFeedView(feeds: $viewModel.feeds) + } + .overlay { + if displayedFeeds.isEmpty { + ContentUnavailableView { + Label(showOnlyFavorites ? "No Favorites" : "No Feeds", + systemImage: Constants.Images.noItemsIcon) + } description: { + Text(showOnlyFavorites ? + "Add feeds to favorites from the Feeds tab" : + "Add an RSS feed to get started") + } actions: { + if !showOnlyFavorites { Button { showingAddFeed = true } label: { diff --git a/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListViewModel.swift b/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListViewModel.swift index e05fbe9..a716603 100644 --- a/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListViewModel.swift +++ b/RSSReaderKit/Sources/FeedListFeature/FeedList/FeedListViewModel.swift @@ -20,25 +20,29 @@ enum FeedListState: Equatable { case error(RSSViewError) } -@MainActor -@Observable class FeedListViewModel { +@MainActor @Observable +public class FeedListViewModel { @ObservationIgnored @Dependency(\.rssClient) private var rssClient - + @ObservationIgnored @Dependency(\.persistenceClient) private var persistenceClient - + var feeds: [FeedViewModel] = [] var state: FeedListState = .idle - - private var saveTask: Task? - private var loadTask: Task? - - init() { - loadFeeds() + + var favoriteFeeds: [FeedViewModel] { + feeds.filter { $0.feed.isFavorite } } - + + private var loadTask: Task? + private var updateTask: Task? + private var deleteTask: Task? + + public init() {} + func loadFeeds() { + feeds.removeAll() state = .loading loadTask?.cancel() loadTask = Task { @@ -51,24 +55,47 @@ enum FeedListState: Equatable { } state = .idle } catch { - state = .error(RSSErrorMapper.mapToViewError(error)) + state = .error(RSSErrorMapper.map(error)) + } + } + } + + func removeFeed(at indexSet: IndexSet, fromFavorites: Bool = false) { + if fromFavorites { + let feedsToRemoveFromFavorites = indexSet.map { favoriteFeeds[$0] } + for feed in feedsToRemoveFromFavorites { + if let index = feeds.firstIndex(where: { $0.url == feed.url }) { + feeds[index].feed.isFavorite = false + toggleFavorite(feeds[index]) + } } + } else { + let feedsToDelete = indexSet.map { feeds[$0] } + for feed in feedsToDelete { + deleteFeed(feed) + } + feeds.remove(atOffsets: indexSet) } } - - func removeFeed(at indexSet: IndexSet) { - feeds.remove(atOffsets: indexSet) - saveFeeds() + + private func toggleFavorite(_ feedViewModel: FeedViewModel) { + updateTask?.cancel() + updateTask = Task { + do { + try await persistenceClient.updateFeed(feedViewModel.feed) + } catch { + state = .error(RSSErrorMapper.map(error)) + } + } } - - func saveFeeds() { - saveTask?.cancel() - saveTask = Task { + + private func deleteFeed(_ feedViewModel: FeedViewModel) { + deleteTask?.cancel() + deleteTask = Task { do { - let feedsToSave = feeds.map { $0.feed } - try await persistenceClient.saveFeeds(feedsToSave) + try await persistenceClient.deleteFeed(feedViewModel.url) } catch { - state = .error(RSSErrorMapper.mapToViewError(error)) + state = .error(RSSErrorMapper.map(error)) } } } diff --git a/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedView.swift b/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedView.swift index 2944a7e..fdd8294 100644 --- a/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedView.swift +++ b/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedView.swift @@ -11,7 +11,7 @@ import SharedModels struct FeedView: View { let viewModel: FeedViewModel - + var body: some View { HStack(spacing: Constants.UI.feedRowSpacing) { switch viewModel.state { @@ -19,16 +19,17 @@ struct FeedView: View { Image(systemName: Constants.Images.loadingIcon) .font(.title2) .frame(width: Constants.UI.feedIconSize, height: Constants.UI.feedIconSize) - + .testId(AccessibilityIdentifier.FeedView.loadingView) + VStack(alignment: .leading) { Text(viewModel.url.absoluteString) .font(.headline) - .lineLimit(1) + .lineLimit(Constants.UI.feedTitleLineLimit) Text("Loading feed details...") .font(.caption) .foregroundStyle(.secondary) } - + case .loaded(let feed): if let imageURL = feed.imageURL { AsyncImage(url: imageURL) { image in @@ -44,34 +45,58 @@ struct FeedView: View { .frame(width: Constants.UI.feedIconSize, height: Constants.UI.feedIconSize) .foregroundStyle(.blue) } - + VStack(alignment: .leading, spacing: Constants.UI.verticalPadding) { Text(feed.title ?? "Unnamed Feed") .font(.headline) - + .testId(AccessibilityIdentifier.FeedView.feedTitle) + if let description = feed.description { Text(description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(Constants.UI.feedDescriptionLineLimit) + .testId(AccessibilityIdentifier.FeedView.feedDescription) + } + HStack { + Spacer() + + Button(action: viewModel.toggleNotifications) { + Image(systemName: viewModel.feed.notificationsEnabled ? Constants.Images.notificationEnabledIcon : Constants.Images.notificationDisabledIcon) + .font(.title2) + .foregroundColor(viewModel.feed.notificationsEnabled ? .blue : .gray) + } + .buttonStyle(BorderlessButtonStyle()) + .testId(AccessibilityIdentifier.FeedView.notificationsButton) + + Button { + viewModel.toggleFavorite() + } label: { + Image(systemName: viewModel.feed.isFavorite ? "star.fill" : "star") + .font(.title2) + .foregroundColor(viewModel.feed.isFavorite ? .yellow : .gray) + } + .buttonStyle(BorderlessButtonStyle()) + .testId(AccessibilityIdentifier.FeedView.favoriteButton) } } - + case .error(let error): Image(systemName: Constants.Images.errorIcon) .font(.title2) .frame(width: Constants.UI.feedIconSize, height: Constants.UI.feedIconSize) .foregroundStyle(.red) - + VStack(alignment: .leading) { Text(viewModel.url.absoluteString) .font(.headline) - .lineLimit(1) + .lineLimit(Constants.UI.feedTitleLineLimit) Text("Failed to load feed: \(error.errorDescription)") .font(.caption) .foregroundStyle(.red) } - + .testId(AccessibilityIdentifier.FeedView.errorView) + case .empty: Text("No feed data available") .foregroundStyle(.secondary) diff --git a/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedViewModel.swift b/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedViewModel.swift index 502c4b9..e0742a1 100644 --- a/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedViewModel.swift +++ b/RSSReaderKit/Sources/FeedListFeature/FeedView/FeedViewModel.swift @@ -8,6 +8,8 @@ import Common import Dependencies import Foundation +import NotificationClient +import PersistenceClient import RSSClient import SharedModels @@ -16,7 +18,7 @@ enum FeedViewState: Equatable { case loaded(Feed) case error(RSSViewError) case empty - + static func == (lhs: FeedViewState, rhs: FeedViewState) -> Bool { switch (lhs, rhs) { case (.loading, .loading), (.empty, .empty): @@ -33,26 +35,70 @@ enum FeedViewState: Equatable { @MainActor @Observable class FeedViewModel: Identifiable { - @ObservationIgnored - @Dependency(\.rssClient) private var rssClient - + @ObservationIgnored @Dependency(\.notificationClient) private var notificationClient + @ObservationIgnored @Dependency(\.rssClient.fetchFeed) private var fetchFeed + @ObservationIgnored @Dependency(\.persistenceClient.updateFeed) private var updateFeed + let url: URL - let feed: Feed + var feed: Feed var state: FeedViewState = .loading - + + private var toggleFavoriteTask: Task? + private var toggleNotificationsTask: Task? + private var loadTask: Task? + init(url: URL, feed: Feed) { self.url = url self.feed = feed } - - func loadFeedDetails() async { + + func loadFeedDetails() { + loadTask?.cancel() state = .loading - - do { - let fetchedFeed = try await rssClient.fetchFeed(url) - state = .loaded(fetchedFeed) - } catch let error { - state = .error(RSSErrorMapper.mapToViewError(error)) + + loadTask = Task { + do { + let fetchedFeed = try await fetchFeed(url) + state = .loaded(fetchedFeed) + } catch let error { + state = .error(RSSErrorMapper.map(error)) + } + } + } + + func toggleFavorite() { + toggleFavoriteTask?.cancel() + feed.isFavorite.toggle() + + toggleFavoriteTask = Task { + do { + try await updateFeed(feed) + } catch { + state = .error(RSSErrorMapper.map(error)) + } + } + } + + func toggleNotifications() { + toggleNotificationsTask?.cancel() + + toggleNotificationsTask = Task { + do { + if !feed.notificationsEnabled { + try await notificationClient.requestPermissions() + } + + feed.notificationsEnabled.toggle() + + try await updateFeed(feed) + + if feed.notificationsEnabled { + try await notificationClient.checkForNewItems() + } + } catch { + feed.notificationsEnabled.toggle() + state = .error(RSSErrorMapper.map(error)) + } } } } @@ -61,7 +107,7 @@ extension FeedViewModel: Hashable { nonisolated static func == (lhs: FeedViewModel, rhs: FeedViewModel) -> Bool { lhs.url == rhs.url } - + nonisolated func hash(into hasher: inout Hasher) { hasher.combine(url) } diff --git a/RSSReaderKit/Sources/NotificationClient/BackgroundRefreshClient.swift b/RSSReaderKit/Sources/NotificationClient/BackgroundRefreshClient.swift new file mode 100644 index 0000000..36895f4 --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/BackgroundRefreshClient.swift @@ -0,0 +1,135 @@ +// +// BackgroundRefreshClient.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +import BackgroundTasks +import Common +import Dependencies +import Foundation +import UserNotifications + +@MainActor +public final class BackgroundRefreshClient { + @Dependency(\.notificationClient) private var notificationClient + + private let feedRefreshTaskIdentifier = "com.rssreader.feedrefresh" + + public static let shared = BackgroundRefreshClient() + + private init() {} + + public func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: feedRefreshTaskIdentifier, + using: nil + ) { [weak self] task in + if let bgTask = task as? BGAppRefreshTask { + self?.handleAppRefresh(task: bgTask) + } + } + } + + public func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: feedRefreshTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + print("Scheduled background refresh for future execution") + } catch { + print("Could not schedule app refresh: \(error)") + } + } + + private func handleAppRefresh(task: BGAppRefreshTask) { + scheduleAppRefresh() + + let refreshTask = Task { + do { + try await notificationClient.checkForNewItems() + task.setTaskCompleted(success: true) + } catch { + task.setTaskCompleted(success: false) + } + } + + task.expirationHandler = { + refreshTask.cancel() + task.setTaskCompleted(success: false) + } + } +} + +#if DEBUG +extension BackgroundRefreshClient { + public func manuallyTriggerBackgroundRefresh() async -> Bool { + do { + let center = UNUserNotificationCenter.current() + let testContent = UNMutableNotificationContent() + testContent.title = "Manual Refresh Started" + testContent.body = "Starting background refresh at \(Date().formatted(date: .numeric, time: .standard))" + testContent.sound = .default + + let startTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + + let testRequest = UNNotificationRequest( + identifier: "manual-refresh-start-\(UUID().uuidString)", + content: testContent, + trigger: startTrigger + ) + + try await center.add(testRequest) + print("Pre-refresh notification with 1-second delay") + + try await notificationClient.checkForNewItems() + print("Manual background refresh successful") + + let successContent = UNMutableNotificationContent() + successContent.title = "✅ Manual Refresh Completed" + successContent.body = "Background refresh completed at \(Date().formatted(date: .numeric, time: .standard))" + successContent.sound = .default + + let successTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + + let successRequest = UNNotificationRequest( + identifier: "manual-refresh-complete-\(UUID().uuidString)", + content: successContent, + trigger: successTrigger + ) + + try await center.add(successRequest) + print("Post-refresh success notification with 2-second delay") + + return true + } catch { + print("Error during manual background refresh: \(error)") + + do { + let center = UNUserNotificationCenter.current() + let errorContent = UNMutableNotificationContent() + errorContent.title = "❌ Background Refresh Error" + errorContent.body = "Error: \(error.localizedDescription)" + errorContent.sound = .default + + let errorTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + + let errorRequest = UNNotificationRequest( + identifier: "manual-refresh-error-\(UUID().uuidString)", + content: errorContent, + trigger: errorTrigger + ) + + try await center.add(errorRequest) + print("Error notification with 1-second delay") + } catch { + print("Failed to send error notification: \(error)") + } + + return false + } + } +} +#endif diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationClient.swift b/RSSReaderKit/Sources/NotificationClient/NotificationClient.swift new file mode 100644 index 0000000..2e2f8cc --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationClient.swift @@ -0,0 +1,21 @@ +// +// NotificationClient.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +import Foundation + +public struct NotificationClient: Sendable { + public var requestPermissions: @Sendable () async throws -> Void + public var checkForNewItems: @Sendable () async throws -> Void + + public init( + requestPermissions: @escaping @Sendable () async throws -> Void, + checkForNewItems: @escaping @Sendable () async throws -> Void + ) { + self.requestPermissions = requestPermissions + self.checkForNewItems = checkForNewItems + } +} diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationClientDependency.swift b/RSSReaderKit/Sources/NotificationClient/NotificationClientDependency.swift new file mode 100644 index 0000000..fd83e65 --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationClientDependency.swift @@ -0,0 +1,28 @@ +// +// NotificationClientLive.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +import Dependencies +import Foundation +import SharedModels + +extension NotificationClient: DependencyKey { + public static var liveValue: NotificationClient { .live() } + + public static var testValue: NotificationClient { + NotificationClient( + requestPermissions: {}, + checkForNewItems: {} + ) + } +} + +extension DependencyValues { + public var notificationClient: NotificationClient { + get { self[NotificationClient.self] } + set { self[NotificationClient.self] = newValue } + } +} diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationClientLive.swift b/RSSReaderKit/Sources/NotificationClient/NotificationClientLive.swift new file mode 100644 index 0000000..927f42b --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationClientLive.swift @@ -0,0 +1,178 @@ +// +// NotificationClientLive.swift +// RSSReaderKit + +import Common +import Dependencies +import Foundation +import SharedModels +@preconcurrency import UserNotifications +import PersistenceClient +import RSSClient +import UIKit + +extension NotificationClient { + public static func live() -> NotificationClient { + @Dependency(\.notificationCenter) var notificationCenter + @Dependency(\.persistenceClient.loadFeeds) var loadFeeds + @Dependency(\.rssClient.fetchFeedItems) var fetchFeedItems + + return NotificationClient( + requestPermissions: { + try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + }, + checkForNewItems: { + try await checkForNewItems( + center: notificationCenter, + loadFeeds: loadFeeds, + fetchFeedItems: fetchFeedItems + ) + } + ) + } + + private static func checkForNewItems( + center: UNUserNotificationCenter, + loadFeeds: @escaping () async throws -> [Feed], + fetchFeedItems: @escaping (URL) async throws -> [FeedItem] + ) async throws { + guard await center.notificationSettings().authorizationStatus == .authorized else { + throw NotificationError.permissionDenied + } + + let feeds = try await loadFeeds() + let enabledFeeds = feeds.filter(\.notificationsEnabled) + print("🔔 Checking \(enabledFeeds.count) enabled feeds for updates") + + // Store current check time before processing + let currentCheck = Date() + let lastCheck = UserDefaults.standard.object( + forKey: Constants.Storage.lastNotificationCheckKey + ) as? Date ?? Date.distantPast + + print("🔔 Last check was at: \(lastCheck)") + + // Get stored notification IDs + let notifiedItemIDs = UserDefaults.standard.stringArray( + forKey: Constants.Storage.notifiedItemsKey + ) ?? [] + + var newNotifiedItemIDs = [String]() + try await processFeeds( + enabledFeeds, + lastCheck: lastCheck, + notifiedItemIDs: notifiedItemIDs, + newNotifiedItemIDs: &newNotifiedItemIDs, + center: center, + fetchFeedItems: fetchFeedItems + ) + + // Update state only if we processed feeds successfully + UserDefaults.standard.set(currentCheck, forKey: Constants.Storage.lastNotificationCheckKey) + + // Manage notification IDs + let updatedIDs = (notifiedItemIDs + newNotifiedItemIDs) + .suffix(Constants.Notifications.pruneToCount) + UserDefaults.standard.set(Array(updatedIDs), forKey: Constants.Storage.notifiedItemsKey) + + print("🔔 Check complete. New notifications: \(newNotifiedItemIDs.count)") + } + + private static func processFeeds( + _ feeds: [Feed], + lastCheck: Date, + notifiedItemIDs: [String], + newNotifiedItemIDs: inout [String], + center: UNUserNotificationCenter, + fetchFeedItems: @escaping (URL) async throws -> [FeedItem] + ) async throws { + var delayOffset = 0.5 + + for feed in feeds { + do { + print("🔔 Checking feed: \(feed.title ?? feed.url.absoluteString)") + let items = try await fetchFeedItems(feed.url) + + let newItems = items.filter { item in + guard let pubDate = item.pubDate else { + print("🔔 Item has no publication date: \(item.title)") + return false + } + + let isNew = pubDate > lastCheck + let isNotNotified = !notifiedItemIDs.contains(item.id.uuidString) + + if isNew { + print("🔔 Found new item: \(item.title) published at \(pubDate)") + } + + return isNew && isNotNotified + } + + print("🔔 Found \(newItems.count) new items in feed") + + for item in newItems { + try await scheduleNotification( + for: item, + from: feed, + delayOffset: delayOffset, + center: center + ) + newNotifiedItemIDs.append(item.id.uuidString) + delayOffset += 0.5 + } + } catch { + print("🔔 Error processing feed \(feed.url): \(error)") + } + } + } + + private static func scheduleNotification( + for item: FeedItem, + from feed: Feed, + delayOffset: Double, + center: UNUserNotificationCenter + ) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let content = UNMutableNotificationContent() + content.title = feed.title ?? "New item in feed" + content.body = item.title + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: delayOffset, + repeats: false + ) + + let request = UNNotificationRequest( + identifier: "notification-\(item.id.uuidString)", + content: content, + trigger: trigger + ) + + print("🔔 Scheduling notification for: \(item.title)") + center.add(request) { error in + if let error = error { + print("🔔 Failed to schedule notification: \(error)") + continuation.resume(throwing: error) + } else { + print("🔔 Successfully scheduled notification") + continuation.resume() + } + } + } + } +} + +// MARK: - Dependencies + +private enum NotificationCenterKey: DependencyKey { + static let liveValue = UNUserNotificationCenter.current() +} + +extension DependencyValues { + var notificationCenter: UNUserNotificationCenter { + get { self[NotificationCenterKey.self] } + set { self[NotificationCenterKey.self] = newValue } + } +} diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationDebugView.swift b/RSSReaderKit/Sources/NotificationClient/NotificationDebugView.swift new file mode 100644 index 0000000..c801683 --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationDebugView.swift @@ -0,0 +1,357 @@ +// +// NotificationDebugView.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Common +import Dependencies +import SwiftUI +import UIKit +@preconcurrency import UserNotifications + +@MainActor +public struct NotificationDebugView: View { + private enum Section { + case status + case actions + case results + } + + @State private var isRefreshing = false + @State private var refreshResult = "" + @State private var notificationStatus = "Unknown" + @Environment(\.scenePhase) private var scenePhase + @Dependency(\.notificationClient) private var notificationClient + + public init() {} + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Constants.UI.debugViewSpacing) { + header + statusSection + actionsSection + resultsSection + } + .padding() + } + .onAppear(perform: checkNotificationStatus) + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + checkNotificationStatus() + } + } + } +} + +// MARK: - View Components +private extension NotificationDebugView { + var header: some View { + Text("Notification Debug") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .center) + } + + var statusSection: some View { + VStack(alignment: .leading, spacing: Constants.UI.debugSectionSpacing) { + Text("Status") + .font(.headline) + + HStack { + Text("Notification Status:") + .fontWeight(.medium) + + Text(notificationStatus) + .foregroundStyle(statusColor) + .fontWeight(.semibold) + + Spacer() + + Button("Check", action: checkNotificationStatus) + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding() + .background(Color.gray.opacity(Constants.UI.debugBackgroundOpacity)) + .clipShape(RoundedRectangle(cornerRadius: Constants.UI.debugCornerRadius)) + } + } + + var actionsSection: some View { + VStack(alignment: .leading, spacing: Constants.UI.debugActionSpacing) { + Text("Actions") + .font(.headline) + + Group { + actionButton( + title: "Request Notification Permissions", + icon: "bell.badge", + action: requestPermissions + ) + + actionButton( + title: "Send Delayed (5 sec) Notification", + icon: "clock", + action: sendDelayedNotification, + accentColor: .blue, + backgroundApp: true + ) + + actionButton( + title: "Trigger Manual Background Refresh", + icon: "arrow.clockwise", + action: triggerManualRefresh, + accentColor: .green + ) + + actionButton( + title: "Test Feed Parsing", + icon: "doc.text.magnifyingglass", + action: testFeedParsing, + accentColor: .purple + ) + } + .disabled(isRefreshing) + } + } + + var resultsSection: some View { + VStack(alignment: .leading, spacing: Constants.UI.debugSectionSpacing) { + Text("Results") + .font(.headline) + + Group { + if isRefreshing { + HStack { + ProgressView() + Text("Processing...") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .center) + } + + Text(refreshResult) + .multilineTextAlignment(.leading) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(Constants.UI.debugBackgroundOpacity)) + .clipShape(RoundedRectangle(cornerRadius: Constants.UI.debugCornerRadius)) + .animation(.easeInOut, value: refreshResult) + } + } + + func actionButton( + title: String, + icon: String, + action: @escaping () -> Void, + accentColor: Color = .blue, + backgroundApp: Bool = false + ) -> some View { + Button { + action() + if backgroundApp { + moveAppToBackground() + } + } label: { + HStack { + Image(systemName: icon) + .foregroundColor(.white) + .frame(width: Constants.UI.debugIconSize, height: Constants.UI.debugIconSize) + .background(accentColor) + .clipShape(Circle()) + + Text(title) + .foregroundColor(.primary) + + Spacer() + + if backgroundApp { + Image(systemName: "iphone.and.arrow.forward") + .foregroundColor(.secondary) + .font(.caption) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding() + .background(Color.gray.opacity(Constants.UI.debugBackgroundOpacity)) + .clipShape(RoundedRectangle(cornerRadius: Constants.UI.debugCornerRadius)) + } +} + +// MARK: - Helper Properties +private extension NotificationDebugView { + var statusColor: Color { + switch notificationStatus { + case "Authorized": return .green + case "Denied": return .red + default: return .orange + } + } +} + +// MARK: - Actions +private extension NotificationDebugView { + func moveAppToBackground() { + Task { try? await Task.sleep(nanoseconds: Constants.UI.debugUIUpdateDelay) } + UIApplication.shared.perform(#selector(NSXPCConnection.suspend)) + } + + func checkNotificationStatus() { + Task { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + notificationStatus = settings.authorizationStatus.description + + if settings.authorizationStatus == .authorized { + listScheduledNotifications() + } + } + } + + func listScheduledNotifications() { + Task { + let center = UNUserNotificationCenter.current() + let pendingRequests = await center.pendingNotificationRequests() + print("Pending notifications: \(pendingRequests.count)") + for (index, request) in pendingRequests.enumerated() { + print("[\(index)] \(request.identifier): \(request.content.title)") + } + } + } + + func requestPermissions() { + Task { + do { + try await notificationClient.requestPermissions() + refreshResult = "✅ Notification permissions granted" + checkNotificationStatus() + } catch { + refreshResult = "❌ Failed to get permissions: \(error.localizedDescription)" + } + } + } + + func triggerManualRefresh() { + Task { + isRefreshing = true + refreshResult = "Refreshing..." + + let success = await BackgroundRefreshClient.shared.manuallyTriggerBackgroundRefresh() + + isRefreshing = false + if success { + let timestamp = Date().formatted(date: .numeric, time: .standard) + refreshResult = "✅ Background refresh triggered successfully at \(timestamp)" + try? await sendConfirmationNotification("Background refresh executed") + } else { + refreshResult = "❌ Background refresh failed" + } + } + } +} + +// MARK: - Notification Handling +private extension NotificationDebugView { + func sendDelayedNotification() { + Task { + do { + let content = UNMutableNotificationContent() + content.title = "Delayed Test Notification" + content.body = "This notification was scheduled \(Int(Constants.UI.debugDelayedNotificationTime)) seconds ago" + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Constants.UI.debugDelayedNotificationTime, + repeats: false + ) + + try await scheduleTestNotification(content: content, trigger: trigger) + refreshResult = "✅ Delayed notification scheduled" + listScheduledNotifications() + } catch { + refreshResult = "❌ Failed to schedule delayed notification: \(error.localizedDescription)" + } + } + } + + func sendConfirmationNotification(_ context: String) async throws { + let content = UNMutableNotificationContent() + content.title = "Test Notification: \(context)" + content.body = "Test notification sent at \(Date().formatted(date: .numeric, time: .standard))" + content.sound = .default + + try await scheduleTestNotification(content: content, trigger: nil) + listScheduledNotifications() + } + + func scheduleTestNotification( + content: UNMutableNotificationContent, + trigger: UNNotificationTrigger? + ) async throws { + let request = UNNotificationRequest( + identifier: "test-\(UUID().uuidString)", + content: content, + trigger: trigger + ) + try await UNUserNotificationCenter.current().add(request) + } +} + +// MARK: - Feed Testing +private extension NotificationDebugView { + func testFeedParsing() { + Task { + isRefreshing = true + refreshResult = "Testing feed parsing..." + + @Dependency(\.persistenceClient) var persistenceClient + @Dependency(\.rssClient) var rssClient + + do { + var results = "" + let feeds = try await persistenceClient.loadFeeds() + results += "📊 Stored feeds: \(feeds.count)\n" + + if feeds.isEmpty { + results += "ℹ️ No stored feeds to test\n" + } else { + for (index, feed) in feeds.enumerated() { + do { + let items = try await rssClient.fetchFeedItems(feed.url) + let status = "✅ \(items.count) items" + results += "\(index + 1). \(feed.title ?? feed.url.absoluteString): \(status)\n" + } catch { + results += "\(index + 1). \(feed.title ?? feed.url.absoluteString): ❌ Error: \(error)\n" + } + } + } + + refreshResult = results + } catch { + refreshResult = "❌ Error testing feeds: \(error.localizedDescription)" + } + + isRefreshing = false + } + } +} + +// MARK: - UNAuthorizationStatus Description +private extension UNAuthorizationStatus { + var description: String { + switch self { + case .authorized: return "Authorized" + case .denied: return "Denied" + case .notDetermined: return "Not Determined" + case .provisional: return "Provisional" + case .ephemeral: return "Ephemeral" + @unknown default: return "Unknown" + } + } +} diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationDelegate.swift b/RSSReaderKit/Sources/NotificationClient/NotificationDelegate.swift new file mode 100644 index 0000000..28ef629 --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationDelegate.swift @@ -0,0 +1,53 @@ +// +// NotificationDelegate.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation +@preconcurrency import UserNotifications + +public final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + // Fix the shared singleton pattern to be concurrency-safe + public static var shared: NotificationDelegate { NotificationDelegate() } + + private override init() { + super.init() + } + + public func setup() { + UNUserNotificationCenter.current().delegate = self + print("NotificationDelegate setup complete") + } + + // This method allows notifications to be displayed when the app is in the foreground + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + print("⭐️ IMPORTANT: willPresent notification called for: \(notification.request.identifier)") + print("⭐️ IMPORTANT: Notification content: \(notification.request.content.title) - \(notification.request.content.body)") + + // Force all possible presentation options for notifications in foreground + if #available(iOS 14.0, *) { + completionHandler([.banner, .list, .sound, .badge]) + } else { + completionHandler([.alert, .sound, .badge]) + } + } + + // This method handles when a user interacts with a notification + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + print("⭐️ IMPORTANT: User interacted with notification: \(response.notification.request.identifier)") + + // Handle the notification response here if needed + + completionHandler() + } +} diff --git a/RSSReaderKit/Sources/NotificationClient/NotificationError.swift b/RSSReaderKit/Sources/NotificationClient/NotificationError.swift new file mode 100644 index 0000000..aa52d6d --- /dev/null +++ b/RSSReaderKit/Sources/NotificationClient/NotificationError.swift @@ -0,0 +1,10 @@ +// +// NotificationError.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +public enum NotificationError: Error { + case permissionDenied +} diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistableFeed.swift b/RSSReaderKit/Sources/PersistenceClient/PersistableFeed.swift index 6525155..444d55f 100644 --- a/RSSReaderKit/Sources/PersistenceClient/PersistableFeed.swift +++ b/RSSReaderKit/Sources/PersistenceClient/PersistableFeed.swift @@ -1,5 +1,5 @@ // -// PersistableFeed.swift +// PersistenceClient.swift // RSSReaderKit // // Created by Martino Mamić on 15.04.25. @@ -10,28 +10,50 @@ import SwiftData import SharedModels @Model -public final class PersistableFeed { - @Attribute(.unique) public var id: UUID - public var url: URL - public var title: String? - public var feedDescription: String? - public var imageURLString: String? - - public init(from feed: Feed) { - self.id = feed.id - self.url = feed.url - self.title = feed.title - self.feedDescription = feed.description - self.imageURLString = feed.imageURL?.absoluteString +final class PersistableFeed { + @Attribute(.unique) + var url: URL + var title: String? + var feedDescription: String? + var imageURLString: String? + var isFavorite: Bool + var notificationsEnabled: Bool + + init( + title: String?, + url: URL, + feedDescription: String?, + imageURLString: String?, + isFavorite: Bool, + notificationsEnabled: Bool = false + ) { + self.title = title + self.url = url + self.feedDescription = feedDescription + self.imageURLString = imageURLString + self.isFavorite = isFavorite + self.notificationsEnabled = notificationsEnabled } - - public func toFeed() -> Feed { + + convenience init(from feed: Feed) { + self.init( + title: feed.title, + url: feed.url, + feedDescription: feed.description, + imageURLString: feed.imageURL?.absoluteString, + isFavorite: feed.isFavorite, + notificationsEnabled: feed.notificationsEnabled + ) + } + + func toFeed() -> Feed { Feed( - id: id, url: url, title: title, description: feedDescription, - imageURL: imageURLString.flatMap { URL(string: $0) } + imageURL: imageURLString.flatMap(URL.init(string:)), + isFavorite: isFavorite, + notificationsEnabled: notificationsEnabled ) } } diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistenceClient.swift b/RSSReaderKit/Sources/PersistenceClient/PersistenceClient.swift index 52e7d66..ebafd59 100644 --- a/RSSReaderKit/Sources/PersistenceClient/PersistenceClient.swift +++ b/RSSReaderKit/Sources/PersistenceClient/PersistenceClient.swift @@ -9,14 +9,20 @@ import Foundation import SharedModels public struct PersistenceClient: Sendable { - public var saveFeeds: @Sendable ([Feed]) async throws -> Void + public var addFeed: @Sendable (Feed) async throws -> Void + public var updateFeed: @Sendable (Feed) async throws -> Void + public var deleteFeed: @Sendable (URL) async throws -> Void public var loadFeeds: @Sendable () async throws -> [Feed] - + public init( - saveFeeds: @escaping @Sendable ([Feed]) async throws -> Void, + addFeed: @escaping @Sendable (Feed) async throws -> Void, + updateFeed: @escaping @Sendable (Feed) async throws -> Void, + deleteFeed: @escaping @Sendable (URL) async throws -> Void, loadFeeds: @escaping @Sendable () async throws -> [Feed] ) { - self.saveFeeds = saveFeeds + self.addFeed = addFeed + self.updateFeed = updateFeed + self.deleteFeed = deleteFeed self.loadFeeds = loadFeeds } } diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistenceClientDependency.swift b/RSSReaderKit/Sources/PersistenceClient/PersistenceClientDependency.swift new file mode 100644 index 0000000..8a468bc --- /dev/null +++ b/RSSReaderKit/Sources/PersistenceClient/PersistenceClientDependency.swift @@ -0,0 +1,49 @@ +// +// PersistenceDependency.swift +// RSSReaderKit +// +// Created by Martino Mamić on 15.04.25. +// + +import ConcurrencyExtras +import Dependencies +import Foundation +import SharedModels + +extension PersistenceClient: DependencyKey { + public static var liveValue: PersistenceClient { .live() } + + public static var testValue: PersistenceClient { + let feedStore = LockIsolated<[Feed]>([]) + + return PersistenceClient( + addFeed: { feed in + feedStore.withValue { feeds in + feeds.append(feed) + } + }, + updateFeed: { feed in + feedStore.withValue { feeds in + if let index = feeds.firstIndex(where: { $0.url == feed.url }) { + feeds[index] = feed + } + } + }, + deleteFeed: { url in + feedStore.withValue { feeds in + feeds.removeAll(where: { $0.url == url }) + } + }, + loadFeeds: { + return feedStore.value + } + ) + } +} + +extension DependencyValues { + public var persistenceClient: PersistenceClient { + get { self[PersistenceClient.self] } + set { self[PersistenceClient.self] = newValue } + } +} diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistenceClientLive.swift b/RSSReaderKit/Sources/PersistenceClient/PersistenceClientLive.swift index e714c4b..99d2f6d 100644 --- a/RSSReaderKit/Sources/PersistenceClient/PersistenceClientLive.swift +++ b/RSSReaderKit/Sources/PersistenceClient/PersistenceClientLive.swift @@ -12,41 +12,77 @@ import SwiftData extension PersistenceClient { public static func live() -> PersistenceClient { let modelContainer: ModelContainer - + do { let schema = Schema([PersistableFeed.self]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let modelConfiguration = ModelConfiguration(schema: schema) modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { fatalError("Failed to create model container: \(error.localizedDescription)") } - + return PersistenceClient( - saveFeeds: { feeds async throws in + addFeed: { feed async throws in let context = ModelContext(modelContainer) - - let fetchDescriptor = FetchDescriptor() - let existingFeeds = try context.fetch(fetchDescriptor) - - for feed in existingFeeds { - context.delete(feed) + let persistableFeed = PersistableFeed(from: feed) + context.insert(persistableFeed) + + do { + try context.save() + } catch { + throw PersistenceError.saveFailed(error.localizedDescription) } - - for feed in feeds { - let persistableFeed = PersistableFeed(from: feed) - context.insert(persistableFeed) + }, + updateFeed: { feed async throws in + let context = ModelContext(modelContainer) + let feedURL = feed.url + let predicate = #Predicate { $0.url == feedURL } + let descriptor = FetchDescriptor(predicate: predicate) + + do { + guard let existingFeed = try context.fetch(descriptor).first else { + return + } + // I assumed only isFavorite can change, but maybe the feed content can be modified + // if the URL changes it's a different feed, but maybe I should resolve that as well + existingFeed.title = feed.title + existingFeed.feedDescription = feed.description + existingFeed.imageURLString = feed.imageURL?.absoluteString + existingFeed.isFavorite = feed.isFavorite + existingFeed.notificationsEnabled = feed.notificationsEnabled + + try context.save() + } catch { + throw PersistenceError.saveFailed(error.localizedDescription) + } + }, + deleteFeed: { url async throws in + let context = ModelContext(modelContainer) + let predicate = #Predicate { $0.url == url } + let descriptor = FetchDescriptor(predicate: predicate) + + do { + guard let existingFeed = try context.fetch(descriptor).first else { + return + } + + context.delete(existingFeed) + try context.save() + } catch { + throw PersistenceError.saveFailed(error.localizedDescription) } - - try context.save() }, loadFeeds: { () async throws in let context = ModelContext(modelContainer) let descriptor = FetchDescriptor() - - let persistableFeeds = try context.fetch(descriptor) - return persistableFeeds.map { $0.toFeed() } - } + do { + let persistableFeeds = try context.fetch(descriptor) + return persistableFeeds.reversed().map { $0.toFeed() } + } catch { + throw PersistenceError.loadFailed(error.localizedDescription) + } + } ) } } diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistenceDependency.swift b/RSSReaderKit/Sources/PersistenceClient/PersistenceDependency.swift deleted file mode 100644 index cdaae9e..0000000 --- a/RSSReaderKit/Sources/PersistenceClient/PersistenceDependency.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PersistenceDependency.swift -// RSSReaderKit -// -// Created by Martino Mamić on 15.04.25. -// - -import Dependencies -import Foundation - -extension PersistenceClient: DependencyKey { - public static var liveValue: PersistenceClient { .live() } - - public static var testValue: PersistenceClient { - return PersistenceClient( - saveFeeds: { _ in }, - loadFeeds: { [] } - ) - } -} - -extension DependencyValues { - public var persistenceClient: PersistenceClient { - get { self[PersistenceClient.self] } - set { self[PersistenceClient.self] = newValue } - } -} diff --git a/RSSReaderKit/Sources/PersistenceClient/PersistenceError.swift b/RSSReaderKit/Sources/PersistenceClient/PersistenceError.swift new file mode 100644 index 0000000..145c182 --- /dev/null +++ b/RSSReaderKit/Sources/PersistenceClient/PersistenceError.swift @@ -0,0 +1,11 @@ +// +// PersistenceError.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +public enum PersistenceError: Error, Equatable { + case saveFailed(String) + case loadFailed(String) +} diff --git a/RSSReaderKit/Sources/RSSClient/RSSClient.swift b/RSSReaderKit/Sources/RSSClient/RSSClient.swift index 0997cc4..2a33e7a 100644 --- a/RSSReaderKit/Sources/RSSClient/RSSClient.swift +++ b/RSSReaderKit/Sources/RSSClient/RSSClient.swift @@ -9,10 +9,10 @@ import Foundation import SharedModels import Dependencies -public struct RSSClient : Sendable { +public struct RSSClient: Sendable { public var fetchFeed: @Sendable (URL) async throws -> Feed public var fetchFeedItems: @Sendable (URL) async throws -> [FeedItem] - + public init( fetchFeed: @escaping @Sendable (URL) async throws -> Feed, fetchFeedItems: @escaping @Sendable (URL) async throws -> [FeedItem] @@ -24,7 +24,7 @@ public struct RSSClient : Sendable { extension RSSClient: DependencyKey { public static var liveValue: RSSClient { RSSClient.live() } - + public static var testValue: RSSClient { return RSSClient( fetchFeed: { url in diff --git a/RSSReaderKit/Sources/RSSClient/RSSClientLive.swift b/RSSReaderKit/Sources/RSSClient/RSSClientLive.swift index 4c4e4b0..af55f22 100644 --- a/RSSReaderKit/Sources/RSSClient/RSSClientLive.swift +++ b/RSSReaderKit/Sources/RSSClient/RSSClientLive.swift @@ -12,7 +12,7 @@ import SharedModels extension RSSClient { public static func live() -> RSSClient { let parser = RSSParser() - + return RSSClient( fetchFeed: { url in do { @@ -26,7 +26,7 @@ extension RSSClient { throw RSSError.networkError(error) } }, - + fetchFeedItems: { url in do { let (data, _) = try await URLSession.shared.data(from: url) diff --git a/RSSReaderKit/Sources/RSSClient/RSSElement.swift b/RSSReaderKit/Sources/RSSClient/RSSElement.swift index 6814403..50eb0f1 100644 --- a/RSSReaderKit/Sources/RSSClient/RSSElement.swift +++ b/RSSReaderKit/Sources/RSSClient/RSSElement.swift @@ -27,7 +27,7 @@ public enum RSSAttribute: String { public enum MediaType: String { case image = "image/" - + public func matches(_ value: String) -> Bool { return value.hasPrefix(self.rawValue) } @@ -36,18 +36,18 @@ public enum MediaType: String { public enum DateFormat: String { case rfc822 = "EEE, dd MMM yyyy HH:mm:ss Z" case iso8601 = "yyyy-MM-dd'T'HH:mm:ssZ" - + public static func parseDate(_ dateString: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - + for format in [DateFormat.rfc822, DateFormat.iso8601] { formatter.dateFormat = format.rawValue if let date = formatter.date(from: dateString) { return date } } - + return nil } } diff --git a/RSSReaderKit/Sources/RSSClient/RSSParser.swift b/RSSReaderKit/Sources/RSSClient/RSSParser.swift index 57faba2..7bc4af7 100644 --- a/RSSReaderKit/Sources/RSSClient/RSSParser.swift +++ b/RSSReaderKit/Sources/RSSClient/RSSParser.swift @@ -10,17 +10,17 @@ import SharedModels public struct RSSParser: Sendable { private let delegateFactory: @Sendable () -> RSSParserDelegateProtocol - + public init(delegateFactory: @Sendable @escaping () -> RSSParserDelegateProtocol = { RSSParserDelegate() }) { self.delegateFactory = delegateFactory } - + public func parse(data: Data, feedURL: URL) async throws -> (feed: Feed?, items: [FeedItem]) { let parser = XMLParser(data: data) let delegate = delegateFactory() delegate.configure(feedURL: feedURL) parser.delegate = delegate - + if parser.parse() { return delegate.result } else if let error = parser.parserError { diff --git a/RSSReaderKit/Sources/RSSClient/RSSParserDelegate.swift b/RSSReaderKit/Sources/RSSClient/RSSParserDelegate.swift index 860643d..95065d3 100644 --- a/RSSReaderKit/Sources/RSSClient/RSSParserDelegate.swift +++ b/RSSReaderKit/Sources/RSSClient/RSSParserDelegate.swift @@ -11,7 +11,7 @@ import SharedModels public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { private var feed: Feed? private var items: [FeedItem] = [] - + private var currentElement = "" private var currentTitle: String? private var currentDescription: String? @@ -21,23 +21,23 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { private var isInsideItem = false private var isInsideImage = false private var feedID = UUID() - + private var textBuffer = "" - + public var result: (feed: Feed?, items: [FeedItem]) { return (feed: feed, items: items) } - + public override init() { super.init() } - + public func configure(feedURL: URL) { self.feed = Feed(url: feedURL) self.items = [] self.feedID = UUID() } - + public func parser( _ parser: XMLParser, didStartElement elementName: String, @@ -47,9 +47,9 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { ) { currentElement = elementName textBuffer = "" - + guard let element = RSSElement(rawValue: elementName) else { return } - + switch element { case .item: isInsideItem = true @@ -61,8 +61,8 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { case .image: isInsideImage = true case .enclosure where isInsideItem: - if let url = attributeDict[RSSAttribute.url.rawValue], - let type = attributeDict[RSSAttribute.type.rawValue], + if let url = attributeDict[RSSAttribute.url.rawValue], + let type = attributeDict[RSSAttribute.type.rawValue], MediaType.image.matches(type) { currentImageURL = url } @@ -70,7 +70,7 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { break } } - + public func parser( _ parser: XMLParser, didEndElement elementName: String, @@ -78,9 +78,9 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { qualifiedName qName: String? ) { let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines) - + guard let element = RSSElement(rawValue: elementName) else { return } - + switch element { case .title: if isInsideItem { @@ -110,11 +110,11 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { isInsideImage = false case .item: isInsideItem = false - + if let title = currentTitle, let linkString = currentLink, let link = URL(string: linkString) { let pubDate = currentPubDate.flatMap { DateFormat.parseDate($0) } let imageURL = currentImageURL.flatMap { URL(string: $0) } - + let item = FeedItem( feedID: feedID, title: title, @@ -123,14 +123,14 @@ public class RSSParserDelegate: NSObject, RSSParserDelegateProtocol { description: currentDescription, imageURL: imageURL ) - + items.append(item) } default: break } } - + public func parser(_ parser: XMLParser, foundCharacters string: String) { textBuffer += string } diff --git a/RSSReaderKit/Sources/SharedModels/ExploreFeed.swift b/RSSReaderKit/Sources/SharedModels/ExploreFeed.swift new file mode 100644 index 0000000..5a11b13 --- /dev/null +++ b/RSSReaderKit/Sources/SharedModels/ExploreFeed.swift @@ -0,0 +1,20 @@ +// +// ExploreFeed.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +import Foundation + +public struct ExploreFeed: Codable, Identifiable, Hashable, Sendable { + public var id: String { url } + + public let name: String + public let url: String + + public init(name: String, url: String) { + self.name = name + self.url = url + } +} diff --git a/RSSReaderKit/Sources/SharedModels/ExploreFeedList.swift b/RSSReaderKit/Sources/SharedModels/ExploreFeedList.swift new file mode 100644 index 0000000..293bddc --- /dev/null +++ b/RSSReaderKit/Sources/SharedModels/ExploreFeedList.swift @@ -0,0 +1,14 @@ +// +// ExploreFeedList.swift +// RSSReaderKit +// +// Created by Martino Mamić on 18.04.25. +// + +public struct ExploreFeedList: Codable, Sendable { + public let feeds: [ExploreFeed] + + public init(feeds: [ExploreFeed]) { + self.feeds = feeds + } +} diff --git a/RSSReaderKit/Sources/SharedModels/Feed.swift b/RSSReaderKit/Sources/SharedModels/Feed.swift index efffa3d..0a00df2 100644 --- a/RSSReaderKit/Sources/SharedModels/Feed.swift +++ b/RSSReaderKit/Sources/SharedModels/Feed.swift @@ -8,23 +8,35 @@ import Foundation public struct Feed: Identifiable, Hashable, Sendable { - public let id: UUID + public var id: URL { url } public let url: URL public var title: String? public var description: String? public var imageURL: URL? - + public var isFavorite: Bool + public var notificationsEnabled: Bool + public init( - id: UUID = UUID(), url: URL, title: String? = nil, description: String? = nil, - imageURL: URL? = nil + imageURL: URL? = nil, + isFavorite: Bool = false, + notificationsEnabled: Bool = false ) { - self.id = id self.url = url self.title = title self.description = description self.imageURL = imageURL + self.isFavorite = isFavorite + self.notificationsEnabled = notificationsEnabled + } + + public static func == (lhs: Feed, rhs: Feed) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) } } diff --git a/RSSReaderKit/Sources/SharedModels/FeedItem.swift b/RSSReaderKit/Sources/SharedModels/FeedItem.swift index 3acb342..6a12c04 100644 --- a/RSSReaderKit/Sources/SharedModels/FeedItem.swift +++ b/RSSReaderKit/Sources/SharedModels/FeedItem.swift @@ -15,7 +15,7 @@ public struct FeedItem: Identifiable, Hashable, Sendable { public let pubDate: Date? public let description: String? public let imageURL: URL? - + public init( id: UUID = UUID(), feedID: UUID, diff --git a/RSSReaderKit/Sources/TabBarFeature/TabBarView.swift b/RSSReaderKit/Sources/TabBarFeature/TabBarView.swift new file mode 100644 index 0000000..ca4927d --- /dev/null +++ b/RSSReaderKit/Sources/TabBarFeature/TabBarView.swift @@ -0,0 +1,64 @@ +// +// TabBarView.swift +// RSSReaderKit +// +// Created by Martino Mamić on 16.04.25. +// + +import Common +import SwiftUI +import ExploreFeature +import FeedListFeature +import NotificationClient + +public struct TabBarView: View { + @State private var viewModel = TabBarViewModel() + + public init() {} + + public var body: some View { + TabView(selection: $viewModel.selectedTab) { + ForEach(TabItem.allCases, id: \.self) { tab in + tabContent(for: tab) + .tabItem { + Label( + viewModel.getTitle(for: tab), + systemImage: viewModel.getIcon(for: tab) + ) + } + .tag(tab) + .testId(accessibilityIdForTab(tab)) + } + } + .testId(AccessibilityIdentifier.TabBar.navigationTabs) + } + + private func accessibilityIdForTab(_ tab: TabItem) -> String { + switch tab { + case .feeds: + return AccessibilityIdentifier.TabBar.feedsTab + case .favorites: + return AccessibilityIdentifier.TabBar.favoritesTab + case .explore: + return AccessibilityIdentifier.TabBar.exploreTab + case .debug: + return AccessibilityIdentifier.TabBar.debugTab + } + } + + @ViewBuilder + private func tabContent(for tab: TabItem) -> some View { + NavigationStack { + switch tab { + case .feeds: + FeedListView() + case .favorites: + FeedListView(showOnlyFavorites: true) + case .explore: + ExploreView() + case .debug: + NotificationDebugView() + } + } + } +} diff --git a/RSSReaderKit/Sources/TabBarFeature/TabBarViewModel.swift b/RSSReaderKit/Sources/TabBarFeature/TabBarViewModel.swift new file mode 100644 index 0000000..2b5df5a --- /dev/null +++ b/RSSReaderKit/Sources/TabBarFeature/TabBarViewModel.swift @@ -0,0 +1,24 @@ +// +// TabBarViewModel.swift +// RSSReaderKit +// +// Created by Martino Mamić on 16.04.25. +// + +import SwiftUI +import Observation + +@MainActor +public class TabBarViewModel { + public var selectedTab: TabItem = .feeds + + public init() {} + + public func getIcon(for tab: TabItem) -> String { + tab == selectedTab ? tab.selectedIcon : tab.icon + } + + public func getTitle(for tab: TabItem) -> String { + return tab.title + } +} diff --git a/RSSReaderKit/Sources/TabBarFeature/TabItem.swift b/RSSReaderKit/Sources/TabBarFeature/TabItem.swift new file mode 100644 index 0000000..75faf55 --- /dev/null +++ b/RSSReaderKit/Sources/TabBarFeature/TabItem.swift @@ -0,0 +1,67 @@ +// +// TabItem.swift +// RSSReaderKit +// +// Created by Martino Mamić on 16.04.25. +// + +import SwiftUI + +public enum TabItem: Int, Hashable, CaseIterable { + case feeds + case explore + case favorites + case debug + + public var title: String { + switch self { + case .feeds: + return "Feeds" + case .explore: + return "Explore" + case .favorites: + return "Favorites" + case .debug: + return "Debug" + } + } + + public var icon: String { + switch self { + case .feeds: + return "newspaper" + case .explore: + return "globe" + case .favorites: + return "star" + case .debug: + return "ladybug" + } + } + + public var selectedIcon: String { + switch self { + case .feeds: + return "newspaper.fill" + case .explore: + return "globe.fill" + case .favorites: + return "star.fill" + case .debug: + return "ladybug.fill" + } + } + + public static var allCases: [TabItem] { + #if DEBUG + return [.feeds, .favorites, .explore, .debug] + #else + return [.feeds, .favorites, .explore] + #endif + } +} +#Preview { + ForEach(TabItem.allCases, id: \.self) { item in + Text(item.title) + } +} diff --git a/RSSReaderKit/Tests/NotificationClientTests/NotificationClientTests.swift b/RSSReaderKit/Tests/NotificationClientTests/NotificationClientTests.swift new file mode 100644 index 0000000..8d1e4c3 --- /dev/null +++ b/RSSReaderKit/Tests/NotificationClientTests/NotificationClientTests.swift @@ -0,0 +1,146 @@ +// +// NotificationClientTests.swift +// RSSReaderKit +// +// Created by Martino Mamić on 17.04.25. +// + +import Common +import ConcurrencyExtras +import Dependencies +import Foundation +import Testing +import UserNotifications + +@testable import NotificationClient +@testable import SharedModels +@testable import RSSClient +@testable import PersistenceClient + +@Suite struct NotificationClientTests { + func testFeed(notificationsEnabled: Bool = true) -> Feed { + Feed( + url: URL(string: "https://example.com/feed")!, + title: "Test Feed", + description: "Test Description", + notificationsEnabled: notificationsEnabled + ) + } + + func testItem(pubDate: Date = Date()) -> FeedItem { + FeedItem( + feedID: UUID(), + title: "Test Item", + link: URL(string: "https://example.com/item")!, + pubDate: pubDate + ) + } + + @Test("Request permissions success") + func testRequestPermissionsSuccess() async throws { + let permissionsRequested = LockIsolated(false) + + try await withDependencies { deps in + deps.notificationClient = .init( + requestPermissions: { + permissionsRequested.setValue(true) + }, + checkForNewItems: {} + ) + } operation: { + @Dependency(\.notificationClient) var client + try await client.requestPermissions() + #expect(permissionsRequested.value) + } + } + + @Test("Request permissions denied") + func testRequestPermissionsDenied() async throws { + try await withDependencies { deps in + deps.notificationClient = .init( + requestPermissions: { throw NotificationError.permissionDenied }, + checkForNewItems: {} + ) + } operation: { + @Dependency(\.notificationClient) var client + + do { + try await client.requestPermissions() + #expect(Bool(false), "Should have thrown permission denied error") + } catch is NotificationError { + } + } + } + + @Test("Check for new items") + func testCheckForNewItems() async throws { + let feed = testFeed() + let item = testItem(pubDate: Date().addingTimeInterval(60)) + + let notifications = LockIsolated<[(title: String, body: String)]>([]) + let notifiedItems = LockIsolated<[String]>([]) + let lastCheckTime = LockIsolated(nil) + + try await withDependencies { deps in + deps.rssClient = .init( + fetchFeed: { _ in feed }, + fetchFeedItems: { _ in [item] } + ) + + deps.persistenceClient = .init( + addFeed: { _ in }, + updateFeed: { _ in }, + deleteFeed: { _ in }, + loadFeeds: { [feed] } + ) + + deps.notificationClient = .init( + requestPermissions: {}, + checkForNewItems: { + @Dependency(\.persistenceClient) var persistenceClient + @Dependency(\.rssClient) var rssClient + + let lastCheck = lastCheckTime.value ?? Date.distantPast + + let feeds = try await persistenceClient.loadFeeds() + let enabledFeeds = feeds.filter(\.notificationsEnabled) + + for feed in enabledFeeds { + let items = try await rssClient.fetchFeedItems(feed.url) + + for item in items { + guard let pubDate = item.pubDate else { continue } + + let shouldNotify = pubDate > lastCheck && + !notifiedItems.value.contains(item.id.uuidString) + + if shouldNotify { + notifications.withValue { notifications in + notifications.append(( + title: feed.title ?? "New item in feed", + body: item.title + )) + } + + notifiedItems.withValue { items in + items.append(item.id.uuidString) + } + } + } + } + + lastCheckTime.setValue(Date()) + } + ) + } operation: { + @Dependency(\.notificationClient) var client + try await client.checkForNewItems() + + #expect(notifications.value.count == 1) + #expect(notifications.value.first?.title == feed.title) + #expect(notifications.value.first?.body == item.title) + + #expect(notifiedItems.value.contains(item.id.uuidString)) + } + } +} diff --git a/RSSReaderKit/Tests/PersistenceClientTests/PersistenceClientTests.swift b/RSSReaderKit/Tests/PersistenceClientTests/PersistenceClientTests.swift index 9e37e01..8d4caf4 100644 --- a/RSSReaderKit/Tests/PersistenceClientTests/PersistenceClientTests.swift +++ b/RSSReaderKit/Tests/PersistenceClientTests/PersistenceClientTests.swift @@ -6,79 +6,88 @@ // import ConcurrencyExtras +import Dependencies import Foundation import Testing @testable import PersistenceClient @testable import SharedModels @Suite struct PersistenceClientTests { + @Dependency(\.persistenceClient) var client + + func createTestFeed( + url: String = "https://example.com/feed", + title: String = "Test Feed", + description: String = "Test Description", + isFavorite: Bool = false + ) -> Feed { + return Feed( + url: URL(string: url)!, + title: title, + description: description, + isFavorite: isFavorite + ) + } + @Test("Save and load feeds") func testSaveAndLoadFeeds() async throws { - let feedStore = LockIsolated<[Feed]>([]) - - let testClient = PersistenceClient( - saveFeeds: { feeds in - feedStore.setValue(feeds) - }, - loadFeeds: { - return feedStore.value - } - ) - - let feed1 = Feed( - url: URL(string: "https://example.com/feed1")!, - title: "Test Feed 1", - description: "Description 1" - ) - - let feed2 = Feed( - url: URL(string: "https://example.com/feed2")!, - title: "Test Feed 2", - description: "Description 2" - ) - - try await testClient.saveFeeds([feed1, feed2]) - - let loadedFeeds = try await testClient.loadFeeds() - - #expect(loadedFeeds.count == 2) - #expect(loadedFeeds[0].title == "Test Feed 1") - #expect(loadedFeeds[1].title == "Test Feed 2") + try await withDependencies { _ in + } operation: { + let feed1 = createTestFeed() + let feed2 = createTestFeed(url: "https://example.com/feed2", title: "Test Feed 2") + + try await client.addFeed(feed1) + try await client.addFeed(feed2) + + let loadedFeeds = try await client.loadFeeds() + + #expect(loadedFeeds.count == 2) + #expect(loadedFeeds.contains(where: { $0.url == feed1.url })) + #expect(loadedFeeds.contains(where: { $0.url == feed2.url })) + #expect(loadedFeeds.first(where: { $0.url == feed1.url })?.title == "Test Feed") + #expect(loadedFeeds.first(where: { $0.url == feed2.url })?.title == "Test Feed 2") + } } - + + @Test("Update feed") + func testUpdateFeed() async throws { + try await withDependencies { _ in } + operation: { + let feed = createTestFeed(isFavorite: false) + try await client.addFeed(feed) + + var loadedFeeds = try await client.loadFeeds() + #expect(loadedFeeds.count == 1) + #expect(loadedFeeds[0].isFavorite == false) + + var updatedFeed = feed + updatedFeed.isFavorite = true + try await client.updateFeed(updatedFeed) + + loadedFeeds = try await client.loadFeeds() + #expect(loadedFeeds.count == 1) + #expect(loadedFeeds[0].isFavorite == true) + } + } + @Test("Delete feed") func testDeleteFeed() async throws { - let feed1 = Feed( - id: UUID(), - url: URL(string: "https://example.com/feed1")!, - title: "Test Feed 1" - ) - - let feed2 = Feed( - id: UUID(), - url: URL(string: "https://example.com/feed2")!, - title: "Test Feed 2" - ) - - let feedStore = LockIsolated<[Feed]>([feed1, feed2]) - - let testClient = PersistenceClient( - saveFeeds: { feeds in - feedStore.setValue(feeds) - }, - loadFeeds: { - return feedStore.value - } - ) - - var loadedFeeds = try await testClient.loadFeeds() - #expect(loadedFeeds.count == 2) - - loadedFeeds.removeAll(where: { $0.id == feed1.id }) - try await testClient.saveFeeds(loadedFeeds) - - let remainingFeeds = try await testClient.loadFeeds() - #expect(remainingFeeds.count == 1) - #expect(remainingFeeds[0].id == feed2.id) + try await withDependencies { _ in } + operation: { + let feed1 = createTestFeed() + let feed2 = createTestFeed(url: "https://example.com/feed2", title: "Test Feed 2") + + try await client.addFeed(feed1) + try await client.addFeed(feed2) + + var loadedFeeds = try await client.loadFeeds() + #expect(loadedFeeds.count == 2) + + try await client.deleteFeed(feed1.url) + + loadedFeeds = try await client.loadFeeds() + #expect(loadedFeeds.count == 1) + #expect(loadedFeeds[0].url == feed2.url) + } } } diff --git a/RSSReaderKit/Tests/RSSClientTests/RSSClientTests.swift b/RSSReaderKit/Tests/RSSClientTests/RSSClientTests.swift index f356d9e..ed26d5c 100644 --- a/RSSReaderKit/Tests/RSSClientTests/RSSClientTests.swift +++ b/RSSReaderKit/Tests/RSSClientTests/RSSClientTests.swift @@ -14,29 +14,31 @@ import Foundation @Test("Parse valid RSS feed") func testParseValidRSSFeed() async throws { let parser = RSSParser() - let sampleRSSData = bbcXML.data(using: .utf8)! - let url = URL(string: "https://example.com/feed.xml")! - + guard let url = Bundle.module.url(forResource: "bbc", withExtension: "xml") else { + throw RSSError.invalidURL + } + + let sampleRSSData = try Data(contentsOf: url) let (feed, items) = try await parser.parse(data: sampleRSSData, feedURL: url) - + #expect(feed?.title == "BBC News") #expect(feed?.description == "BBC News - World") #expect(feed?.url == url) #expect(items.count == 24) - + let firstItem = items.first #expect(firstItem != nil) #expect(firstItem?.title == "Israeli air strike destroys part of last functioning hospital in Gaza City") #expect(firstItem?.description == "The Israel Defense Forces said the hospital contained a \"command and control center used by Hamas\".") #expect(firstItem?.link == URL(string: "https://www.bbc.com/news/articles/cjr7l123zy5o")) } - + @Test("Parse invalid XML throws error") func testParseInvalidXML() async throws { let parser = RSSParser() - let invalidXMLData = "blob".data(using: .utf8)! + let invalidXMLData = Data("blob".utf8) let url = URL(string: "blob")! - + do { _ = try await parser.parse(data: invalidXMLData, feedURL: url) #expect(Bool(false), "Invalid XML error") @@ -45,323 +47,3 @@ import Foundation } } } - -private let bbcXML = """ - -