diff --git a/AppDelegate.swift b/AppDelegate.swift index fad75b4..9f53117 100644 --- a/AppDelegate.swift +++ b/AppDelegate.swift @@ -22,6 +22,8 @@ import Bookmarks class EDSyncAppDelegate: NSObject, UIApplicationDelegate { + private let appViewModel = AppViewModel.shared + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self @@ -32,8 +34,8 @@ class EDSyncAppDelegate: NSObject, UIApplicationDelegate { configureEventObservers() requestNotificationAuthorization() - DataStackProvider.shared.load { - if let container = DataStackProvider.shared.persistentContainer { + appViewModel.dataStackProvider.load { [weak self] in + if let container = self?.appViewModel.dataStackProvider.persistentContainer { let librariesController = LibraryManager(persistentContainer: container) librariesController.loadOrUpdateLibraries(in: container.viewContext) @@ -79,7 +81,7 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { private func refresh(response: UNNotificationResponse) async { do { - try await AccountUpdater().update() + try await AccountUpdater(dataStackProvider: appViewModel.dataStackProvider).update(in: appViewModel.dataStackProvider.backgroundManagedObjectContext) } catch { } @@ -87,15 +89,19 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { private func renew(response: UNNotificationResponse) async { if let barcode = response.notification.request.content.userInfo["barcode"] as? String { - let context = DataStackProvider.shared.foregroundManagedObjectContext + let context = appViewModel.dataStackProvider.foregroundManagedObjectContext - let backendService = DatabaseConnectionFactory().databaseConnection(for: context, accountService: AccountScraper()) + let backendService = DatabaseConnectionFactory().databaseConnection( + for: context, + accountService: AccountScraper(), + dataStackProvider: appViewModel.dataStackProvider + ) let bookmarkService = BookmarkService(managedObjectContext: context) let loanService = BTLBLoanService(backendService: backendService, bookmarkService: bookmarkService) do { - let activeAccounts = try await DataStackProvider.shared.activeAccounts(in: context) + let activeAccounts = try await appViewModel.dataStackProvider.activeAccounts(in: context) if let loanManagedObjectId = await loan(for: barcode, in: context, accounts: activeAccounts), let loanManagedObject = context.object(with: loanManagedObjectId) as? Persistence.Loan { @@ -191,7 +197,6 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { notificationCenter.setNotificationCategories([itemRenewableCategory, itemExpiresTodayCategory]) } - func didRegister(deviceToken: Data) { Task { let pushManager = PushNotificationManager() @@ -210,13 +215,13 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { let currentValue = UserDefaults.suite.backgroundNotificationRefreshCount UserDefaults.suite.backgroundNotificationRefreshCount = currentValue + 1 - AppEventPublisher.shared.addObserver(WidgetSynchronisation.shared) - AppEventPublisher.shared.addObserver(NotificationSynchronisation.shared) + AppEventPublisher.shared.addObserver(appViewModel.widgetSynchronisation) + AppEventPublisher.shared.addObserver(appViewModel.notificationSynchronisation) - let context = DataStackProvider.shared.foregroundManagedObjectContext + let context = appViewModel.dataStackProvider.foregroundManagedObjectContext return await withCheckedContinuation { continuation in - let operation = RefreshAppContentsOperation(updater: AccountUpdater(), + let operation = RefreshAppContentsOperation(updater: AccountUpdater(dataStackProvider: appViewModel.dataStackProvider), context: context) { updateResult in let currentValue = UserDefaults.suite.successfulBackgroundNotificationRefreshCount UserDefaults.suite.successfulBackgroundNotificationRefreshCount = currentValue + 1 @@ -231,9 +236,9 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { } func configureEventObservers() { - AppEventPublisher.shared.addObserver(WidgetSynchronisation.shared) - AppEventPublisher.shared.addObserver(NotificationSynchronisation.shared) - AppEventPublisher.shared.addObserver(CoreSpotlightSynchronisation.shared) + AppEventPublisher.shared.addObserver(appViewModel.widgetSynchronisation) + AppEventPublisher.shared.addObserver(appViewModel.notificationSynchronisation) + AppEventPublisher.shared.addObserver(appViewModel.coreSpotlightSynchronisation) } // MARK: - Background Tasks @@ -277,8 +282,8 @@ extension EDSyncAppDelegate: UNUserNotificationCenterDelegate { scheduleAppRefresh() - let context = DataStackProvider.shared.backgroundManagedObjectContext - let operation = RefreshAppContentsOperation(updater: AccountUpdater(), + let context = appViewModel.dataStackProvider.backgroundManagedObjectContext + let operation = RefreshAppContentsOperation(updater: AccountUpdater(dataStackProvider: appViewModel.dataStackProvider), context: context) { updateResult in switch updateResult { case .finished: diff --git a/BTLB.xcodeproj/project.pbxproj b/BTLB.xcodeproj/project.pbxproj index cc26214..5b638ea 100644 --- a/BTLB.xcodeproj/project.pbxproj +++ b/BTLB.xcodeproj/project.pbxproj @@ -319,6 +319,7 @@ 3BAF8FEB2B0A6C0900D5AF81 /* BTLBAppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTLBAppViewModel.swift; sourceTree = ""; }; 3BB03179164EA89B004030D5 /* libz.1.2.5.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.1.2.5.dylib; path = usr/lib/libz.1.2.5.dylib; sourceTree = SDKROOT; }; 3BB5C86A28E233B400D56B04 /* Localization */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Localization; path = Packages/Localization; sourceTree = ""; }; + 3BB5F0C02E7DFB4F00214A41 /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; name = ci_post_clone.sh; path = ci_scripts/ci_post_clone.sh; sourceTree = ""; }; 3BB68ABA1FFFA3DF00A1F616 /* BTLBTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BTLBTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3BB68ABE1FFFA3DF00A1F616 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3BBA8CAF23F2E8F000795358 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -452,6 +453,7 @@ 29B97314FDCFA39411CA2CEA /* CustomTemplate */ = { isa = PBXGroup; children = ( + 3BB5F0C02E7DFB4F00214A41 /* ci_post_clone.sh */, 3B3ED2592BA6366C00C67380 /* PrivacyInfo.xcprivacy */, 3B4A909C1BF26B8A0097962E /* Changelog.md */, 3BBD79C4294CFE320006A165 /* Release.xcconfig */, diff --git a/BTLBApp.swift b/BTLBApp.swift index 357c224..fd58b18 100644 --- a/BTLBApp.swift +++ b/BTLBApp.swift @@ -78,9 +78,10 @@ struct BTLBApp: App { }) .onContinueUserActivity(CSSearchableItemActionType, perform: handleSpotlight) .onOpenURL(perform: handleIncomingURL) - .environment(\.accountActivating, AccountUpdater()) - .environment(\.accountUpdating, AccountUpdater()) - .environment(\.managedObjectContext, DataStackProvider.shared.foregroundManagedObjectContext) + .environment(\.accountActivating, AccountUpdater(dataStackProvider: viewModel.dataStackProvider)) + .environment(\.accountUpdating, AccountUpdater(dataStackProvider: viewModel.dataStackProvider)) + .environment(\.managedObjectContext, viewModel.dataStackProvider.foregroundManagedObjectContext) + .environment(\.dataStackProvider, viewModel.dataStackProvider) .environment(\.loanService, viewModel.loanService) .environment(\.localAccountService, viewModel.localAccountRepository) .environment(\.accountCredentialStore, viewModel.accountCredentialStore) @@ -145,10 +146,8 @@ struct BTLBApp: App { appReviewService: BTLBSettings.AppReviewService(userDefaults: .suite), notificationScheduler: NotificationScheduler(), loan: loan) - LoanDetailView(loanDetailViewModel) { - viewModel.deeplinkLoanContainer = nil - } - .containInNavigation + LoanDetailView(loanDetailViewModel) + .containInNavigation } @State private var navigationPath = NavigationPath() @@ -250,9 +249,9 @@ struct BTLBApp: App { func handleSpotlight(_ userActivity: NSUserActivity) { if let id = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String { Task { @MainActor in - let context = DataStackProvider.shared.foregroundManagedObjectContext + let context = viewModel.dataStackProvider.foregroundManagedObjectContext - let loanManagedObjectId = try await DataStackProvider.shared.loan(for: id, in: context) + let loanManagedObjectId = try await viewModel.dataStackProvider.loan(for: id, in: context) guard let loan = context.object(with: loanManagedObjectId) as? Persistence.Loan else { return } diff --git a/BTLBAppViewModel.swift b/BTLBAppViewModel.swift index b9f78b8..f8ee642 100644 --- a/BTLBAppViewModel.swift +++ b/BTLBAppViewModel.swift @@ -40,7 +40,7 @@ enum AppState { class AppViewModel: ObservableObject { static var shared: AppViewModel = { - AppViewModel(debugCenter: DebugCenter(), appReviewService: AppReviewService(userDefaults: .suite), accountUpdater: AccountUpdater()) + AppViewModel(debugCenter: DebugCenter(), appReviewService: AppReviewService(userDefaults: .suite)) }() enum Tab { @@ -52,17 +52,9 @@ class AppViewModel: ObservableObject { case settings } - init(debugCenter: DebugCenter, appReviewService: LibraryCore.AppReviewService, accountUpdater: AccountUpdating) { + init(debugCenter: DebugCenter, appReviewService: LibraryCore.AppReviewService) { self.debugCenter = debugCenter self.appReviewService = appReviewService - self.accountUpdater = accountUpdater - - backendService = DatabaseConnectionFactory() - .databaseConnection( - for: DataStackProvider.shared.foregroundManagedObjectContext, - accountService: AccountScraper() - ) - bookmarkService = BookmarkService(managedObjectContext: DataStackProvider.shared.foregroundManagedObjectContext) } func updateLaunchCount() { @@ -73,7 +65,7 @@ class AppViewModel: ObservableObject { private let debugCenter: DebugCenter private let appReviewService: LibraryCore.AppReviewService - private let accountUpdater: AccountUpdating + private lazy var accountUpdater: AccountUpdating = AccountUpdater(dataStackProvider: dataStackProvider) @Published var route: Route? @Published var deeplinkLoanContainer: DeeplinkLoanContainer? @@ -96,7 +88,7 @@ class AppViewModel: ObservableObject { @MainActor func refresh() async throws { guard lastRefreshIsOlderThan12Hours else { return state = .idle } - guard try await DataStackProvider.shared.activeAccounts(in: DataStackProvider.shared.foregroundManagedObjectContext).count > 0 else { return state = .idle } + guard try await dataStackProvider.activeAccounts(in: dataStackProvider.foregroundManagedObjectContext).count > 0 else { return state = .idle } state = .refreshing @@ -123,48 +115,62 @@ class AppViewModel: ObservableObject { // MARK: Bookmarks - var bookmarksCoordinator = BookmarkListCoordinator(viewModel: BookmarkListViewModel()) + @MainActor var bookmarksCoordinator = BookmarkListCoordinator(viewModel: BookmarkListViewModel()) + + @MainActor lazy var bookmarkService: BookmarkServicing = BookmarkService(managedObjectContext: dataStackProvider.foregroundManagedObjectContext) - let bookmarkService: BookmarkServicing + @MainActor var bookmarksTitle: String = Localization.Titles.bookmarks - var bookmarksTitle: String = Localization.Titles.bookmarks + lazy var widgetSynchronisation = WidgetSynchronisation(dataStackProvider: dataStackProvider) + @MainActor lazy var notificationSynchronisation = NotificationSynchronisation(scheduler: NotificationScheduler()) + lazy var coreSpotlightSynchronisation = CoreSpotlightSynchronisation(dataStackProvider: dataStackProvider) - var loansCoordinator = LoansSectionCoordinator( + lazy var loansCoordinator = LoansSectionCoordinator( databaseFactory: DatabaseConnectionFactory(), - accountUpdater: AccountUpdater(), + accountUpdater: accountUpdater, dependencies: LoanSectionDependencies( accountService: AccountScraper() ) ) var loansTitle = Localization.Titles.loans - let backendService: LoanBackendServicing + lazy var backendService: LoanBackendServicing = DatabaseConnectionFactory() + .databaseConnection( + for: dataStackProvider.foregroundManagedObjectContext, + accountService: AccountScraper(), + dataStackProvider: dataStackProvider + ) + + lazy var chargesCoordinator = ChargeListCoordinator(refreshable: RefreshHandler { [weak self] in + + guard let self else { return } - var chargesCoordinator = ChargeListCoordinator(refreshable: RefreshHandler { // TODO: unite the 2 update methods into one // try await AccountUpdater().update(.manual(.now)) - try await AccountUpdater().manualUpdate(at: Date()) + try await self.accountUpdater.manualUpdate(in: self.dataStackProvider.backgroundManagedObjectContext, at: Date()) }) var chargesTitle: String = Localization.Titles.charges func createSearchCoordinator() -> SearchSectionCoordinator { - let container = DataStackProvider.shared.persistentContainer! + let container = dataStackProvider.persistentContainer! let libraryManagedObjectIdentifier = LibraryManager(persistentContainer: container).legacySearchLibrary - let libraryManagedObject = libraryManagedObjectIdentifier.map { DataStackProvider.shared.foregroundManagedObjectContext.object(with: $0) as? any LibraryCore.Library + let libraryManagedObject = libraryManagedObjectIdentifier.map { dataStackProvider.foregroundManagedObjectContext.object(with: $0) as? any LibraryCore.Library } ?? nil let name = libraryManagedObject?.name ?? "unknown" let subtitle = libraryManagedObject?.subtitle ?? "" let databaseConnection = DatabaseConnectionFactory().databaseConnection( - for: DataStackProvider.shared.foregroundManagedObjectContext, - accountService: AccountScraper() + for: dataStackProvider.foregroundManagedObjectContext, + accountService: AccountScraper(), + dataStackProvider: dataStackProvider ) as? DatabaseConnection let searchDependencies = SearchDependencies( databaseConnection: databaseConnection, searchProvider: SearchScraper(), - detailsProvider: SearchResultDetailScraper() + detailsProvider: SearchResultDetailScraper(), + dataStackProvider: dataStackProvider ) return SearchSectionCoordinator( @@ -179,23 +185,25 @@ class AppViewModel: ObservableObject { ) } - var searchCoordinator: SearchSectionCoordinator? = { - guard let container = DataStackProvider.shared.persistentContainer else { return nil } + lazy var searchCoordinator: SearchSectionCoordinator? = { + guard let container = dataStackProvider.persistentContainer else { return nil } let libraryManagedObjectIdentifier = LibraryManager(persistentContainer: container).legacySearchLibrary - let libraryManagedObject = libraryManagedObjectIdentifier.map { DataStackProvider.shared.foregroundManagedObjectContext.object(with: $0) as? any LibraryCore.Library + let libraryManagedObject = libraryManagedObjectIdentifier.map { dataStackProvider.foregroundManagedObjectContext.object(with: $0) as? any LibraryCore.Library } ?? nil let name = libraryManagedObject?.name ?? "unknown" let subtitle = libraryManagedObject?.subtitle ?? "" let databaseConnection = DatabaseConnectionFactory().databaseConnection( - for: DataStackProvider.shared.foregroundManagedObjectContext, - accountService: AccountScraper() + for: dataStackProvider.foregroundManagedObjectContext, + accountService: AccountScraper(), + dataStackProvider: dataStackProvider ) as? DatabaseConnection let searchDependencies = SearchDependencies( databaseConnection: databaseConnection, searchProvider: SearchScraper(), - detailsProvider: SearchResultDetailScraper() + detailsProvider: SearchResultDetailScraper(), + dataStackProvider: dataStackProvider ) return SearchSectionCoordinator( @@ -214,32 +222,34 @@ class AppViewModel: ObservableObject { // MARK: More Section - @MainActor var moreCoordinator = { + @MainActor lazy var moreCoordinator = { let settingsService = SettingsService( notificationScheduler: NotificationScheduler(), - accountService: AccountRepository.shared, userDefaults: UserDefaults.suite) + accountService: accountRepository, userDefaults: UserDefaults.suite) let accountCredentialStore = AccountCredentialStore(keychainProvider: KeychainManager()) return MoreSectionCoordinator(entries: [.accounts, .about, .settings(settingsService), .openSource]) }() - @MainActor var settingsService: LibraryCore.SettingsService = SettingsService( + @MainActor lazy var settingsService: LibraryCore.SettingsService = SettingsService( notificationScheduler: NotificationScheduler(), - accountService: AccountRepository.shared, userDefaults: UserDefaults.suite) + accountService: accountRepository, userDefaults: UserDefaults.suite) var moreTitle: String = Localization.Titles.more - var localAccountRepository = { - LocalAccountRepository(context: DataStackProvider.shared.foregroundManagedObjectContext) - }() + lazy var localAccountRepository = LocalAccountRepository(context: dataStackProvider.foregroundManagedObjectContext) + + lazy var dataStackProvider: DataStackProviding = DataStackProvider() + + @MainActor lazy var accountRepository = AccountRepository(dataStackProvider: dataStackProvider) - lazy var loanService: LoanService = { + @MainActor lazy var loanService: LoanService = { BTLBLoanService(backendService: backendService, bookmarkService: bookmarkService) }() var accountCredentialStore = AccountCredentialStore(keychainProvider: KeychainManager()) // TODO: kill the force! ! - var libraryProvider: LibraryProvider = LibraryManager(persistentContainer: DataStackProvider.shared.persistentContainer!) as LibraryProvider + lazy var libraryProvider: LibraryProvider = LibraryManager(persistentContainer: dataStackProvider.persistentContainer!) as LibraryProvider } diff --git a/BTLBTests/APITests.swift b/BTLBTests/APITests.swift index c26604e..863c403 100644 --- a/BTLBTests/APITests.swift +++ b/BTLBTests/APITests.swift @@ -30,8 +30,8 @@ import Utilities // try await super.setUp() // mocStub = await SomeHelper().managedObjectContextStub(for: self) // -// account = try await DataStackProvider.shared.newAccount() -// let library = try await DataStackProvider.shared.persistentContainer?.libraries(in: mocStub).first +// account = try await dataStackProvider.newAccount() +// let library = try await dataStackProvider.persistentContainer?.libraries(in: mocStub).first // account.library = library // authManager = AuthenticationManager(network: networkMock, keychainManager: keychainMock) // credentialStore = AccountCredentialStore(keychainProvider: keychainMock) diff --git a/BTLBTests/BookmarkServiceTests.swift b/BTLBTests/BookmarkServiceTests.swift index 2572b16..e080ede 100644 --- a/BTLBTests/BookmarkServiceTests.swift +++ b/BTLBTests/BookmarkServiceTests.swift @@ -69,6 +69,7 @@ class BookmarkControllerTests: XCTestCase { } } + @MainActor func testRemoveBookmark() async throws { throw XCTSkip() guard let mock = loanMock else { XCTFail(); return } @@ -87,7 +88,7 @@ class BookmarkControllerTests: XCTestCase { func setupLoanInMockContext(_ moc: NSManagedObjectContext) async throws { guard let mock = loanMock else { XCTFail(); return } - let account = try await DataStackProvider.shared.newAccount() + let account = try await DataStackProvider().newAccount() // account.accountType = "an account type" account.addToLoans(mock) diff --git a/BTLBTests/Parsers and Scrapers/AuthenticationManagerTests.swift b/BTLBTests/Parsers and Scrapers/AuthenticationManagerTests.swift index 503a64a..38debf6 100644 --- a/BTLBTests/Parsers and Scrapers/AuthenticationManagerTests.swift +++ b/BTLBTests/Parsers and Scrapers/AuthenticationManagerTests.swift @@ -180,9 +180,10 @@ import Persistence // extension SomeHelper { public func managedObjectContextStub(for test: XCTestCase) async -> NSManagedObjectContext { - DataStackProvider.shared.loadInMemory() + let dataStackProvider = DataStackProvider() + dataStackProvider.loadInMemory() - return DataStackProvider.shared.foregroundManagedObjectContext + return dataStackProvider.foregroundManagedObjectContext // let exp = test.expectation(description: "wait for persistent store") // guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: [PersistenceBundle.module]) else { diff --git a/BTLBWidget/BTLBWidgetBundle.swift b/BTLBWidget/BTLBWidgetBundle.swift index 0e36dd1..74fed7c 100644 --- a/BTLBWidget/BTLBWidgetBundle.swift +++ b/BTLBWidget/BTLBWidgetBundle.swift @@ -9,6 +9,8 @@ import WidgetKit import SwiftUI +import Persistence + @main struct BTLBWidgetBundle: WidgetBundle { var body: some Widget { @@ -19,3 +21,7 @@ struct BTLBWidgetBundle: WidgetBundle { LargeBTLBWidget() } } + +class BTLBWidgetViewModel { + lazy var dataStackProvider: DataStackProviding = DataStackProvider() +} diff --git a/BTLBWidget/WidgetTimelineProvider.swift b/BTLBWidget/WidgetTimelineProvider.swift index 9bb51d8..2afbf88 100644 --- a/BTLBWidget/WidgetTimelineProvider.swift +++ b/BTLBWidget/WidgetTimelineProvider.swift @@ -15,13 +15,20 @@ import Utilities import LibraryCore struct Provider: TimelineProvider { + + private let widgetSynchronisation: WidgetSynchronisation + + init(widgetSynchronisation: WidgetSynchronisation) { + self.widgetSynchronisation = widgetSynchronisation + } + func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), state: .content(viewModel: Self.errorContentViewModel)) } func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let widgetState = try? WidgetSynchronisation.shared.widgetState + let widgetState = try? widgetSynchronisation.widgetState guard case .content(let viewModel) = widgetState else { completion(SimpleEntry(date: Date(), state: .content(viewModel: Self.errorContentViewModel))) @@ -73,7 +80,7 @@ struct Provider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { - let widgetState: WidgetState = try WidgetSynchronisation.shared.widgetState + let widgetState: WidgetState = try widgetSynchronisation.widgetState let currentDate: Date = .now @@ -99,8 +106,12 @@ struct SimpleEntry: TimelineEntry { struct LockscreenBTLBWidget: Widget { let kind: String = "LockscreenBTLBWidget" + var provider: Provider { + Provider(widgetSynchronisation: WidgetSynchronisation(dataStackProvider: BTLBWidgetViewModel().dataStackProvider)) + } + var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: provider) { entry in TinyEntryView(entry: entry) } .configurationDisplayName("widget configuration title".localized) @@ -112,8 +123,12 @@ struct LockscreenBTLBWidget: Widget { struct LockscreenRectangularBTLBWidget: Widget { let kind: String = "RectangularLockscreenBTLBWidget" + var provider: Provider { + Provider(widgetSynchronisation: WidgetSynchronisation(dataStackProvider: BTLBWidgetViewModel().dataStackProvider)) + } + var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: provider) { entry in TinyEntryView(entry: entry) } .configurationDisplayName("widget configuration title".localized) diff --git a/BTLBWidget/Widgets/LargeEntryView.swift b/BTLBWidget/Widgets/LargeEntryView.swift index 050ef59..56f36e5 100644 --- a/BTLBWidget/Widgets/LargeEntryView.swift +++ b/BTLBWidget/Widgets/LargeEntryView.swift @@ -16,8 +16,12 @@ import Utilities struct LargeBTLBWidget: Widget { let kind: String = "LargeBTLBWidget" + var provider: Provider { + Provider(widgetSynchronisation: WidgetSynchronisation(dataStackProvider: BTLBWidgetViewModel().dataStackProvider)) + } + var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: provider) { entry in LargeEntryView(entry: entry) } .configurationDisplayName("widget configuration title".localized) diff --git a/BTLBWidget/Widgets/MediumBTLBWidget.swift b/BTLBWidget/Widgets/MediumBTLBWidget.swift index 06cbb35..8f2ded5 100644 --- a/BTLBWidget/Widgets/MediumBTLBWidget.swift +++ b/BTLBWidget/Widgets/MediumBTLBWidget.swift @@ -16,13 +16,17 @@ struct MediumBTLBWidget: Widget { let kind: String = "BTLBWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: provider) { entry in MediumEntryView(entry: entry) } .configurationDisplayName("widget configuration title".localized) .description("widget configuration description".localized) .supportedFamilies([.systemMedium]) } + + var provider: Provider { + Provider(widgetSynchronisation: WidgetSynchronisation(dataStackProvider: BTLBWidgetViewModel().dataStackProvider)) + } } #if DEBUG diff --git a/BTLBWidget/Widgets/SmallBTLBWidget.swift b/BTLBWidget/Widgets/SmallBTLBWidget.swift index 77172d0..761054b 100644 --- a/BTLBWidget/Widgets/SmallBTLBWidget.swift +++ b/BTLBWidget/Widgets/SmallBTLBWidget.swift @@ -10,11 +10,17 @@ import Foundation import SwiftUI import WidgetKit +import Utilities + struct SmallBTLBWidget: Widget { let kind: String = "MediumBTLBWidget" + var provider: Provider { + Provider(widgetSynchronisation: WidgetSynchronisation(dataStackProvider: BTLBWidgetViewModel().dataStackProvider)) + } + var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: provider) { entry in SmallEntryView(entry: entry) } .configurationDisplayName("widget configuration title".localized) diff --git a/Classes/AccountRepository.swift b/Classes/AccountRepository.swift index 0d30763..210214b 100644 --- a/Classes/AccountRepository.swift +++ b/Classes/AccountRepository.swift @@ -16,9 +16,14 @@ import Persistence import Utilities import Libraries -public final class AccountRepository: AccountService, Sendable { +@MainActor +public final class AccountRepository: AccountService { - public static let shared = AccountRepository() + private let dataStackProvider: DataStackProviding + + init(dataStackProvider: DataStackProviding) { + self.dataStackProvider = dataStackProvider + } public func removeLoansNotifications() { Task { @@ -26,8 +31,8 @@ public final class AccountRepository: AccountService, Sendable { await NotificationScheduler().removeAllNotifications() // remove all Notification Scheduled Dates from the loans - let moc = DataStackProvider.shared.backgroundManagedObjectContext - let accounts = try await DataStackProvider.shared.persistentContainer?.accounts(in: moc) + let moc = dataStackProvider.backgroundManagedObjectContext + let accounts = try await dataStackProvider.persistentContainer?.accounts(in: moc) try await moc.perform { accounts?.forEach { @@ -48,22 +53,27 @@ public final class AccountRepository: AccountService, Sendable { /// - account: the account to update /// - context: the NSManagedObjectContext in which the account should be modified /// - Returns: a ``UpdateResult`` - public func updateAccount(_ account: NSManagedObjectID, in context: NSManagedObjectContext) async throws -> UpdateResult { + public func updateAccount( + _ account: NSManagedObjectID, + in context: NSManagedObjectContext, + dataStackProvider: DataStackProviding + ) async throws -> UpdateResult { Logger.accountRepository.debug("updateAccount \(account)") let databaseConnection = DatabaseConnectionFactory().databaseConnection( for: context, - accountService: AccountScraper() + accountService: AccountScraper(), + dataStackProvider: dataStackProvider ) let keychainProvider = KeychainManager() let accountCredentialStore = AccountCredentialStore(keychainProvider: keychainProvider) - guard let persistentContainer = DataStackProvider.shared.persistentContainer else { + guard let persistentContainer = dataStackProvider.persistentContainer else { Logger.accountRepository.debug("updateAccount missing persistent container") throw PaperErrorInternal.accountReposioryError(.update(.missingPersistentContainer)) } - let serializer = LoanSerializer() + let serializer = LoanSerializer(dataStackProvider: dataStackProvider) let loansHash = try await serializer.loansHash(for: account) let libraryProvider = LibraryManager(persistentContainer: persistentContainer) @@ -97,7 +107,7 @@ public final class AccountRepository: AccountService, Sendable { throw PaperErrorInternal.accountReposioryError(.update(.missingCredentials)) } - let serializer = LoanSerializer() + let serializer = LoanSerializer(dataStackProvider: dataStackProvider) let oldLoanBarcodes = try await serializer.loansBarcodes(for: accountId) try await databaseConnection.initiateUpdate(forAccount: accountId, accountIdentifier: userId, password: password, libraryProvider: libraryProvider) diff --git a/Classes/AccountUpdater.swift b/Classes/AccountUpdater.swift index 4c02bf4..846e2f6 100644 --- a/Classes/AccountUpdater.swift +++ b/Classes/AccountUpdater.swift @@ -15,11 +15,17 @@ import LibraryCore import Utilities @objc class AccountUpdater: NSObject, AccountUpdating { - + + private let dataStackProvider: DataStackProviding + + init(dataStackProvider: DataStackProviding) { + self.dataStackProvider = dataStackProvider + } + @discardableResult - func manualUpdate(in moc: NSManagedObjectContext? = DataStackProvider.shared.backgroundManagedObjectContext, at date: Date) async throws -> UpdateResult { - let moc = moc ?? DataStackProvider.shared.backgroundManagedObjectContext - let accounts = try await DataStackProvider.shared.activeAccounts(in: moc) + func manualUpdate(in moc: NSManagedObjectContext?, at date: Date) async throws -> UpdateResult { + let moc = moc ?? dataStackProvider.backgroundManagedObjectContext + let accounts = try await dataStackProvider.activeAccounts(in: moc) do { return try await update(accounts, in: moc, at: date) @@ -29,9 +35,9 @@ import Utilities } @discardableResult - func update(in moc: NSManagedObjectContext? = DataStackProvider.shared.backgroundManagedObjectContext) async throws -> UpdateResult { - let moc = moc ?? DataStackProvider.shared.backgroundManagedObjectContext - let accounts = try await DataStackProvider.shared.activeAccounts(in: moc) + func update(in moc: NSManagedObjectContext?) async throws -> UpdateResult { + let moc = moc ?? dataStackProvider.backgroundManagedObjectContext + let accounts = try await dataStackProvider.activeAccounts(in: moc) return try await update(accounts, in: moc) } @@ -50,7 +56,7 @@ import Utilities func update(completion:@escaping (NSError?) -> ()) { Task { do { - _ = try await update() + _ = try await update(in: dataStackProvider.backgroundManagedObjectContext) completion(nil) } catch(let error) { completion(error as NSError) @@ -80,7 +86,7 @@ import Utilities for account in accounts { taskgroup.addTask { do { - return try await AccountRepository().updateAccount(account, in: context) + return try await AccountRepository(dataStackProvider: self.dataStackProvider).updateAccount(account, in: context, dataStackProvider: self.dataStackProvider) } catch let error as PaperErrorInternal { Logger.accountActivation.debug("finished with update error") return .error(error) diff --git a/Intents/RenewIntent.swift b/Intents/RenewIntent.swift index 1e4191e..a7dd1c3 100644 --- a/Intents/RenewIntent.swift +++ b/Intents/RenewIntent.swift @@ -11,6 +11,8 @@ import Utilities public struct RenewItemsIntent: AppIntent { + private let dataStackProvider: DataStackProviding = AppViewModel.shared.dataStackProvider + public init() {} init(items: [ItemEntity]) { @@ -46,8 +48,9 @@ public struct RenewItemsIntent: AppIntent { } let loanService = DatabaseConnectionFactory().databaseConnection( - for: DataStackProvider.shared.foregroundManagedObjectContext, - accountService: AccountScraper() + for: dataStackProvider.foregroundManagedObjectContext, + accountService: AccountScraper(), + dataStackProvider: dataStackProvider ) let renewedItemResults = try await withThrowingTaskGroup(of: Result.self) { group in diff --git a/Intents/RenewableItemEntityQuery.swift b/Intents/RenewableItemEntityQuery.swift index 5d3c700..0bacb41 100644 --- a/Intents/RenewableItemEntityQuery.swift +++ b/Intents/RenewableItemEntityQuery.swift @@ -13,10 +13,13 @@ import Persistence public struct RenewableItemEntityQuery: EntityQuery { - public init() {} + private let dataStackProvider: DataStackProviding = AppViewModel.shared.dataStackProvider + + public init() { + } public func entities(for identifiers: [ItemEntity.ID]) async throws -> [ItemEntity] { - let items = await DataStackProvider.shared.items(in: DataStackProvider.shared.foregroundManagedObjectContext, + let items = await dataStackProvider.items(in: dataStackProvider.foregroundManagedObjectContext, renewableOnly: true, fetchLimit: 1000) return items.filter { @@ -29,7 +32,7 @@ public struct RenewableItemEntityQuery: EntityQuery { } public func suggestedEntities() async throws -> [ItemEntity] { - let items = await DataStackProvider.shared.items(in: DataStackProvider.shared.foregroundManagedObjectContext, + let items = await dataStackProvider.items(in: dataStackProvider.foregroundManagedObjectContext, renewableOnly: true, fetchLimit: 1000) diff --git a/Packages/Accounts/Sources/Accounts/CreateAccount/CreateAccountView.swift b/Packages/Accounts/Sources/Accounts/CreateAccount/CreateAccountView.swift index 1b878ac..2284ff8 100644 --- a/Packages/Accounts/Sources/Accounts/CreateAccount/CreateAccountView.swift +++ b/Packages/Accounts/Sources/Accounts/CreateAccount/CreateAccountView.swift @@ -31,11 +31,16 @@ enum SignInState: Equatable, Sendable { @MainActor class CreateAccountViewModel: ObservableObject { @Published var account: (any Account)? - @Published var activationState: SignInState = .signedOut + @Published var activationState: SignInState lazy var signInViewModel: SignInViewModel = SignInViewModel(publisher: signInPublisher) + private var cancellables: Set - private var cancellables = Set() + init(account: (any Account)? = nil, activationState: SignInState = .signedOut, cancellables: Set = Set()) { + self.account = account + self.activationState = activationState + self.cancellables = cancellables + } var signInPublisher: CurrentValueSubject { let publisher = CurrentValueSubject(.signedOut) @@ -56,10 +61,16 @@ class CreateAccountViewModel: ObservableObject { public struct CreateAccountView: View { - @ObservedObject var viewModel = CreateAccountViewModel() + @ObservedObject var viewModel: CreateAccountViewModel @Environment(\.dismiss) private var dismiss - public init() {} + public init() { + self.init(viewModel: CreateAccountViewModel()) + } + + init(viewModel: CreateAccountViewModel) { + self.viewModel = viewModel + } public var body: some View { NavigationStack { @@ -78,6 +89,25 @@ public struct CreateAccountView: View { } } +#if DEBUG +class MockAccount: Account { + var allLoans: [any LibraryCore.Loan] = [] + + var allCharges: [any LibraryCore.Charge] = [] + + var name: String? = "Irma Vep" + + var username: String? = "Irmion" + + var avatar: String? = AccountTemplate.cat.avatar.imageName + + var isActivated: Bool = true + + var library: (any LibraryCore.Library)? +} #Preview { - CreateAccountView() + let mockAccount = MockAccount() + let viewModel = CreateAccountViewModel(activationState: .signedOut) + CreateAccountView(viewModel: viewModel) } +#endif diff --git a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInSuccessView.swift b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInSuccessView.swift index 6825445..fc30b0a 100644 --- a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInSuccessView.swift +++ b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInSuccessView.swift @@ -50,6 +50,7 @@ struct SignInSuccessView: View { @Environment(\.localAccountService) private var accountService: LocalAccountService @Environment(\.accountActivating) private var accountActivating: AccountActivating @Environment(\.accountCredentialStore) private var accountCredentialStore: AccountCredentialStoring + @Environment(\.dataStackProvider) private var dataStackProvider init(viewModel: SignInSuccessViewModel, done: @escaping () -> Void) { self.viewModel = viewModel @@ -94,16 +95,27 @@ struct SignInSuccessView: View { } Section { - NavigationLink(value: AccountEditViewModel(accountService: accountService, - accountCredentialStore: accountCredentialStore, - accountActivating: accountActivating, - managedObjectContext: DataStackProvider.shared.foregroundManagedObjectContext, - managedObjectId: viewModel.managedObjectId, - onDelete: { - assertionFailure("you cannot delete the account during account creation") - })) { - HStack(alignment: .center) { - AvatarView(viewModel.avatar, size: .small) + NavigationLink( + value: AccountEditViewModel( + accountService: accountService, + accountCredentialStore: accountCredentialStore, + accountActivating: accountActivating, + managedObjectContext: dataStackProvider.foregroundManagedObjectContext, + managedObjectId: viewModel.managedObjectId, + dataStackProvider: dataStackProvider, + onDelete: { + assertionFailure( + "you cannot delete the account during account creation" + ) + }) + ) { + HStack( + alignment: .center + ) { + AvatarView( + viewModel.avatar, + size: .small + ) VStack { HStack { @@ -154,7 +166,6 @@ struct SignInSuccessView: View { } } } - }) RoundedButton({ @@ -167,20 +178,27 @@ struct SignInSuccessView: View { } } +#if DEBUG import Mocks #Preview { - let inMemoryContext: (NSManagedObjectID, NSManagedObjectContext) = { - DataStackProvider.shared.loadInMemory() + let dataStackProvider = DataStackProvider() + let inMemoryContext = dataStackProvider.inMemory() + return SignInSuccessView(viewModel: SignInSuccessViewModel(account: AccountMock(), identifier: inMemoryContext.0)) { + } + .environment(\.dataStackProvider, dataStackProvider) +} + +extension DataStackProvider { + func inMemory() -> (NSManagedObjectID, NSManagedObjectContext) { + loadInMemory() - let account = try! DataStackProvider.shared.createAccount() + let account = try! createAccount() account.accountName = "account II" account.accountUserID = "12345" account.displayName = "Irma Vep 👩🏻‍🏫" account.activated = true - return (account.objectID, DataStackProvider.shared.foregroundManagedObjectContext) - }() - - return SignInSuccessView(viewModel: SignInSuccessViewModel(account: AccountMock(), identifier: inMemoryContext.0)) { + return (account.objectID, foregroundManagedObjectContext) } } +#endif diff --git a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInView.swift b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInView.swift index 87ad673..81d19dc 100644 --- a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInView.swift +++ b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInView.swift @@ -30,6 +30,7 @@ struct SignInView: View { @Environment(\.accountActivating) private var accountActivating: AccountActivating @FetchRequest private var libraries: FetchedResults @Environment(\.dismiss) private var dismiss + @Environment(\.dataStackProvider) private var dataStackProvider init(viewModel: SignInViewModel) { self.viewModel = viewModel @@ -101,19 +102,14 @@ struct SignInView: View { } @ViewBuilder private var signInButton: some View { - RoundedButton( { + RoundedButton(loading: $viewModel.isAuthenticating, { viewModel.handleLogin(username: username, password: password, library: viewModel.library, libraryProvider: libraryProvider, - accountActivator: accountActivating) + accountActivator: accountActivating, dataStackProvider: dataStackProvider) }) { - if viewModel.isAuthenticating { - ActivityIndicator(shouldAnimate: .constant(true)) - .foregroundStyle(.primary) - } else { - Text(Localization.CreateAccount.SignIn.signInButtonTitle) - } + Text(Localization.CreateAccount.SignIn.signInButtonTitle) } } @@ -125,7 +121,7 @@ struct SignInView: View { @ViewBuilder private var librarySelectionView: some View { NavigationView { - LibrarySelectionCoordinator(for: .login) { (library: Persistence.Library) in + LibrarySelectionCoordinator(for: .login, persistentContainer: dataStackProvider.persistentContainer) { (library: Persistence.Library) in viewModel.library = library viewModel.isShowingLibrarySelection = false @@ -155,6 +151,8 @@ struct SignInView: View { } #Preview { + let dataStackProvider = DataStackProvider() + NavigationStack { SignInView(viewModel: SignInViewModel(publisher: CurrentValueSubject(.signedOut))) } diff --git a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInViewModel.swift b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInViewModel.swift index 1f3359c..1052e31 100644 --- a/Packages/Accounts/Sources/Accounts/CreateAccount/SignInViewModel.swift +++ b/Packages/Accounts/Sources/Accounts/CreateAccount/SignInViewModel.swift @@ -27,15 +27,14 @@ final class SignInViewModel: ObservableObject { @Published var showsError = false private var publisher = CurrentValueSubject(.signedOut) - init(publisher: CurrentValueSubject) { self.publisher = publisher } - @MainActor func handleLogin(username: String, password: String, library: Persistence.Library?, libraryProvider: (any LibraryProvider)?, accountActivator: AccountActivating) { + @MainActor func handleLogin(username: String, password: String, library: Persistence.Library?, libraryProvider: (any LibraryProvider)?, accountActivator: AccountActivating, dataStackProvider: DataStackProviding) { let keychainProvider = KeychainManager() let authenticationManager = AuthenticationManager(network: NetworkClient(), keychainManager: keychainProvider) - let persistentContainer = DataStackProvider.shared.persistentContainer + let persistentContainer = dataStackProvider.persistentContainer guard let foregroundManagedObjectContext = persistentContainer?.viewContext, let libraryProvider, @@ -59,7 +58,7 @@ final class SignInViewModel: ObservableObject { Task { @MainActor in try credentialStore.store(password, of: username) - let account = try DataStackProvider.shared.createAccount() + let account = try dataStackProvider.createAccount() let accountTemplate = AccountTemplateGenerator.random() account.accountName = Localization.localized(accountTemplate.name) diff --git a/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditView.swift b/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditView.swift index 7287b06..a0b318d 100644 --- a/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditView.swift +++ b/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditView.swift @@ -21,6 +21,7 @@ struct AccountEditView: View { @ObservedObject private var viewModel: AccountEditViewModel @Environment(\.libraryProvider) private var libraryProvider: LibraryProvider? @Environment(\.accountActivating) private var accountActivating: AccountActivating + @Environment(\.dataStackProvider) private var dataStackProvider @State private var showsDeleteConfirmation = false @State var isShowingAvatarSelection = false private let onSave: () -> Void @@ -31,42 +32,40 @@ struct AccountEditView: View { } var body: some View { - VStack { - List { - Section { - HStack { - Spacer() - AvatarView(viewModel.avatarName, selected: true) { - isShowingAvatarSelection = true - } + List { + Section { + HStack { + Spacer() + AvatarView(viewModel.avatarName, selected: true) { + isShowingAvatarSelection = true } } - .listRowBackground(Color.clear) - - Section { - ValueEditView(viewModel.displayNameValueEditViewModel) - .textContentType(.nickname) + } + .listRowBackground(Color.clear) - ValueEditView(viewModel.userNameValueEditViewModel) - .textContentType(.username) + Section { + ValueEditView(viewModel.displayNameValueEditViewModel) + .textContentType(.nickname) - ValueEditView(viewModel.passwordValueEditViewModel) - .textContentType(.password) - } + ValueEditView(viewModel.userNameValueEditViewModel) + .textContentType(.username) - - Section { - librarySelectionButton - } - .listRowBackground(Color.clear) + ValueEditView(viewModel.passwordValueEditViewModel) + .textContentType(.password) } - .scrollContentBackground(.hidden) - Spacer() + Section { + librarySelectionButton + } + .listRowBackground(Color.clear) + } + .scrollContentBackground(.hidden) + .toolbar { activateButton - .padding() } + .toolbarRole(.editor) + .scrollDismissesKeyboard(.interactively) .task { viewModel.updateValues() @@ -109,25 +108,20 @@ struct AccountEditView: View { @ViewBuilder private var activateButton: some View { switch viewModel.activationState { case .signInFailed, .inactive, .error: - RoundedButton({ + Button(action: { viewModel.saveAndActivateAccount(libraryProvider: libraryProvider, accountActivating: accountActivating) - }, { - Text(viewModel.isDirty ? + }) { + Text(viewModel.isDirty ? Localization.EditAccount.ActivateButtonTitle.authenticate: Localization.EditAccount.ActivateButtonTitle.notActivated) - }) + } case .activating: - RoundedButton({ - }, { - ActivityIndicator(shouldAnimate: .constant(true)) - }) - .disabled(true) + ActivityIndicator(shouldAnimate: .constant(true)) case .activated: - RoundedButton({ - }, { + Button(action: {}) { Text(Localization.EditAccount.ActivateButtonTitle.activated) - }) + } .disabled(true) } } @@ -161,7 +155,7 @@ struct AccountEditView: View { @ViewBuilder private var librarySelectionView: some View { NavigationView { - LibrarySelectionCoordinator(for: .login, currentlySelected: viewModel.account?.library?.identifier) { (library: Persistence.Library) in + LibrarySelectionCoordinator(for: .login, persistentContainer: dataStackProvider.persistentContainer, currentlySelected: viewModel.account?.library?.identifier) { (library: Persistence.Library) in viewModel.librarySelectionViewModel.selectedLibraryIdentifier = library.identifier viewModel.account?.library = library viewModel.isShowingLibrarySelection = false @@ -186,8 +180,8 @@ struct AccountEditView: View { LibraryButton( name: viewModel.account?.library?.name ?? Localization.Detail.noLibrarySelected, subtitle: viewModel.account?.library?.subtitle) { - viewModel.isShowingLibrarySelection = true - } + viewModel.isShowingLibrarySelection = true + } } private var deleteConfirmationAlert: Alert { @@ -208,23 +202,24 @@ struct AccountEditView: View { } #Preview { + let dataStackProvider = DataStackProvider() let inMemoryContext: (NSManagedObjectContext, NSManagedObjectID) = { - DataStackProvider.shared.loadInMemory() + dataStackProvider.loadInMemory() - let account = try! DataStackProvider.shared.createAccount() + let account = try! dataStackProvider.createAccount() account.accountName = "account II" account.accountUserID = "12345" account.displayName = "Irma Vep 👩🏻‍🏫" account.activated = false - return (DataStackProvider.shared.foregroundManagedObjectContext, account.objectID) + return (dataStackProvider.foregroundManagedObjectContext, account.objectID) }() let viewModel = AccountEditViewModel( accountService: LocalAccountRepository(context: inMemoryContext.0), accountCredentialStore: nil, accountActivating: nil, - managedObjectContext: inMemoryContext.0, managedObjectId: inMemoryContext.1, onDelete: {}) + managedObjectContext: inMemoryContext.0, managedObjectId: inMemoryContext.1, dataStackProvider: dataStackProvider, onDelete: {}) return NavigationView { AccountEditView(viewModel, onSave: {}) diff --git a/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditViewModel.swift b/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditViewModel.swift index 0f19359..43a67af 100644 --- a/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditViewModel.swift +++ b/Packages/Accounts/Sources/Accounts/EditAccount/AccountEditViewModel.swift @@ -62,6 +62,7 @@ final class AccountEditViewModel: ObservableObject, @MainActor AccountEditViewMo @Published var isAuthenticated: Bool = false @Published var avatarName: String? private var initialAccountActivation = false + private let dataStackProvider: DataStackProviding private var onDelete: () -> Void @@ -91,12 +92,14 @@ final class AccountEditViewModel: ObservableObject, @MainActor AccountEditViewMo accountActivating: AccountActivating?, managedObjectContext: NSManagedObjectContext, managedObjectId: NSManagedObjectID, + dataStackProvider: DataStackProviding, onDelete: @escaping () -> Void) { self.accountService = accountService self.accountCredentialStore = accountCredentialStore self.accountActivating = accountActivating self.managedObjectContext = managedObjectContext self.managedObjectId = managedObjectId + self.dataStackProvider = dataStackProvider self.onDelete = onDelete shirt.isDirty @@ -160,7 +163,7 @@ final class AccountEditViewModel: ObservableObject, @MainActor AccountEditViewMo let keychainProvider = KeychainManager() let authenticationManager = AuthenticationManager(network: NetworkClient(), keychainManager: keychainProvider) - let persistentContainer = DataStackProvider.shared.persistentContainer + let persistentContainer = dataStackProvider.persistentContainer guard let account, let foregroundManagedObjectContext = persistentContainer?.viewContext, diff --git a/Packages/Accounts/Sources/Accounts/List/AccountList.swift b/Packages/Accounts/Sources/Accounts/List/AccountList.swift index 6599210..513fd21 100644 --- a/Packages/Accounts/Sources/Accounts/List/AccountList.swift +++ b/Packages/Accounts/Sources/Accounts/List/AccountList.swift @@ -72,18 +72,18 @@ public struct AccountList: View { } #Preview { - let viewModel = AccountListViewModel(dataStackProvider: DataStackProvider.shared) {} + let dataStackProvider = DataStackProvider() + let viewModel = AccountListViewModel(dataStackProvider: dataStackProvider) {} var inMemoryContext: NSManagedObjectContext = { - DataStackProvider.shared.loadInMemory() + dataStackProvider.loadInMemory() - let account2 = try! DataStackProvider.shared.createAccount() + let account2 = try! dataStackProvider.createAccount() account2.accountName = "account II" account2.accountUserID = "12345" account2.accountAvatar = "avatar-tiger" - return DataStackProvider.shared.foregroundManagedObjectContext + return dataStackProvider.foregroundManagedObjectContext }() - NavigationStack { AccountList(viewModel: viewModel) } diff --git a/Packages/Accounts/Sources/Accounts/List/AccountListViewModel.swift b/Packages/Accounts/Sources/Accounts/List/AccountListViewModel.swift index fd11ac7..2ca046e 100644 --- a/Packages/Accounts/Sources/Accounts/List/AccountListViewModel.swift +++ b/Packages/Accounts/Sources/Accounts/List/AccountListViewModel.swift @@ -39,6 +39,7 @@ import Persistence accountActivating: accountActivating, managedObjectContext: dataStackProvider.foregroundManagedObjectContext, managedObjectId: identifier, + dataStackProvider: dataStackProvider, onDelete: onDelete) } } diff --git a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionCoordinator.swift b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionCoordinator.swift index 17d6cd7..4a4f073 100644 --- a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionCoordinator.swift +++ b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionCoordinator.swift @@ -3,19 +3,22 @@ import SwiftUI import ArchitectureX import LibraryCore +import Persistence public class SettingsSectionCoordinator: Coordinator { public var router: Router? private let settingsService: any LibraryCore.SettingsService + let dataStackProvider: DataStackProviding private lazy var viewModel: SettingsViewModel = { - SettingsViewModel(service: settingsService) + SettingsViewModel(service: settingsService, dataStackProvider: dataStackProvider) }() - public init(router: Router? = nil, settingsService: any LibraryCore.SettingsService) { + public init(router: Router? = nil, settingsService: any LibraryCore.SettingsService, dataStackProvider: DataStackProviding) { self.router = router self.settingsService = settingsService + self.dataStackProvider = dataStackProvider } public var contentView: some View { diff --git a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionView.swift b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionView.swift index 94544b3..9ce312c 100644 --- a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionView.swift +++ b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsSectionView.swift @@ -243,11 +243,12 @@ extension View { #if DEBUG import Combine import LibraryCore +import Persistence struct SettingsSectionView_Previews: PreviewProvider { struct Preview: View { - @ObservedObject private var viewModel = SettingsViewModel(service: MockSettingsService(isAlternateAppIconEnabled: true, aiRecommenderEnabled: true)) + @ObservedObject private var viewModel = SettingsViewModel(service: MockSettingsService(isAlternateAppIconEnabled: true, aiRecommenderEnabled: true), dataStackProvider: DataStackProvider()) var body: some View { NavigationView { diff --git a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsViewModel.swift b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsViewModel.swift index 3dfc5e5..eac61f7 100644 --- a/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsViewModel.swift +++ b/Packages/BTLBSettings/Sources/BTLBSettings/Sections/Settings Section/SettingsViewModel.swift @@ -13,14 +13,16 @@ final class SettingsViewModel: ObservableObject { private let service: any LibraryCore.SettingsService private var bag = Set() private let expirationNotificationsThresholds: (lower: UInt, upper: UInt) = (2, 7) + private let dataStackProvider: DataStackProviding - init(service: any LibraryCore.SettingsService) { + init(service: any LibraryCore.SettingsService, dataStackProvider: DataStackProviding) { self.service = service self.alternetAppIconEnabled = service.isAlternateAppIconEnabled self.loanExpirationNotificationsEnabled = service.loanExpirationNotificationsEnabled() self.notificationsEnabled = service.notificationsEnabled() self.aiRecommenderEnabled = service.aiRecommenderEnabled self.debugEnabled = service.debugEnabled + self.dataStackProvider = dataStackProvider service.publisher.receive(on: RunLoop.main).sink { value in switch value { @@ -83,8 +85,8 @@ final class SettingsViewModel: ObservableObject { self.service.loanExpirationNotificationsThreshold = $0 Task { - if let dataStackProvider = DataStackProvider.shared as? SwiftOnlyDataStackProviding { - let renewableItems = try await dataStackProvider.renewableItems(in: DataStackProvider.shared.backgroundManagedObjectContext) + if let dataStackProvider = self.dataStackProvider as? SwiftOnlyDataStackProviding { + let renewableItems = try await dataStackProvider.renewableItems(in: self.dataStackProvider.backgroundManagedObjectContext) try await AppEventPublisher.shared.sendUpdate(.settingChange(renewableItems: renewableItems)) } } diff --git a/Packages/BTLBSettings/Sources/BTLBSettings/SettingsService.swift b/Packages/BTLBSettings/Sources/BTLBSettings/SettingsService.swift index 12c86b2..1874861 100644 --- a/Packages/BTLBSettings/Sources/BTLBSettings/SettingsService.swift +++ b/Packages/BTLBSettings/Sources/BTLBSettings/SettingsService.swift @@ -64,6 +64,7 @@ public final class SettingsService: LibraryCore.SettingsService { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) } + @MainActor public func toggleNotificationsEnabled(on isOn: Bool) { Task { if await notificationScheduler.authorized() { @@ -73,11 +74,12 @@ public final class SettingsService: LibraryCore.SettingsService { publisher.send(.notificationsAuthorized(false)) } - if !isOn { + if isOn == false { // TODO: should remove all notifications, not just loans accountService.removeLoansNotifications() } else { - // let renewableItems = DataStackProvider.shared.renewableItems(in: DataStackProvider.shared.backgroundManagedObjectContext) + // TODO: what's going on here? + // let renewableItems = dataStackProvider.renewableItems(in: dataStackProvider.backgroundManagedObjectContext) try await AppEventPublisher.shared.sendUpdate(.settingChange(renewableItems: [])) //renewableItems } } @@ -93,6 +95,7 @@ public final class SettingsService: LibraryCore.SettingsService { userDefaults.accountUpdateNotificationsEnabled } + @MainActor public func toggleLoanExpirationNotificationsEnabled(on isOn: Bool) { userDefaults.accountUpdateNotificationsEnabled = isOn diff --git a/Packages/Bookmarks/Package.swift b/Packages/Bookmarks/Package.swift index 1662bff..01c9f8f 100644 --- a/Packages/Bookmarks/Package.swift +++ b/Packages/Bookmarks/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 import PackageDescription @@ -30,7 +30,8 @@ let package = Package( "LibraryUI", "Localization", "Persistence" - ]), + ], + resources: [.process("Resources")]), .testTarget( name: "BookmarksTests", dependencies: ["Bookmarks"]), diff --git a/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommendationBookmarkSelectionCoordinator.swift b/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommendationBookmarkSelectionCoordinator.swift index d698d08..e7fdfd8 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommendationBookmarkSelectionCoordinator.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommendationBookmarkSelectionCoordinator.swift @@ -10,7 +10,7 @@ import ArchitectureX import LibraryCore import LibraryUI -class AiRecommendationBookmarkSelectionCoordinator: Coordinator { +@MainActor class AiRecommendationBookmarkSelectionCoordinator: @MainActor Coordinator { var router: Router? let bookmarks: [any LibraryCore.Bookmark] diff --git a/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommenderCoordinator.swift b/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommenderCoordinator.swift index 1105b95..dea3d43 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommenderCoordinator.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/AI/AiRecommenderCoordinator.swift @@ -14,7 +14,7 @@ import Libraries import Utilities import Persistence -class AiRecommenderCoordinator: Coordinator { +@MainActor class AiRecommenderCoordinator: @MainActor Coordinator { var router: Router? let recommender: RecommenderProtocol @@ -25,7 +25,7 @@ class AiRecommenderCoordinator: Coordinator { self.bookmarks = bookmarks } - var contentView: some View { + @MainActor var contentView: some View { AiRecommenderView(viewModel: AiRecommenderViewModel(recommender, titles: bookmarks.compactMap { $0.bookmarkTitle }) { recommendation, bookRecommendation, coordinatorProvider in self.transition(to: coordinatorProvider.coordinator(for: .search(query: bookRecommendation.title)), style: .present(modalInPresentation: false)) diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkCoordinator.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkCoordinator.swift index c2ca6cb..4e51e73 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkCoordinator.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkCoordinator.swift @@ -16,7 +16,7 @@ import Localization import Persistence import Utilities -public class BookmarkCoordinator: Coordinator { +@MainActor public class BookmarkCoordinator: @MainActor Coordinator { public var router: Router? private var bookmark: any Bookmark @@ -26,7 +26,7 @@ public class BookmarkCoordinator: Coordinator { } private var bag = Set() - private lazy var viewModel: BookmarkViewModelProtocol = { + private lazy var viewModel: BookmarkViewModel = { let viewModel = BookmarkViewModel(bookmark: bookmark) viewModel.eventPublisher.sink { [weak self] event in self?.handle(event) @@ -42,8 +42,6 @@ public class BookmarkCoordinator: Coordinator { private func handle(_ event: BookmarkEvent) { switch event { - case .dismiss: - dismiss() case .delete(let bookmark): Task { @MainActor in dismiss() diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkDetailView.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkDetailView.swift index 1362e58..8abc4f9 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkDetailView.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkDetailView.swift @@ -12,11 +12,11 @@ import LibraryUI import Utilities import Localization -struct BookmarkDetailView: View { +struct BookmarkDetailView: View { - private var viewModel: BookmarkViewModelProtocol + @ObservedObject private var viewModel: ViewModel - init(viewModel: BookmarkViewModelProtocol) { + init(viewModel: ViewModel) { self.viewModel = viewModel } @@ -54,35 +54,32 @@ struct BookmarkDetailView: View { ForEach(viewModel.bookmark.infos, id: \.self) { info in PairView(key: info.title.localized, value: info.value) } - - deleteButton } - .navigationBarItems(trailing: trailingBarItems) - } - } + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + viewModel.showsDeleteConfirmation.toggle() + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("delete bookmark button") + .confirmationDialog("Are you sure you want to delete the bookmark?", isPresented: $viewModel.showsDeleteConfirmation) { + Button(action: { + Task { + viewModel.delete() + } + }, label: { + Text("delete bookmark button", bundle: .module) + }) - @ViewBuilder private var trailingBarItems: some View { - Group { - if UIDevice.current.userInterfaceIdiom == .phone { - doneButton + Button(action: { + viewModel.showsDeleteConfirmation = false + }, label: { + Text("cancel delete bookmark button", bundle: .module) + }) + } + } } } } - - @ViewBuilder private var doneButton: some View { - Button(action: { - viewModel.dismiss() - }) { - Text("Done", bundle: .localization) - } - } - - @ViewBuilder private var deleteButton: some View { - Button(action: { - viewModel.delete() - }) { - Text("delete bookmark button", bundle: .module) - .foregroundColor(.red) - } - } } diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListCoordinator.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListCoordinator.swift index 7f3cba9..684c8e6 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListCoordinator.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListCoordinator.swift @@ -5,7 +5,7 @@ import LibraryCore import Localization import Persistence -public class BookmarkListCoordinator: Coordinator { +@MainActor public class BookmarkListCoordinator: @MainActor Coordinator { public var router: Router? = Router() private var viewModel: ViewModel diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListView.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListView.swift index 9e87ffd..3441f9a 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListView.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListView.swift @@ -120,8 +120,9 @@ class ViewModel: BookmarkListViewModelProtocol { } #Preview { - DataStackProvider.shared.loadInMemory() - let moc = DataStackProvider.shared.foregroundManagedObjectContext + let dataStackProvider = DataStackProvider() + dataStackProvider.loadInMemory() + let moc = dataStackProvider.foregroundManagedObjectContext let controller = BookmarkService(managedObjectContext: moc) try! controller.bookmarkSearchResult(.init(library: LibraryMock(), ISBN: "nil", title: "A", author: "a", image: nil, imageURL: nil, barcode: "", content: [Pair(key: "x", value: "a")]), identifier: "123") diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListViewModel.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListViewModel.swift index ac2fd76..425bc94 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkListViewModel.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkListViewModel.swift @@ -12,7 +12,7 @@ import SwiftUI import ArchitectureX import LibraryCore -public class BookmarkListViewModel: Bookmarks.BookmarkListViewModelProtocol { +@MainActor public class BookmarkListViewModel: @MainActor Bookmarks.BookmarkListViewModelProtocol { @Published public var searchText: String = "" private var currentDetailCoordinator: (any Coordinator)? diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModel.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModel.swift index 1e6f60d..f2b5be1 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModel.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModel.swift @@ -16,16 +16,14 @@ import Persistence public final class BookmarkViewModel: BookmarkViewModelProtocol, ObservableObject { @Published public var bookmark: any Bookmark + @Published public var showsDeleteConfirmation: Bool = false public var eventPublisher: PassthroughSubject = PassthroughSubject() public init(bookmark: any Bookmark) { self.bookmark = bookmark } - public func dismiss() { - eventPublisher.send(.dismiss) - } - + @MainActor public func delete() { eventPublisher.send(.delete(bookmark: bookmark)) } diff --git a/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModelProtocol.swift b/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModelProtocol.swift index b44e3ec..1ea2aa8 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModelProtocol.swift +++ b/Packages/Bookmarks/Sources/Bookmarks/BookmarkViewModelProtocol.swift @@ -10,20 +10,18 @@ import Combine import LibraryCore public enum BookmarkEvent { - /// Used for dismissing the Bookmark Detail View - case dismiss - /// Used for deleting a given bookmark case delete(bookmark: any Bookmark) } -public protocol BookmarkViewModelProtocol { +@MainActor +public protocol BookmarkViewModelProtocol: ObservableObject { var bookmark: any Bookmark { get } - var eventPublisher: PassthroughSubject { get } + @MainActor var showsDeleteConfirmation: Bool { get set } - func dismiss() + var eventPublisher: PassthroughSubject { get } - func delete() + @MainActor func delete() } diff --git a/Packages/Bookmarks/Sources/Bookmarks/Localizable.xcstrings b/Packages/Bookmarks/Sources/Bookmarks/Resources/Localizable.xcstrings similarity index 96% rename from Packages/Bookmarks/Sources/Bookmarks/Localizable.xcstrings rename to Packages/Bookmarks/Sources/Bookmarks/Resources/Localizable.xcstrings index ab2de66..8580e53 100644 --- a/Packages/Bookmarks/Sources/Bookmarks/Localizable.xcstrings +++ b/Packages/Bookmarks/Sources/Bookmarks/Resources/Localizable.xcstrings @@ -18,6 +18,23 @@ } } }, + "cancel delete bookmark button" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, "delete bookmark button" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/Libraries/Sources/Libraries/LibraryManager.swift b/Packages/Libraries/Sources/Libraries/LibraryManager.swift index 8c69505..46d7cb4 100644 --- a/Packages/Libraries/Sources/Libraries/LibraryManager.swift +++ b/Packages/Libraries/Sources/Libraries/LibraryManager.swift @@ -20,7 +20,7 @@ private enum Constants { } public final class LibraryManager: LibraryProvider { - private let persistentContainer: NSPersistentContainer + private let persistentContainer: PersistentContainer var amountOfLibrariesToProcess: UInt = 0 public var legacySearchLibrary: NSManagedObjectID? { @@ -36,7 +36,7 @@ public final class LibraryManager: LibraryProvider { } return library(forIdentifier: libraryIdentifier, - in: DataStackProvider.shared.backgroundManagedObjectContext) + in: persistentContainer.newBackgroundContext()) } public var defaultLibrary: NSManagedObjectID? { @@ -44,7 +44,7 @@ public final class LibraryManager: LibraryProvider { in: persistentContainer.viewContext) } - public init(persistentContainer: NSPersistentContainer) { + public init(persistentContainer: PersistentContainer) { self.persistentContainer = persistentContainer } @@ -84,11 +84,7 @@ public final class LibraryManager: LibraryProvider { func libraries(in context: NSManagedObjectContext, completion: @escaping ([Persistence.Library]) -> Void) { Task { do { - if let libraries = try await DataStackProvider.shared.persistentContainer?.libraries(in: context) { - completion(libraries) - } else { - completion([]) - } + completion(try await persistentContainer.libraries(in: context)) } catch let error { Logger.libraries.error("Error loading libraries from persistent container\n\(error.localizedDescription)") completion([]) diff --git a/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionCoordinator.swift b/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionCoordinator.swift index c39466e..19ce056 100644 --- a/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionCoordinator.swift +++ b/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionCoordinator.swift @@ -15,16 +15,23 @@ import Persistence public class LibrarySelectionCoordinator { let currentlySelectedIdentifier: String? let librarySelectionType: LibrarySelectionType + let persistentContainer: PersistentContainer? let librarySelection: @MainActor (_ selectedLibrary: L) -> () - public init(for librarySelectionType: LibrarySelectionType, currentlySelected identifier: String? = nil, librarySelection: @escaping @MainActor (_ selectedLibrary: L) -> ()) { + public init(for librarySelectionType: LibrarySelectionType, persistentContainer: PersistentContainer?, currentlySelected identifier: String? = nil, librarySelection: @escaping @MainActor (_ selectedLibrary: L) -> ()) { self.currentlySelectedIdentifier = identifier + self.persistentContainer = persistentContainer self.librarySelectionType = librarySelectionType self.librarySelection = librarySelection } @MainActor public var contentView: some View { - let viewModel = LibrarySelectionViewModel(for: librarySelectionType, currentlySelected: currentlySelectedIdentifier, librarySelection: librarySelection) + let viewModel = LibrarySelectionViewModel( + for: librarySelectionType, + persistentContainer: persistentContainer, + currentlySelected: currentlySelectedIdentifier, + librarySelection: librarySelection + ) return LibrarySelectionView(viewModel: viewModel) diff --git a/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionView.swift b/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionView.swift index 1a7d246..fa0fc6f 100644 --- a/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionView.swift +++ b/Packages/Libraries/Sources/Libraries/LibrarySelection/LibrarySelectionView.swift @@ -12,6 +12,7 @@ import SwiftUI import LibraryCore import class Persistence.DataStackProvider +import class Persistence.PersistentContainer public struct LibrarySelectionView: View { @@ -69,6 +70,7 @@ public class LibrarySelectionViewModel: ObservableObject @Published var filterText = "" @Published var filteredLibraries = [L]() @Published var libraries = [L]() + private let persistentContainer: PersistentContainer? let selectionType: LibrarySelectionType let currentlySelectedIdentifier: String? let librarySelection: (_ selectedLibrary: L) -> () @@ -82,9 +84,11 @@ public class LibrarySelectionViewModel: ObservableObject } init(for selectionType: LibrarySelectionType, + persistentContainer: PersistentContainer?, currentlySelected identifier: String? = nil, librarySelection: @escaping (_ selectedLibrary: L) -> ()) { self.selectionType = selectionType + self.persistentContainer = persistentContainer self.currentlySelectedIdentifier = identifier self.librarySelection = librarySelection @@ -110,7 +114,7 @@ public class LibrarySelectionViewModel: ObservableObject } fileprivate func fetchLibraries() { - DataStackProvider.shared.persistentContainer?.libraries(completion: { (result: Result<[L], Error>) in + persistentContainer?.libraries(completion: { (result: Result<[L], Error>) in switch result { case .success(let libraries): print(libraries) @@ -125,15 +129,13 @@ public class LibrarySelectionViewModel: ObservableObject } #Preview { - DataStackProvider.shared.loadInMemory() - - return LibrarySelectionView(viewModel: LibrarySelectionViewModel.stub) + LibrarySelectionView(viewModel: LibrarySelectionViewModel.stub) } private extension LibrarySelectionViewModel { - static var stub: LibrarySelectionViewModel { - LibrarySelectionViewModel(for: .search, librarySelection: { library in + @MainActor static var stub: LibrarySelectionViewModel { + LibrarySelectionViewModel(for: .search, persistentContainer: DataStackProvider().persistentContainer, librarySelection: { library in }) } } diff --git a/Packages/LibraryCore/Sources/LibraryCore/LibraryInterfaces.swift b/Packages/LibraryCore/Sources/LibraryCore/LibraryInterfaces.swift index 449213a..eb5dc53 100644 --- a/Packages/LibraryCore/Sources/LibraryCore/LibraryInterfaces.swift +++ b/Packages/LibraryCore/Sources/LibraryCore/LibraryInterfaces.swift @@ -168,10 +168,6 @@ public enum Setting { case notificationsAuthorized(Bool) } -public protocol DatabaseConnectionProducing { - func databaseConnection(for managedObjectContext: NSManagedObjectContext, accountService: AccountServiceProviding) -> LoanBackendServicing -} - public enum ProgressButtonState: Equatable { case idle(systemImageName: String) case animating(systemImageName: String) @@ -200,7 +196,7 @@ public protocol LoanListViewModel: ObservableObject { var isShowingErrors: Bool { get set } var errors: [PaperErrorInternal] { get } - func refresh() async throws + func refresh(in context: NSManagedObjectContext) async throws func show(_ loan: some LoanViewModel) } @@ -677,7 +673,7 @@ public struct BookRecommendation: Hashable { } public protocol RecommenderProtocol { - func recommendations(for titles: [String]) async throws -> Recommendation + @MainActor func recommendations(for titles: [String]) async throws -> Recommendation } public extension EnvironmentValues { diff --git a/Packages/LibraryUI/Sources/LibraryUI/AvatarView.swift b/Packages/LibraryUI/Sources/LibraryUI/AvatarView.swift index 768319e..a5cefb7 100644 --- a/Packages/LibraryUI/Sources/LibraryUI/AvatarView.swift +++ b/Packages/LibraryUI/Sources/LibraryUI/AvatarView.swift @@ -51,6 +51,18 @@ public struct AvatarView: View { } public var body: some View { + if editAction != nil { + Button { + editAction?() + } label: { + image + } + } else { + image + } + } + + @ViewBuilder private var image: some View { Image(avatar.imageName, bundle: .module) .resizable() .frame(width: size.width, height: size.width) diff --git a/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/Contents.json b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/buttonFill.colorset/Contents.json b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/buttonFill.colorset/Contents.json new file mode 100644 index 0000000..7f16475 --- /dev/null +++ b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/buttonFill.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.134", + "green" : "0.134", + "red" : "0.134" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/primaryButtonForeground.colorset/Contents.json b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/primaryButtonForeground.colorset/Contents.json new file mode 100644 index 0000000..05326ed --- /dev/null +++ b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/primaryButtonForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "0.990" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "0.990" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/secondaryButtonForeground.colorset/Contents.json b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/secondaryButtonForeground.colorset/Contents.json new file mode 100644 index 0000000..2d1c4c1 --- /dev/null +++ b/Packages/LibraryUI/Sources/LibraryUI/Resources/Colors.xcassets/secondaryButtonForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "0.990" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/LibraryUI/Sources/LibraryUI/RoundedButton.swift b/Packages/LibraryUI/Sources/LibraryUI/RoundedButton.swift index 1a52846..39579f4 100644 --- a/Packages/LibraryUI/Sources/LibraryUI/RoundedButton.swift +++ b/Packages/LibraryUI/Sources/LibraryUI/RoundedButton.swift @@ -15,67 +15,93 @@ public enum RoundedButtonStyle { public struct RoundedButton: View { - let label: Content - let action: () -> Void + let label: () -> Content + @Binding var loading: Bool + let action: (() -> Void)? let style: RoundedButtonStyle @Environment(\.isEnabled) private var isEnabled: Bool - public init(style: RoundedButtonStyle = .primary, _ action: @escaping () -> Void, @ViewBuilder _ label: @escaping () -> Content) { + public init(style: RoundedButtonStyle = .primary, loading: Binding = .constant(false), _ action: (() -> Void)? = nil, @ViewBuilder _ label: @escaping () -> Content) { self.style = style - self.label = label() + _loading = loading + self.label = label self.action = action } public var body: some View { - Button(action: action) { + Button(action: action ?? {}) { HStack { Spacer() Group { - if style == .primary { - label - .bold() - .foregroundStyle(.primary) - .colorInvert() + if loading { + loadingView } else { - label - .bold() - .foregroundStyle(.primary) + labelView } } - .opacity(isEnabled ? 1 : 0.7) .padding() + .opacity(isEnabled ? 1 : 0.7) + Spacer() } .background( RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(style == .primary ? Color.primary : Color.clear) + .fill(style == .primary ? Color(.buttonFill) : Color.clear) .overlay( RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(Color.primary, lineWidth: style == .secondary ? 3 : 0) + .stroke(Color.primary.opacity(loadingOrDisabled ? 0.3 : 1), lineWidth: style == .secondary ? 3 : 4) ) - .opacity(isEnabled ? 1 : 0.3) ) } + .disabled(loadingOrDisabled) + } + private var loadingOrDisabled: Bool { + !isEnabled || loading + } + + @ViewBuilder private var labelView: some View { + label() + .bold() + .foregroundStyle(style == .primary ? Color(.primaryButtonForeground) : Color(.secondaryButtonForeground)) + } + + @ViewBuilder private var loadingView: some View { + ActivityIndicator(shouldAnimate: $loading) } } #Preview { VStack(spacing: 20) { - RoundedButton({ - - }) { + RoundedButton{ Text("Primary Button") } - - RoundedButton(style: .secondary, { - + + RoundedButton(loading: .constant(true), { }) { + Text("Primary Button loading") + } + + RoundedButton { + Text("Primary Button disabled") + } + .environment(\.isEnabled, false) + + RoundedButton(style: .secondary) { Text("Secondary Button") } + + RoundedButton(style: .secondary) { + Text("Secondary Button disabled") + } + .environment(\.isEnabled, false) + + RoundedButton(style: .secondary, loading: .constant(true)) { + Text("Secondary Button loading") + } } .padding() } diff --git a/Packages/Loans/Sources/Loans/List/LoanList.swift b/Packages/Loans/Sources/Loans/List/LoanList.swift index 973a323..f37c694 100644 --- a/Packages/Loans/Sources/Loans/List/LoanList.swift +++ b/Packages/Loans/Sources/Loans/List/LoanList.swift @@ -23,6 +23,7 @@ public struct LoanList: View { @ObservedObject private var viewModel: ViewModel @Environment(\.loanService) private var loanService: LoanService @Environment(\.intent) private var intent: (any AppIntent)? + @Environment(\.dataStackProvider) private var dataStackProvider @SectionedFetchRequest( sectionIdentifier: \.accountIdentifier, @@ -104,7 +105,7 @@ public struct LoanList: View { } .refreshable { do { - try await viewModel.refresh() + try await viewModel.refresh(in: dataStackProvider.foregroundManagedObjectContext) } catch let error{ print("handle me \(error.localizedDescription)") } @@ -113,9 +114,17 @@ public struct LoanList: View { } #if DEBUG +import CoreData + +struct LoanList_Previews: PreviewProvider { + private static let viewModel = PreviewLoanListViewModel() + static var previews: some View { + LoanList(viewModel: viewModel) + } +} + public class PreviewLoanListViewModel: LibraryCore.LoanListViewModel { public var errors: [LibraryCore.PaperErrorInternal] = [] - public var id = UUID() @@ -125,20 +134,12 @@ public class PreviewLoanListViewModel: LibraryCore.LoanListViewModel { .activated(account) } - public func refresh() async throws { + public func refresh(in context: NSManagedObjectContext) async throws { print("refreshing") } public func show(_ loan: some LibraryCore.LoanViewModel) { print("showing") } - -} - -struct LoanList_Previews: PreviewProvider { - private static let viewModel = PreviewLoanListViewModel() - static var previews: some View { - LoanList(viewModel: viewModel) - } } #endif diff --git a/Packages/Loans/Sources/Loans/List/LoanListViewModel.swift b/Packages/Loans/Sources/Loans/List/LoanListViewModel.swift index 0399c96..4ea7dbb 100644 --- a/Packages/Loans/Sources/Loans/List/LoanListViewModel.swift +++ b/Packages/Loans/Sources/Loans/List/LoanListViewModel.swift @@ -7,6 +7,7 @@ // import Combine +import CoreData import Foundation import SwiftUI @@ -34,9 +35,9 @@ class LoanListViewModel: LibraryCore.LoanListViewModel { } @MainActor - func refresh() async throws { + func refresh(in context: NSManagedObjectContext) async throws { do { - let updateResult = try await accountUpdater.manualUpdate(in: DataStackProvider.shared.foregroundManagedObjectContext, at: .now) + let updateResult = try await accountUpdater.manualUpdate(in: context, at: .now) errors = updateResult.errors isShowingErrors = updateResult.errors.isEmpty == false } catch(let error as PaperErrorInternal) { diff --git a/Packages/Loans/Sources/Loans/Loan/LoanCoordinator.swift b/Packages/Loans/Sources/Loans/Loan/LoanCoordinator.swift index f44023c..7aff0b9 100644 --- a/Packages/Loans/Sources/Loans/Loan/LoanCoordinator.swift +++ b/Packages/Loans/Sources/Loans/Loan/LoanCoordinator.swift @@ -24,8 +24,7 @@ class LoanCoordinator: Coordinator { } @MainActor var contentView: some View { - LoanDetailView(loan) { - self.dismiss() - } + LoanDetailView(loan) + .presentationDetents([.medium, .large]) } } diff --git a/Packages/Loans/Sources/Loans/Loan/LoanDetailView.swift b/Packages/Loans/Sources/Loans/Loan/LoanDetailView.swift index f2fac6b..1ca75ac 100644 --- a/Packages/Loans/Sources/Loans/Loan/LoanDetailView.swift +++ b/Packages/Loans/Sources/Loans/Loan/LoanDetailView.swift @@ -19,11 +19,9 @@ public struct LoanDetailView: View { @ObservedObject private var viewModel: ViewModel @Environment(\.requestReview) private var requestReview - private var dismiss: @MainActor () -> Void - public init(_ viewModel: ViewModel, dismiss: @MainActor @escaping () -> Void) { + public init(_ viewModel: ViewModel) { self.viewModel = viewModel - self.dismiss = dismiss } public var body: some View { @@ -119,20 +117,6 @@ public struct LoanDetailView: View { }) } } - - - - ToolbarItem(placement: .topBarTrailing) { - Button(action: { - Task { - await MainActor.run { - dismiss() - } - } - }) { - Text("Done".localized) - } - } } #if swift(>=6.2) @@ -378,9 +362,7 @@ class LoanStub: Loan { loan: LoanStub.stub1) viewModel.showsRenewalConfirmation = false - return LoanDetailView(viewModel) { - - } + return LoanDetailView(viewModel) } } #endif diff --git a/Packages/Loans/Sources/Loans/LoansSectionCoordinator.swift b/Packages/Loans/Sources/Loans/LoansSectionCoordinator.swift index 81dcd11..52a6cf8 100644 --- a/Packages/Loans/Sources/Loans/LoansSectionCoordinator.swift +++ b/Packages/Loans/Sources/Loans/LoansSectionCoordinator.swift @@ -46,15 +46,6 @@ public class LoansSectionCoordinator: Coordinator { } public var contentView: some View { - let viewContext = DataStackProvider.shared.foregroundManagedObjectContext - let backendService = databaseFactory.databaseConnection(for: viewContext, accountService: dependencies.accountService) - let bookmarkService = BookmarkService(managedObjectContext: viewContext) - - return LoanList(viewModel: viewModel) - .environment(\.managedObjectContext, - DataStackProvider.shared.foregroundManagedObjectContext) - .environment(\.loanService, - BTLBLoanService(backendService: backendService, - bookmarkService: bookmarkService)) + LoanList(viewModel: viewModel) } } diff --git a/Packages/Localization/Sources/Localization/Localization.swift b/Packages/Localization/Sources/Localization/Localization.swift index 54a71ca..ee5e080 100644 --- a/Packages/Localization/Sources/Localization/Localization.swift +++ b/Packages/Localization/Sources/Localization/Localization.swift @@ -15,7 +15,7 @@ public struct Localization { public enum Titles: LocalizableByTable { public static var accounts = "Accounts".localized(table: table) - public static var bookmarks = "Bookmarks".localized(table: table) + @MainActor public static var bookmarks = "Bookmarks".localized(table: table) public static var charges = "Charges".localized(table: table) public static var loans = "Loans".localized(table: table) public static var more = "More".localized(table: table) @@ -39,7 +39,6 @@ public struct Localization { public enum Bookmarks: LocalizableByTable { - public static let deleteBookmarkButton = "delete bookmark button".localized(table: table) public static let emptyHint = "no bookmarks hint text".localized(table: table) public static let emptySearchResult = "empty search result hint text".localized(table: table) diff --git a/Packages/More/Sources/More/MoreSectionCoordinator.swift b/Packages/More/Sources/More/MoreSectionCoordinator.swift index 069dba1..d764095 100644 --- a/Packages/More/Sources/More/MoreSectionCoordinator.swift +++ b/Packages/More/Sources/More/MoreSectionCoordinator.swift @@ -30,6 +30,7 @@ public final class MoreSectionCoordinator: Coordinator { struct MoreList: View { @ObservedObject var viewModel: MoreSectionViewModel @State var path = NavigationPath() + @Environment(\.dataStackProvider) var dataStackProvider var body: some View { NavigationStack(path: $path) { @@ -47,13 +48,14 @@ struct MoreList: View { case .about: AboutSectionCoordinator().contentView case .accounts: - let listViewModel = AccountListViewModel(dataStackProvider: DataStackProvider.shared) { + let listViewModel = AccountListViewModel(dataStackProvider: dataStackProvider) { path.removeLast() } AccountList(viewModel: listViewModel) case .settings(let settingsService): - SettingsSectionCoordinator(settingsService: settingsService).contentView + SettingsSectionCoordinator(settingsService: settingsService, dataStackProvider: dataStackProvider) + .contentView case .openSource: OpenSourceSectionView() } diff --git a/Packages/NetworkShim/Sources/NetworkShim/AccountServiceProtocol.swift b/Packages/NetworkShim/Sources/NetworkShim/AccountServiceProtocol.swift index a05a2fa..772c43a 100644 --- a/Packages/NetworkShim/Sources/NetworkShim/AccountServiceProtocol.swift +++ b/Packages/NetworkShim/Sources/NetworkShim/AccountServiceProtocol.swift @@ -7,6 +7,7 @@ import Foundation +@MainActor public protocol AccountService { /// Removes all notifications related to loan expiration on all accounts func removeLoansNotifications() diff --git a/Packages/Persistence/Sources/Persistence/DataStackProvider.swift b/Packages/Persistence/Sources/Persistence/DataStackProvider.swift index 3178a36..8bb5eb3 100644 --- a/Packages/Persistence/Sources/Persistence/DataStackProvider.swift +++ b/Packages/Persistence/Sources/Persistence/DataStackProvider.swift @@ -136,8 +136,6 @@ extension DataStackProvider: SwiftOnlyDataStackProviding { } } - @MainActor public static let shared: DataStackProviding = DataStackProvider() - public func createAccount() throws -> EDAccount { guard let persistentContainer = dataModelStack.persistentContainer else { throw DataStackProviderError.persistentStoreNotConfigured diff --git a/Packages/Persistence/Sources/Persistence/Loan/Loan+Serialization.swift b/Packages/Persistence/Sources/Persistence/Loan/Loan+Serialization.swift index 4889e5c..68269d5 100644 --- a/Packages/Persistence/Sources/Persistence/Loan/Loan+Serialization.swift +++ b/Packages/Persistence/Sources/Persistence/Loan/Loan+Serialization.swift @@ -12,13 +12,19 @@ import LibraryCore public struct LoanSerializer { - public init() {} + let dataStackProvider: DataStackProviding + + public init(dataStackProvider: DataStackProviding) { + self.dataStackProvider = dataStackProvider + } // TODO: add tests public func loansHash(for accountId: NSManagedObjectID) async throws -> String { var account: EDAccount? - let moc = DataStackProvider.shared.backgroundManagedObjectContext + let moc = await MainActor.run { + dataStackProvider.backgroundManagedObjectContext + } await moc.perform { account = moc.object(with: accountId) as? EDAccount @@ -44,7 +50,9 @@ public struct LoanSerializer { // TODO: add tests public func loansBarcodes(for accountId: NSManagedObjectID) async throws -> [String] { var account: EDAccount? - let moc = DataStackProvider.shared.backgroundManagedObjectContext + let moc = await MainActor.run { + dataStackProvider.backgroundManagedObjectContext + } await moc.perform { account = moc.object(with: accountId) as? EDAccount diff --git a/Packages/Persistence/Sources/Persistence/PersistentContainer.swift b/Packages/Persistence/Sources/Persistence/PersistentContainer.swift index 3c72baf..6f3ac2a 100644 --- a/Packages/Persistence/Sources/Persistence/PersistentContainer.swift +++ b/Packages/Persistence/Sources/Persistence/PersistentContainer.swift @@ -7,6 +7,7 @@ // import CoreData +import SwiftUI import LibraryCore import Localization @@ -17,6 +18,70 @@ public enum PersistenceBundle { } } +public extension EnvironmentValues { + var dataStackProvider: DataStackProviding { + get { self[DataStackProviderEnvironmentKey.self] } + set { self[DataStackProviderEnvironmentKey.self] = newValue } + } +} + +public struct DataStackProviderEnvironmentKey: EnvironmentKey { + public static var defaultValue: DataStackProviding = NoOpDataStackProvider() +} + +class NoOpDataStackProvider: DataStackProviding { + var foregroundManagedObjectContext: NSManagedObjectContext { + fatalError() + } + + var backgroundManagedObjectContext: NSManagedObjectContext { + fatalError() + } + + var persistentContainer: PersistentContainer? + + func load(_ completion: @escaping () -> Void) { + } + + func loadInMemory() { + } + + func resetStore() { + } + + func newAccount() async throws -> EDAccount { + fatalError() + } + + func createAccount() throws -> EDAccount { + fatalError() + } + + func accounts(in context: NSManagedObjectContext) async throws -> [NSManagedObjectID] { + fatalError() + } + + func loan(for barcode: String, in context: NSManagedObjectContext) async throws -> NSManagedObjectID { + fatalError() + } + + func activeAccounts(in context: NSManagedObjectContext) async throws(LibraryCore.PaperErrorInternal) -> [NSManagedObjectID] { + fatalError() + } + + func nextReturnDate(in context: NSManagedObjectContext) async -> Date? { + fatalError() + } + + func overallNumberOfLoans(in context: NSManagedObjectContext) async -> Int { + fatalError() + } + + func items(in context: NSManagedObjectContext, renewableOnly: Bool, fetchLimit: Int) async -> [LibraryCore.Item] { + fatalError() + } +} + /// The CoreData entity name let ENTITY_NAME_ACCOUNT = "Account" @@ -25,11 +90,11 @@ let ENTITY_NAME_LIBRARY = "Library" let DEFAULT_LIBRARY_IDENTIFIER = "Hamburg" -public class PersistentContainer: NSPersistentContainer { +public class PersistentContainer: NSPersistentContainer, @unchecked Sendable { func newAccount(context: NSManagedObjectContext) async throws -> EDAccount { var result: EDAccount! - let libraries = try await self.libraries(in: context) + let libraries = try await self.libraries(in: context) await context.perform { let account = EDAccount(context: context) @@ -47,7 +112,7 @@ public class PersistentContainer: NSPersistentContainer { do { try context.save() } catch { - + } result = account } @@ -86,11 +151,12 @@ extension PersistentContainer { } -/// Libraries -extension PersistentContainer { +// MARK: - Libraries + +public extension PersistentContainer { @MainActor - public func libraries(in context: NSManagedObjectContext) throws -> [Library] { + func libraries(in context: NSManagedObjectContext) throws -> [Library] { let fetchRequest = NSFetchRequest(entityName: ENTITY_NAME_LIBRARY) @@ -110,7 +176,7 @@ extension PersistentContainer { - Parameter completion: The completion with a result */ - public func libraries(completion: @escaping (Result<[L], Error>) -> Void) { + func libraries(completion: @escaping (Result<[L], Error>) -> Void) { let fetchRequest = NSFetchRequest(entityName: ENTITY_NAME_LIBRARY) let predicate = NSPredicate(format: "self.identifier != %@ AND self.name != ''", argumentArray: [DEFAULT_LIBRARY_IDENTIFIER]) diff --git a/Packages/Search/Sources/Search/Search Result/SearchResultCoordinator.swift b/Packages/Search/Sources/Search/Search Result/SearchResultCoordinator.swift index fbfdffa..3cabe8e 100644 --- a/Packages/Search/Sources/Search/Search Result/SearchResultCoordinator.swift +++ b/Packages/Search/Sources/Search/Search Result/SearchResultCoordinator.swift @@ -10,18 +10,22 @@ import SwiftUI import ArchitectureX import LibraryCore +import Persistence + class SearchResultCoordinator: Coordinator { var router: Router? private let result: SearchResultListItemModel private let detailsProvider: SearchResultDetailsProviding + private let dataStackProvider: DataStackProviding - init(result: SearchResultListItemModel, detailsProvider: SearchResultDetailsProviding) { + init(result: SearchResultListItemModel, detailsProvider: SearchResultDetailsProviding, dataStackProvider: DataStackProviding) { self.result = result self.detailsProvider = detailsProvider + self.dataStackProvider = dataStackProvider } var contentView: some View { - SearchResultDetailView(viewModel: SearchResultInfoViewModel(coordinator: self, result: result, detailsProvider: detailsProvider)) + SearchResultDetailView(viewModel: SearchResultInfoViewModel(coordinator: self, result: result, detailsProvider: detailsProvider, dataStackProvider: dataStackProvider)) } } diff --git a/Packages/Search/Sources/Search/Search Result/SearchResultDetailView.swift b/Packages/Search/Sources/Search/Search Result/SearchResultDetailView.swift index 656e1a7..f0daf95 100644 --- a/Packages/Search/Sources/Search/Search Result/SearchResultDetailView.swift +++ b/Packages/Search/Sources/Search/Search Result/SearchResultDetailView.swift @@ -164,6 +164,8 @@ struct SearchResultDetailView: View { } #if DEBUG +import Persistence + struct SearchResultDetail_Previews: PreviewProvider { static var previews: some View { @@ -177,7 +179,7 @@ struct SearchResultDetail_Previews: PreviewProvider { ) return NavigationView { - SearchResultDetailView(viewModel: SearchResultInfoViewModel(result: result, detailsProvider: MockSearchResultDetailsProviding())) + SearchResultDetailView(viewModel: SearchResultInfoViewModel(result: result, detailsProvider: MockSearchResultDetailsProviding(), dataStackProvider: DataStackProvider())) } } } diff --git a/Packages/Search/Sources/Search/Search Result/SearchResultDetailViewModel.swift b/Packages/Search/Sources/Search/Search Result/SearchResultDetailViewModel.swift index f1176fd..f69c28b 100644 --- a/Packages/Search/Sources/Search/Search Result/SearchResultDetailViewModel.swift +++ b/Packages/Search/Sources/Search/Search Result/SearchResultDetailViewModel.swift @@ -21,16 +21,23 @@ final class SearchResultInfoViewModel: ObservableObject { private let detailsProvider: SearchResultDetailsProviding private var coordinator: SearchResultCoordinator? private var loadDetailsTask: Task? + private let dataStackProvider: DataStackProviding var availabilityStatus: AvailabilityStatus { detailsProvider.status(availabilities: details?.availability.availabilities ?? [], in: result.library) } - init(coordinator: SearchResultCoordinator? = nil, result: SearchResultListItemModel, detailsProvider: SearchResultDetailsProviding) { + init( + coordinator: SearchResultCoordinator? = nil, + result: SearchResultListItemModel, + detailsProvider: SearchResultDetailsProviding, + dataStackProvider: DataStackProviding + ) { self.coordinator = coordinator self.result = result self.detailsProvider = detailsProvider self.bookmarkState = .loading + self.dataStackProvider = dataStackProvider defer { Task { @@ -45,13 +52,13 @@ final class SearchResultInfoViewModel: ObservableObject { } @MainActor func updateBookmarkState() { - let controller = BookmarkService(managedObjectContext: DataStackProvider.shared.foregroundManagedObjectContext) + let controller = BookmarkService(managedObjectContext: dataStackProvider.foregroundManagedObjectContext) let bookmarked = controller.hasBookmark(identifier: result.number, title: self.result.title) bookmarkState = .bookmarked(bookmarked) } @MainActor func toggleBookmark() { - let controller = BookmarkService(managedObjectContext: DataStackProvider.shared.foregroundManagedObjectContext) + let controller = BookmarkService(managedObjectContext: dataStackProvider.foregroundManagedObjectContext) do { if case .bookmarked(let bookmark) = bookmarkState { diff --git a/Packages/Search/Sources/Search/SearchDependencies.swift b/Packages/Search/Sources/Search/SearchDependencies.swift index 6935342..2b4b682 100644 --- a/Packages/Search/Sources/Search/SearchDependencies.swift +++ b/Packages/Search/Sources/Search/SearchDependencies.swift @@ -10,14 +10,18 @@ import Foundation import Utilities import LibraryCore +import Persistence + public class SearchDependencies { let databaseConnection: DatabaseConnection? let searchProvider: any SearchScraping let detailsProvider: any SearchResultDetailsProviding + let dataStackProvider: DataStackProviding - public init(databaseConnection: DatabaseConnection?, searchProvider: any SearchScraping, detailsProvider: any SearchResultDetailsProviding) { + public init(databaseConnection: DatabaseConnection?, searchProvider: any SearchScraping, detailsProvider: any SearchResultDetailsProviding, dataStackProvider: DataStackProviding) { self.databaseConnection = databaseConnection self.searchProvider = searchProvider self.detailsProvider = detailsProvider + self.dataStackProvider = dataStackProvider } } diff --git a/Packages/Search/Sources/Search/SearchSectionViewModel.swift b/Packages/Search/Sources/Search/SearchSectionViewModel.swift index 4dda9d9..b35c48e 100644 --- a/Packages/Search/Sources/Search/SearchSectionViewModel.swift +++ b/Packages/Search/Sources/Search/SearchSectionViewModel.swift @@ -150,7 +150,7 @@ class SearchSectionViewModel: ObservableObject { } @MainActor func show(result: SearchResultListItemModel) { - let detailCoordinator = SearchResultCoordinator(result: result, detailsProvider: dependencies.detailsProvider) + let detailCoordinator = SearchResultCoordinator(result: result, detailsProvider: dependencies.detailsProvider, dataStackProvider: dependencies.dataStackProvider) coordinator.transition(to: detailCoordinator, style: .present(modalInPresentation: false)) } diff --git a/Packages/Search/Sources/Search/Views/SearchSectionView.swift b/Packages/Search/Sources/Search/Views/SearchSectionView.swift index fdb8ad8..115b3fa 100644 --- a/Packages/Search/Sources/Search/Views/SearchSectionView.swift +++ b/Packages/Search/Sources/Search/Views/SearchSectionView.swift @@ -19,6 +19,7 @@ struct SearchSectionView: View { @ObservedObject var viewModel: SearchSectionViewModel @FocusState private var removeFocusFromSearchBar: Bool + @Environment(\.dataStackProvider) private var dataStackProvider @ViewBuilder var content: some View { switch viewModel.state { @@ -53,7 +54,7 @@ struct SearchSectionView: View { } .sheet(isPresented: $viewModel.isShowingLibrarySelection, content: { NavigationView { - LibrarySelectionCoordinator(for: .search, currentlySelected: viewModel.library.identifier, librarySelection: viewModel.onLibrarySelected) + LibrarySelectionCoordinator(for: .search, persistentContainer: dataStackProvider.persistentContainer, currentlySelected: viewModel.library.identifier, librarySelection: viewModel.onLibrarySelected) .contentView .navigationTitle(Text("LIBRARIES", tableName: "Applicationwide")) .toolbar { diff --git a/Packages/Utilities/Sources/Utilities/BookmarkService.swift b/Packages/Utilities/Sources/Utilities/BookmarkService.swift index 43e3006..83ad9ca 100644 --- a/Packages/Utilities/Sources/Utilities/BookmarkService.swift +++ b/Packages/Utilities/Sources/Utilities/BookmarkService.swift @@ -17,7 +17,7 @@ extension NSNotification.Name { static let bookmarkChanged = Notification.Name("bookmarkChanged") } -public class BookmarkService: NSObject, BookmarkServicing { +@MainActor public class BookmarkService: NSObject, BookmarkServicing { public func isBookmarked(identifier: String?, title: String?) async throws -> Bool { hasBookmark(identifier: identifier, title: title) diff --git a/Packages/Utilities/Sources/Utilities/DatabaseConnection.swift b/Packages/Utilities/Sources/Utilities/DatabaseConnection.swift index d2b3dac..dcc8889 100644 --- a/Packages/Utilities/Sources/Utilities/DatabaseConnection.swift +++ b/Packages/Utilities/Sources/Utilities/DatabaseConnection.swift @@ -17,6 +17,7 @@ public struct DatabaseConnectionDependencies { let accountService: AccountServiceProviding let authenticationManager: AuthenticationManaging let credentialStore: AccountCredentialStoring + let dataStackProvider: DataStackProviding } public struct DatabaseConnection: LoanBackendServicing { @@ -47,9 +48,9 @@ public struct DatabaseConnection: LoanBackendServicing { public func renew(loan: any LibraryCore.Renewable) async throws -> Result { - let loanManagedObjectId = try await DataStackProvider.shared.loan(for: loan.barcode, in: context) + let loanManagedObjectId = try await dependencies.dataStackProvider.loan(for: loan.barcode, in: context) - guard let loan = DataStackProvider.shared.foregroundManagedObjectContext.object(with: loanManagedObjectId) as? Persistence.Loan, let account = loan.loanAccount else { + guard let loan = dependencies.dataStackProvider.foregroundManagedObjectContext.object(with: loanManagedObjectId) as? Persistence.Loan, let account = loan.loanAccount else { assertionFailure("don't get here") throw RenewalError.unexpectedError } diff --git a/Packages/Utilities/Sources/Utilities/DatabaseConnectionFactory.swift b/Packages/Utilities/Sources/Utilities/DatabaseConnectionFactory.swift index 9613183..4630677 100644 --- a/Packages/Utilities/Sources/Utilities/DatabaseConnectionFactory.swift +++ b/Packages/Utilities/Sources/Utilities/DatabaseConnectionFactory.swift @@ -11,12 +11,25 @@ import Foundation import LibraryCore import Networking +import Persistence + +public protocol DatabaseConnectionProducing { + func databaseConnection( + for managedObjectContext: NSManagedObjectContext, + accountService: AccountServiceProviding, + dataStackProvider: DataStackProviding + ) -> LoanBackendServicing +} public struct DatabaseConnectionFactory: DatabaseConnectionProducing { public init() {} - public func databaseConnection(for managedObjectContext: NSManagedObjectContext, accountService: AccountServiceProviding) -> LoanBackendServicing { + public func databaseConnection( + for managedObjectContext: NSManagedObjectContext, + accountService: AccountServiceProviding, + dataStackProvider: DataStackProviding + ) -> LoanBackendServicing { let keychainProvider = KeychainManager() let authenticationManager = AuthenticationManager(network: NetworkClient(), keychainManager: keychainProvider) let accountCredentialStore = AccountCredentialStore(keychainProvider: keychainProvider) @@ -26,7 +39,8 @@ public struct DatabaseConnectionFactory: DatabaseConnectionProducing { dependencies: DatabaseConnectionDependencies( accountService: accountService, authenticationManager: authenticationManager, - credentialStore: accountCredentialStore + credentialStore: accountCredentialStore, + dataStackProvider: dataStackProvider ) ) } diff --git a/Packages/Utilities/Sources/Utilities/Notifications/CoreSpotlightSynchronisation.swift b/Packages/Utilities/Sources/Utilities/Notifications/CoreSpotlightSynchronisation.swift index 533e76e..4292196 100644 --- a/Packages/Utilities/Sources/Utilities/Notifications/CoreSpotlightSynchronisation.swift +++ b/Packages/Utilities/Sources/Utilities/Notifications/CoreSpotlightSynchronisation.swift @@ -14,10 +14,13 @@ import Persistence public class CoreSpotlightSynchronisation: AppEventObserver { - public static private(set) var shared: CoreSpotlightSynchronisation = CoreSpotlightSynchronisation() - public var id: UUID = UUID() private let domainIdentifier = "Loans" + private let dataStackProvider: DataStackProviding + + public init(dataStackProvider: DataStackProviding) { + self.dataStackProvider = dataStackProvider + } public func handle(_ change: LibraryCore.AppEventPublisher.AppEvent) async throws { switch change { @@ -32,7 +35,7 @@ public class CoreSpotlightSynchronisation: AppEventObserver { private lazy var index = CSSearchableIndex.default() private func updateVocabulary(in context: NSManagedObjectContext) async throws { - let items = await DataStackProvider.shared.items(in: context, renewableOnly: false, fetchLimit: 1000) + let items = await dataStackProvider.items(in: context, renewableOnly: false, fetchLimit: 1000) let contents = items.map { let attributeSet = CSSearchableItemAttributeSet(contentType: .item) attributeSet.title = $0.title diff --git a/Packages/Utilities/Sources/Utilities/Notifications/NotificationSynchronisation.swift b/Packages/Utilities/Sources/Utilities/Notifications/NotificationSynchronisation.swift index f5775ff..ac25a2a 100644 --- a/Packages/Utilities/Sources/Utilities/Notifications/NotificationSynchronisation.swift +++ b/Packages/Utilities/Sources/Utilities/Notifications/NotificationSynchronisation.swift @@ -12,13 +12,11 @@ import Persistence public class NotificationSynchronisation: AppEventObserver { - @MainActor public static private(set) var shared = NotificationSynchronisation(scheduler: NotificationScheduler()) - private var disposeBag = Set() private let scheduler: NotificationScheduling - init(scheduler: NotificationScheduling) { + public init(scheduler: NotificationScheduling) { self.scheduler = scheduler } diff --git a/Packages/Utilities/Sources/Utilities/Widget/WidgetSynchronisation.swift b/Packages/Utilities/Sources/Utilities/Widget/WidgetSynchronisation.swift index 7e899c5..34b5164 100644 --- a/Packages/Utilities/Sources/Utilities/Widget/WidgetSynchronisation.swift +++ b/Packages/Utilities/Sources/Utilities/Widget/WidgetSynchronisation.swift @@ -14,7 +14,15 @@ import CoreData public final class WidgetSynchronisation: AppEventObserver { - public static private(set) var shared: WidgetSynchronisation = WidgetSynchronisation() + public var id: UUID + + private let dataStackProvider: DataStackProviding + private let storage = AppGroupStorage() + + public init(dataStackProvider: DataStackProviding, id: UUID = UUID()) { + self.dataStackProvider = dataStackProvider + self.id = id + } public var widgetState: WidgetState { get throws { @@ -23,9 +31,6 @@ public final class WidgetSynchronisation: AppEventObserver { } } - private let storage = AppGroupStorage() - - public var id = UUID() public func handle(_ change: AppEventPublisher.AppEvent) async throws { switch change { case .accountActivation(count: let count, activated: 0, _): @@ -57,13 +62,13 @@ public final class WidgetSynchronisation: AppEventObserver { } private func updateWidgetContent(didRefresh: Bool, in context: NSManagedObjectContext) async throws { - let nextReturnDate = await DataStackProvider.shared.nextReturnDate(in: context) + let nextReturnDate = await dataStackProvider.nextReturnDate(in: context) let lastUpdateDate: Date = didRefresh ? .now : UserDefaults.suite.latestSuccessfulAccountUpdateDate ?? .now - let overallNumberOfLoans = await DataStackProvider.shared.overallNumberOfLoans(in: context) - let numberOfAccounts = try await DataStackProvider.shared.activeAccounts(in: context).count + let overallNumberOfLoans = await dataStackProvider.overallNumberOfLoans(in: context) + let numberOfAccounts = try await dataStackProvider.activeAccounts(in: context).count // Limit elements displayed in a widget so that they all fit into the available vertical space // Limiting should probably happen within the view/view model of the widget since it knows better how much space is available - let items = await DataStackProvider.shared.items(in: context, renewableOnly: false, fetchLimit: 8) + let items = await dataStackProvider.items(in: context, renewableOnly: false, fetchLimit: 8) let viewModel = WidgetState.ContentViewModel(lastUpdate: lastUpdateDate, nextReturnDate: nextReturnDate, overallNumberOfLoans: overallNumberOfLoans, diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh old mode 100755 new mode 100644