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

View File

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

View File

@ -46,7 +46,7 @@ enum LibraryState {
@MainActor
final class SelectedZimFileViewModel: ObservableObject {
let isMultiSelection: Bool
@Published private(set) var selectedZimFile: ZimFile?
@Published var selectedZimFile: ZimFile?
@Published private(set) var selectedZimFiles = Set<ZimFile>()
init(isMultiSelection: Bool) {
@ -70,7 +70,7 @@ final class SelectedZimFileViewModel: ObservableObject {
selectedZimFiles = Set([zimFile])
}
func resetSelection() {
func reset() {
selectedZimFile = nil
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 {
@Published var selectedZimFile: ZimFile?
@MainActor @Published private(set) var error: Error?
/// Note: due to multiple instances of LibraryViewModel,
/// 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.
struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
@ObservedObject var viewModel: LibraryMultiSelectViewModel
@ObservedObject var selection: SelectedZimFileViewModel
@State private var isPresentingUnlinkAlert: Bool = false
func body(content: Content) -> some View {
@ -105,22 +105,22 @@ struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
content.safeAreaInset(edge: .trailing, spacing: 0) {
HStack(spacing: 0) {
Divider()
switch viewModel.selectedZimFiles.count {
switch selection.selectedZimFiles.count {
case 0:
Message(text: LocalString.library_zim_file_details_side_panel_message)
.background(.thickMaterial)
case 1:
ZimFileDetail(zimFile: viewModel.selectedZimFiles.first!, dismissParent: nil)
ZimFileDetail(zimFile: selection.selectedZimFiles.first!, dismissParent: nil)
default:
Action(title: LocalString.zim_file_action_unlink_title, isDestructive: true) {
isPresentingUnlinkAlert = true
}.alert(isPresented: $isPresentingUnlinkAlert) {
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),
primaryButton: .destructive(Text(LocalString.zim_file_action_unlink_button_title)) {
Task {
for zimFile in viewModel.selectedZimFiles {
for zimFile in selection.selectedZimFiles {
await LibraryOperations.unlink(zimFileID: zimFile.fileID)
}
}
@ -131,14 +131,14 @@ struct LibraryZimFileMultiSelectDetailSidePanel: ViewModifier {
}
}.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.
struct LibraryZimFileDetailSidePanel: ViewModifier {
@EnvironmentObject private var viewModel: LibraryViewModel
@EnvironmentObject private var selection: SelectedZimFileViewModel
func body(content: Content) -> some View {
VStack(spacing: 0) {
@ -146,7 +146,7 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
content.safeAreaInset(edge: .trailing, spacing: 0) {
HStack(spacing: 0) {
Divider()
if let zimFile = viewModel.selectedZimFile {
if let zimFile = selection.selectedZimFile {
ZimFileDetail(zimFile: zimFile, dismissParent: nil)
} else {
Message(text: LocalString.library_zim_file_details_side_panel_message)
@ -154,7 +154,7 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
}
}.frame(width: 275).background(.ultraThinMaterial)
}
}.onAppear { viewModel.selectedZimFile = nil }
}.onAppear { selection.selectedZimFile = nil }
}
}
#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 iOS, converts the modified view to a NavigationLink that goes to the zim file detail.
struct LibraryZimFileContext<Content: View>: View {
@EnvironmentObject private var viewModel: LibraryViewModel
@ObservedObject var selection: SelectedZimFileViewModel
private let content: Content
private let zimFile: ZimFile
/// iOS only
private let dismiss: (() -> Void)?
/// macOS only
private let onMultiSelected: ((Bool) -> Void)?
init(
@ViewBuilder content: () -> Content,
zimFile: ZimFile,
onMultiSelected: ((Bool) -> Void)? = nil,
selection: SelectedZimFileViewModel,
dismiss: (() -> Void)? = nil
) {
self.content = content()
self.zimFile = zimFile
self.onMultiSelected = onMultiSelected
self.selection = selection
self.dismiss = dismiss
}
var body: some View {
Group {
#if os(macOS)
if let onMultiSelected {
if selection.isMultiSelection {
content
.gesture(TapGesture().modifiers(.command).onEnded({ value in
onMultiSelected(true)
selection.toggleMultiSelect(of: zimFile)
}))
.gesture(TapGesture().onEnded({ _ in
onMultiSelected(false)
selection.singleSelect(zimFile: zimFile)
}))
} else {
content.onTapGesture {
viewModel.selectedZimFile = zimFile
selection.singleSelect(zimFile: zimFile)
}
}
#elseif os(iOS)

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ struct ZimFilesOpened: View {
animation: .easeInOut
) private var zimFiles: FetchedResults<ZimFile>
@State private var isFileImporterPresented = false
@EnvironmentObject private var viewModel: SelectedZimFileViewModel
@ObservedObject var selection: SelectedZimFileViewModel
let dismiss: (() -> Void)? // iOS only
var body: some View {
@ -35,28 +35,16 @@ struct ZimFilesOpened: View {
spacing: 12
) {
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(
content: {
ZimFileCell(
zimFile,
prominent: .name,
isSelected: viewModel.isSelected(zimFile)
isSelected: selection.isSelected(zimFile)
)
},
zimFile: zimFile,
onMultiSelected: multiSelected,
selection: selection,
dismiss: dismiss)
}
}
@ -70,9 +58,9 @@ struct ZimFilesOpened: View {
}
.onChange(of: zimFiles.count) { _ in
if let firstZimFile = zimFiles.first {
viewModel.singleSelect(zimFile: firstZimFile)
selection.singleSelect(zimFile: firstZimFile)
} 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

View File

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