Split and rename viewModels

This commit is contained in:
Balazs Perlaki-Horvath 2025-04-15 22:25:30 +02:00
parent 372cceccbb
commit 89ab9d7d42
10 changed files with 49 additions and 74 deletions

View File

@ -25,6 +25,7 @@ struct Kiwix: App {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@StateObject private var library = LibraryViewModel() @StateObject private var library = LibraryViewModel()
@StateObject private var selection = SelectedZimFileViewModel(isMultiSelection: false)
@StateObject private var navigation = NavigationViewModel() @StateObject private var navigation = NavigationViewModel()
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
private let fileMonitor: DirectoryMonitor private let fileMonitor: DirectoryMonitor
@ -53,6 +54,7 @@ struct Kiwix: App {
.ignoresSafeArea() .ignoresSafeArea()
.environment(\.managedObjectContext, Database.shared.viewContext) .environment(\.managedObjectContext, Database.shared.viewContext)
.environmentObject(library) .environmentObject(library)
.environmentObject(selection)
.environmentObject(navigation) .environmentObject(navigation)
.modifier(AlertHandler()) .modifier(AlertHandler())
.modifier(OpenFileHandler()) .modifier(OpenFileHandler())

View File

@ -182,8 +182,12 @@ struct RootView: View {
@State private var currentNavItem: MenuItem? @State private var currentNavItem: MenuItem?
@StateObject private var windowTracker = WindowTracker() @StateObject private var windowTracker = WindowTracker()
@State private var paymentButtonLabel: PayWithApplePayButtonLabel? @State private var paymentButtonLabel: PayWithApplePayButtonLabel?
<<<<<<< HEAD
@StateObject private var multiSelection = LibraryMultiSelectViewModel() @StateObject private var multiSelection = LibraryMultiSelectViewModel()
var isSearchFocused: FocusState<Bool>.Binding var isSearchFocused: FocusState<Bool>.Binding
=======
@StateObject private var selection = SelectedZimFileViewModel(isMultiSelection: false)
>>>>>>> f3ebab51 (Split and rename viewModels)
private let primaryItems: [MenuItem] = [.bookmarks] private let primaryItems: [MenuItem] = [.bookmarks]
private let libraryItems: [MenuItem] = [.opened, .categories, .downloads, .new] private let libraryItems: [MenuItem] = [.opened, .categories, .downloads, .new]
@ -231,9 +235,9 @@ struct RootView: View {
Bookmarks() Bookmarks()
.modifier(SearchFocused(isSearchFocused: isSearchFocused)) .modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .opened: case .opened:
ZimFilesOpened(dismiss: nil) let multiSelection = SelectedZimFileViewModel(isMultiSelection: true)
.environmentObject(multiSelection) ZimFilesOpened(selection: multiSelection, dismiss: nil)
.modifier(LibraryZimFileMultiSelectDetailSidePanel(viewModel: multiSelection)) .modifier(LibraryZimFileMultiSelectDetailSidePanel(selection: multiSelection))
case .categories: case .categories:
ZimFilesCategories(dismiss: nil) ZimFilesCategories(dismiss: nil)
.modifier(LibraryZimFileDetailSidePanel()) .modifier(LibraryZimFileDetailSidePanel())

View File

@ -46,7 +46,7 @@ enum LibraryState {
@MainActor @MainActor
final class SelectedZimFileViewModel: ObservableObject { final class SelectedZimFileViewModel: ObservableObject {
let isMultiSelection: Bool let isMultiSelection: Bool
@Published private(set) var selectedZimFile: ZimFile? @Published var selectedZimFile: ZimFile?
@Published private(set) var selectedZimFiles = Set<ZimFile>() @Published private(set) var selectedZimFiles = Set<ZimFile>()
init(isMultiSelection: Bool) { init(isMultiSelection: Bool) {
@ -70,7 +70,7 @@ final class SelectedZimFileViewModel: ObservableObject {
selectedZimFiles = Set([zimFile]) selectedZimFiles = Set([zimFile])
} }
func resetSelection() { func reset() {
selectedZimFile = nil selectedZimFile = nil
selectedZimFiles.removeAll() selectedZimFiles.removeAll()
} }
@ -83,31 +83,7 @@ final class SelectedZimFileViewModel: ObservableObject {
} }
} }
final class LibraryMultiSelectViewModel: ObservableObject {
@Published private(set) var selectedZimFiles = Set<ZimFile>()
@MainActor
func toggleMultiSelect(of zimFile: ZimFile) {
if selectedZimFiles.contains(zimFile) {
selectedZimFiles.remove(zimFile)
} else {
selectedZimFiles.insert(zimFile)
}
}
@MainActor
func singleSelect(zimFile: ZimFile) {
selectedZimFiles = Set([zimFile])
}
@MainActor
func resetSelection() {
selectedZimFiles.removeAll()
}
}
final class LibraryViewModel: ObservableObject { final class LibraryViewModel: ObservableObject {
@Published var selectedZimFile: ZimFile?
@MainActor @Published private(set) var error: Error? @MainActor @Published private(set) var error: Error?
/// Note: due to multiple instances of LibraryViewModel, /// Note: due to multiple instances of LibraryViewModel,
/// this `state` should not be changed directly, modify the `process.state` instead /// this `state` should not be changed directly, modify the `process.state` instead

View File

@ -96,7 +96,7 @@ struct Library_Previews: PreviewProvider {
/// On macOS, adds a panel to the right of the modified view to show zim file detail. /// On macOS, adds a panel to the right of the modified view to show zim file detail.
struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier { struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
@ObservedObject var viewModel: LibraryMultiSelectViewModel @ObservedObject var selection: SelectedZimFileViewModel
@State private var isPresentingUnlinkAlert: Bool = false @State private var isPresentingUnlinkAlert: Bool = false
func body(content: Content) -> some View { func body(content: Content) -> some View {
@ -105,22 +105,22 @@ struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
content.safeAreaInset(edge: .trailing, spacing: 0) { content.safeAreaInset(edge: .trailing, spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
Divider() Divider()
switch viewModel.selectedZimFiles.count { switch selection.selectedZimFiles.count {
case 0: case 0:
Message(text: LocalString.library_zim_file_details_side_panel_message) Message(text: LocalString.library_zim_file_details_side_panel_message)
.background(.thickMaterial) .background(.thickMaterial)
case 1: case 1:
ZimFileDetail(zimFile: viewModel.selectedZimFiles.first!, dismissParent: nil) ZimFileDetail(zimFile: selection.selectedZimFiles.first!, dismissParent: nil)
default: default:
Action(title: LocalString.zim_file_action_unlink_title, isDestructive: true) { Action(title: LocalString.zim_file_action_unlink_title, isDestructive: true) {
isPresentingUnlinkAlert = true isPresentingUnlinkAlert = true
}.alert(isPresented: $isPresentingUnlinkAlert) { }.alert(isPresented: $isPresentingUnlinkAlert) {
Alert( Alert(
title: Text(LocalString.zim_file_action_unlink_title + " " + "\(viewModel.selectedZimFiles.count)"), title: Text(LocalString.zim_file_action_unlink_title + " " + "\(selection.selectedZimFiles.count)"),
message: Text(LocalString.zim_file_action_unlink_message), message: Text(LocalString.zim_file_action_unlink_message),
primaryButton: .destructive(Text(LocalString.zim_file_action_unlink_button_title)) { primaryButton: .destructive(Text(LocalString.zim_file_action_unlink_button_title)) {
Task { Task {
for zimFile in viewModel.selectedZimFiles { for zimFile in selection.selectedZimFiles {
await LibraryOperations.unlink(zimFileID: zimFile.fileID) await LibraryOperations.unlink(zimFileID: zimFile.fileID)
} }
} }
@ -131,14 +131,14 @@ struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
} }
}.frame(width: 275).background(.ultraThinMaterial) }.frame(width: 275).background(.ultraThinMaterial)
} }
}.onAppear { viewModel.resetSelection() } }.onAppear { selection.reset() }
} }
} }
/// On macOS, adds a panel to the right of the modified view to show zim file detail. /// On macOS, adds a panel to the right of the modified view to show zim file detail.
struct LibraryZimFileDetailSidePanel: ViewModifier { struct LibraryZimFileDetailSidePanel: ViewModifier {
@EnvironmentObject private var viewModel: LibraryViewModel @EnvironmentObject private var selection: SelectedZimFileViewModel
func body(content: Content) -> some View { func body(content: Content) -> some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -146,7 +146,7 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
content.safeAreaInset(edge: .trailing, spacing: 0) { content.safeAreaInset(edge: .trailing, spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
Divider() Divider()
if let zimFile = viewModel.selectedZimFile { if let zimFile = selection.selectedZimFile {
ZimFileDetail(zimFile: zimFile, dismissParent: nil) ZimFileDetail(zimFile: zimFile, dismissParent: nil)
} else { } else {
Message(text: LocalString.library_zim_file_details_side_panel_message) Message(text: LocalString.library_zim_file_details_side_panel_message)
@ -154,7 +154,7 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
} }
}.frame(width: 275).background(.ultraThinMaterial) }.frame(width: 275).background(.ultraThinMaterial)
} }
}.onAppear { viewModel.selectedZimFile = nil } }.onAppear { selection.selectedZimFile = nil }
} }
} }
#endif #endif
@ -162,41 +162,39 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
/// On macOS, converts the modified view to a Button that modifies the currently selected zim file /// On macOS, converts the modified view to a Button that modifies the currently selected zim file
/// On iOS, converts the modified view to a NavigationLink that goes to the zim file detail. /// On iOS, converts the modified view to a NavigationLink that goes to the zim file detail.
struct LibraryZimFileContext<Content: View>: View { struct LibraryZimFileContext<Content: View>: View {
@EnvironmentObject private var viewModel: LibraryViewModel @ObservedObject var selection: SelectedZimFileViewModel
private let content: Content private let content: Content
private let zimFile: ZimFile private let zimFile: ZimFile
/// iOS only /// iOS only
private let dismiss: (() -> Void)? private let dismiss: (() -> Void)?
/// macOS only
private let onMultiSelected: ((Bool) -> Void)?
init( init(
@ViewBuilder content: () -> Content, @ViewBuilder content: () -> Content,
zimFile: ZimFile, zimFile: ZimFile,
onMultiSelected: ((Bool) -> Void)? = nil, selection: SelectedZimFileViewModel,
dismiss: (() -> Void)? = nil dismiss: (() -> Void)? = nil
) { ) {
self.content = content() self.content = content()
self.zimFile = zimFile self.zimFile = zimFile
self.onMultiSelected = onMultiSelected self.selection = selection
self.dismiss = dismiss self.dismiss = dismiss
} }
var body: some View { var body: some View {
Group { Group {
#if os(macOS) #if os(macOS)
if let onMultiSelected { if selection.isMultiSelection {
content content
.gesture(TapGesture().modifiers(.command).onEnded({ value in .gesture(TapGesture().modifiers(.command).onEnded({ value in
onMultiSelected(true) selection.toggleMultiSelect(of: zimFile)
})) }))
.gesture(TapGesture().onEnded({ _ in .gesture(TapGesture().onEnded({ _ in
onMultiSelected(false) selection.singleSelect(zimFile: zimFile)
})) }))
} else { } else {
content.onTapGesture { content.onTapGesture {
viewModel.selectedZimFile = zimFile selection.singleSelect(zimFile: zimFile)
} }
} }
#elseif os(iOS) #elseif os(iOS)

View File

@ -282,14 +282,14 @@ private struct FileLocator: ViewModifier {
private struct DownloadTaskDetail: View { private struct DownloadTaskDetail: View {
@ObservedObject var downloadZimFile: ZimFile @ObservedObject var downloadZimFile: ZimFile
@EnvironmentObject var viewModel: LibraryViewModel @EnvironmentObject var selection: SelectedZimFileViewModel
@State private var downloadState = DownloadState.empty() @State private var downloadState = DownloadState.empty()
var body: some View { var body: some View {
Group { Group {
Action(title: LocalString.zim_file_download_task_action_title_cancel, isDestructive: true) { Action(title: LocalString.zim_file_download_task_action_title_cancel, isDestructive: true) {
DownloadService.shared.cancel(zimFileID: downloadZimFile.fileID) DownloadService.shared.cancel(zimFileID: downloadZimFile.fileID)
viewModel.selectedZimFile = nil selection.reset()
} }
if let error = downloadZimFile.downloadTask?.error { if let error = downloadZimFile.downloadTask?.error {
if downloadState.resumeData != nil { if downloadState.resumeData != nil {

View File

@ -105,6 +105,7 @@ private struct CategoryGrid: View {
@Binding var searchText: String @Binding var searchText: String
@Default(.libraryLanguageCodes) private var languageCodes @Default(.libraryLanguageCodes) private var languageCodes
@EnvironmentObject private var viewModel: LibraryViewModel @EnvironmentObject private var viewModel: LibraryViewModel
@EnvironmentObject private var selection: SelectedZimFileViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@SectionedFetchRequest private var sections: SectionedFetchResults<String, ZimFile> @SectionedFetchRequest private var sections: SectionedFetchResults<String, ZimFile>
private let dismiss: (() -> Void)? // iOS only private let dismiss: (() -> Void)? // iOS only
@ -142,6 +143,7 @@ private struct CategoryGrid: View {
LibraryZimFileContext( LibraryZimFileContext(
content: { ZimFileCell(zimFile, prominent: .size) }, content: { ZimFileCell(zimFile, prominent: .size) },
zimFile: zimFile, zimFile: zimFile,
selection: selection,
dismiss: dismiss) dismiss: dismiss)
} }
} else { } else {
@ -150,6 +152,7 @@ private struct CategoryGrid: View {
LibraryZimFileContext( LibraryZimFileContext(
content: { ZimFileCell(zimFile, prominent: .size) }, content: { ZimFileCell(zimFile, prominent: .size) },
zimFile: zimFile, zimFile: zimFile,
selection: selection,
dismiss: dismiss) dismiss: dismiss)
} }
} header: { } header: {
@ -173,7 +176,7 @@ private struct CategoryGrid: View {
} }
} }
.searchable(text: $searchText) .searchable(text: $searchText)
.onChange(of: category) { _ in viewModel.selectedZimFile = nil } .onChange(of: category) { _ in selection.reset() }
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
sections.nsPredicate = ZimFilesCategory.buildPredicate(category: category, searchText: searchText) sections.nsPredicate = ZimFilesCategory.buildPredicate(category: category, searchText: searchText)
} }
@ -211,6 +214,7 @@ private struct CategoryList: View {
@Binding var searchText: String @Binding var searchText: String
@Default(.libraryLanguageCodes) private var languageCodes @Default(.libraryLanguageCodes) private var languageCodes
@EnvironmentObject private var viewModel: LibraryViewModel @EnvironmentObject private var viewModel: LibraryViewModel
@EnvironmentObject private var selection: SelectedZimFileViewModel
@FetchRequest private var zimFiles: FetchedResults<ZimFile> @FetchRequest private var zimFiles: FetchedResults<ZimFile>
private let dismiss: (() -> Void)? private let dismiss: (() -> Void)?
@ -247,10 +251,11 @@ private struct CategoryList: View {
Message(text: LocalString.zim_file_category_section_empty_message) Message(text: LocalString.zim_file_category_section_empty_message)
} }
} else { } else {
List(zimFiles, id: \.self, selection: $viewModel.selectedZimFile) { zimFile in List(zimFiles, id: \.self, selection: $selection.selectedZimFile) { zimFile in
LibraryZimFileContext( LibraryZimFileContext(
content: { ZimFileRow(zimFile) }, content: { ZimFileRow(zimFile) },
zimFile: zimFile, zimFile: zimFile,
selection: selection,
dismiss: dismiss) dismiss: dismiss)
} }
#if os(macOS) #if os(macOS)
@ -261,7 +266,7 @@ private struct CategoryList: View {
} }
} }
.searchable(text: $searchText) .searchable(text: $searchText)
.onChange(of: category) { _ in viewModel.selectedZimFile = nil } .onChange(of: category) { _ in selection.reset() }
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
zimFiles.nsPredicate = ZimFilesCategory.buildPredicate(category: category, searchText: searchText) zimFiles.nsPredicate = ZimFilesCategory.buildPredicate(category: category, searchText: searchText)
} }

View File

@ -18,6 +18,7 @@ import SwiftUI
/// A grid of zim files that are being downloaded. /// A grid of zim files that are being downloaded.
struct ZimFilesDownloads: View { struct ZimFilesDownloads: View {
@EnvironmentObject var selection: SelectedZimFileViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest( @FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \DownloadTask.created, ascending: false)], sortDescriptors: [NSSortDescriptor(keyPath: \DownloadTask.created, ascending: false)],
@ -39,6 +40,7 @@ struct ZimFilesDownloads: View {
LibraryZimFileContext( LibraryZimFileContext(
content: { DownloadTaskCell(zimFile) }, content: { DownloadTaskCell(zimFile) },
zimFile: zimFile, zimFile: zimFile,
selection: selection,
dismiss: dismiss) dismiss: dismiss)
} }
} }

View File

@ -97,6 +97,7 @@ private final class ViewModel: ObservableObject {
/// A grid of zim files that are newly available. /// A grid of zim files that are newly available.
struct ZimFilesNew: View { struct ZimFilesNew: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var selection: SelectedZimFileViewModel
@EnvironmentObject var library: LibraryViewModel @EnvironmentObject var library: LibraryViewModel
@Default(.libraryLanguageCodes) private var languageCodes @Default(.libraryLanguageCodes) private var languageCodes
@StateObject private var viewModel = ViewModel() @StateObject private var viewModel = ViewModel()
@ -115,6 +116,7 @@ struct ZimFilesNew: View {
ZimFileCell(zimFile, prominent: .name) ZimFileCell(zimFile, prominent: .name)
}, },
zimFile: zimFile, zimFile: zimFile,
selection: selection,
dismiss: dismiss) dismiss: dismiss)
.transition(AnyTransition.opacity) .transition(AnyTransition.opacity)
} }

View File

@ -25,7 +25,7 @@ struct ZimFilesOpened: View {
animation: .easeInOut animation: .easeInOut
) private var zimFiles: FetchedResults<ZimFile> ) private var zimFiles: FetchedResults<ZimFile>
@State private var isFileImporterPresented = false @State private var isFileImporterPresented = false
@EnvironmentObject private var viewModel: SelectedZimFileViewModel @ObservedObject var selection: SelectedZimFileViewModel
let dismiss: (() -> Void)? // iOS only let dismiss: (() -> Void)? // iOS only
var body: some View { var body: some View {
@ -35,28 +35,16 @@ struct ZimFilesOpened: View {
spacing: 12 spacing: 12
) { ) {
ForEach(zimFiles) { zimFile in ForEach(zimFiles) { zimFile in
let multiSelected: ((Bool) -> Void)? = if viewModel.isMultiSelection {
{ [zimFile] isSelectionMulti in
if isSelectionMulti {
viewModel.toggleMultiSelect(of: zimFile)
} else {
viewModel.singleSelect(zimFile: zimFile)
}
}
} else {
nil
}
LibraryZimFileContext( LibraryZimFileContext(
content: { content: {
ZimFileCell( ZimFileCell(
zimFile, zimFile,
prominent: .name, prominent: .name,
isSelected: viewModel.isSelected(zimFile) isSelected: selection.isSelected(zimFile)
) )
}, },
zimFile: zimFile, zimFile: zimFile,
onMultiSelected: multiSelected, selection: selection,
dismiss: dismiss) dismiss: dismiss)
} }
} }
@ -70,9 +58,9 @@ struct ZimFilesOpened: View {
} }
.onChange(of: zimFiles.count) { _ in .onChange(of: zimFiles.count) { _ in
if let firstZimFile = zimFiles.first { if let firstZimFile = zimFiles.first {
viewModel.singleSelect(zimFile: firstZimFile) selection.singleSelect(zimFile: firstZimFile)
} else { } else {
viewModel.resetSelection() selection.reset()
} }
} }
// not using OpenFileButton here, because it does not work on iOS/iPadOS 15 when this view is in a modal // not using OpenFileButton here, because it does not work on iOS/iPadOS 15 when this view is in a modal

View File

@ -18,19 +18,17 @@ import SwiftUI
enum CellBackground { enum CellBackground {
#if os(macOS) #if os(macOS)
private static let normal: Color = Color(nsColor: NSColor.controlBackgroundColor) private static let normal: Color = Color(nsColor: NSColor.controlBackgroundColor)
private static let hover: Color = Color(nsColor: NSColor.selectedControlColor)
private static let selected: Color = Color(nsColor: NSColor.selectedControlColor) private static let selected: Color = Color(nsColor: NSColor.selectedControlColor)
private static let hoverSelected: Color = Color(nsColor: NSColor.selectedControlColor)
#else #else
private static let normal: Color = .secondaryBackground private static let normal: Color = .secondaryBackground
private static let hover: Color = .tertiaryBackground private static let selected: Color = .tertiaryBackground
#endif #endif
static func colorFor(isHovering: Bool, isSelected: Bool = false) -> Color { static func colorFor(isHovering: Bool, isSelected: Bool = false) -> Color {
if isSelected { if isSelected {
isHovering ? hoverSelected : selected isHovering ? selected.opacity(0.75) : selected
} else { } else {
isHovering ? hover : normal isHovering ? selected.opacity(0.5) : normal
} }
} }