Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Offline: Remove downloaded area and view details #843

Merged
merged 62 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
d72c3ee
Add logics related to mmpk removal.
yo1995 Aug 21, 2024
6c8afda
Add metadata view.
yo1995 Aug 26, 2024
a6f8dac
Changes to the list view to present metadata view.
yo1995 Aug 26, 2024
1a5c007
Move constant strings to an enum.
yo1995 Aug 26, 2024
49b1db3
Fix loadable image view to fit aspect ratio.
yo1995 Aug 26, 2024
463d335
File size related changes.
yo1995 Aug 27, 2024
0aae655
Metadata related changes.
yo1995 Aug 27, 2024
d00b8e0
Merge branch 'OfflineMapAreasView' into Ting/RemovalAndDetails
yo1995 Aug 27, 2024
a1105c5
Dismiss full swipe to avoid accidental deletion.
yo1995 Aug 27, 2024
954a5bd
Limit the display scale of thumbnail to not exceed resolution.
yo1995 Aug 27, 2024
0c29ae8
Add onTap list item to show the metadata and remove swipe action.
yo1995 Aug 27, 2024
6ac2ff1
Set selectedMap to nil when a map area is removed from local disk.
yo1995 Aug 27, 2024
d4dc292
Set selectedMap to nil when a map area is removed from local disk.
yo1995 Aug 28, 2024
29db2c9
Renamve boolean.
yo1995 Aug 28, 2024
fbdce12
Rename metadata view.
yo1995 Aug 28, 2024
c99254b
Refactor downloaded status check.
yo1995 Aug 28, 2024
95e1aa5
No need to write metadata for preplanned.
yo1995 Aug 28, 2024
0afbcc4
Remove unneeded strings.
yo1995 Aug 28, 2024
48edbd1
MetadataView -> PreplannedMetadataView.
yo1995 Aug 28, 2024
21d7d88
Handle empty description.
yo1995 Aug 28, 2024
70b58f3
Don't use image for delete button.
yo1995 Aug 28, 2024
7170162
Remove unused string literal.
yo1995 Aug 29, 2024
bf05aa8
Add unit test for remove method.
yo1995 Aug 29, 2024
c637a95
Refactor removal method.
yo1995 Aug 29, 2024
a2a95c4
Use destructive delete button.
yo1995 Aug 29, 2024
e438a21
Change thumbnail style.
yo1995 Aug 29, 2024
5822c36
Adjust appearance.
yo1995 Aug 29, 2024
c111220
Rename onDelete closure.
yo1995 Aug 29, 2024
e4ffa56
Fix comment.
yo1995 Aug 29, 2024
e6abe37
Rename onDelete.
yo1995 Aug 29, 2024
53c0723
Add failure handling.
yo1995 Aug 29, 2024
eac92f6
Add content shape to make HStack tappable.
yo1995 Aug 29, 2024
ae3464c
Address suggestions from code review.
yo1995 Aug 30, 2024
c226361
Make size not optional.
yo1995 Aug 30, 2024
ef8859f
Rename deletion closure.
yo1995 Aug 30, 2024
e5d4657
Add URL suffix.
yo1995 Aug 30, 2024
414ac80
File size naming.
yo1995 Aug 30, 2024
b64ccad
File size formatting.
yo1995 Aug 31, 2024
c2fe659
Remove unneeded nav modifier.
yo1995 Aug 31, 2024
8fd4f76
Swap call order.
yo1995 Sep 3, 2024
cc77628
Move the convenience Booleans to the model.
yo1995 Sep 3, 2024
48521dc
Remove string constant type.
yo1995 Sep 3, 2024
72678b1
Use convenience appending method.
yo1995 Sep 4, 2024
cf9b325
Make constants computed.
yo1995 Sep 4, 2024
464a66a
Refactor done button.
yo1995 Sep 4, 2024
e4b5214
Move size calculation.
yo1995 Sep 4, 2024
282ed0f
More changes for mmpk size.
yo1995 Sep 4, 2024
d9ce613
Add tests for convenience booleans.
yo1995 Sep 4, 2024
6af6676
Apply suggestions from code review.
yo1995 Sep 5, 2024
7a67a85
Fix optionality.
yo1995 Sep 5, 2024
4a9c8ad
URL related suggestion.
yo1995 Sep 6, 2024
94ffbbe
Add button to go back online.
yo1995 Sep 11, 2024
a1e6680
Revamp selection logic.
yo1995 Sep 11, 2024
15e2a7b
Move reload back into the remove method.
yo1995 Sep 11, 2024
72b96de
Fix test.
yo1995 Sep 11, 2024
7f1e9a6
Remove onDeletion closure as it is not used.
yo1995 Sep 11, 2024
b9749e3
Resolve merge conflict.
yo1995 Sep 11, 2024
fe527f7
Remove `onMapSelectionChanged` closure.
yo1995 Sep 12, 2024
8a61fd5
Update selection condition and mmpk loading.
yo1995 Sep 13, 2024
626590e
Add missing comment.
yo1995 Sep 13, 2024
ba9c4b5
Rename Boolean to read like assertion.
yo1995 Sep 13, 2024
8d1bd04
Merge pull request #867 from Esri/Ting/RevampSelection
yo1995 Sep 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public struct OfflineMapAreasView: View {
PreplannedListItemView(model: preplannedMapModel) { newMap in
selectedMap = newMap
dismiss()
} onDeletion: {
selectedMap = nil
}
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ struct PreplannedListItemView: View {
/// The view model for the preplanned map.
@ObservedObject var model: PreplannedMapModel

/// A Boolean value indicating whether the metadata view is presented.
@State private var metadataViewIsPresented = false

/// The closure to perform when the map selection changes.
let onMapSelectionChanged: (Map) -> Void
/// The closure to perform when the map is removed from local disk.
let onDeletion: () -> Void

var body: some View {
HStack(alignment: .center, spacing: 10) {
Expand All @@ -37,6 +42,20 @@ struct PreplannedListItemView: View {
statusView
}
}
.contentShape(.rect)
.swipeActions {
deleteButton
}
yo1995 marked this conversation as resolved.
Show resolved Hide resolved
.onTapGesture {
if model.status.isDownloaded {
metadataViewIsPresented = true
}
}
.sheet(isPresented: $metadataViewIsPresented) {
NavigationStack {
PreplannedMetadataView(model: model)
}
}
.task {
await model.load()
}
Expand All @@ -55,6 +74,16 @@ struct PreplannedListItemView: View {
.font(.body)
}

@ViewBuilder private var deleteButton: some View {
if model.status.allowsRemoval {
Button("Delete") {
model.removeDownloadedPreplannedMapArea()
onDeletion()
philium marked this conversation as resolved.
Show resolved Hide resolved
}
.tint(.red)
}
}

@ViewBuilder private var downloadButton: some View {
switch model.status {
case .downloaded:
Expand Down Expand Up @@ -136,10 +165,10 @@ struct PreplannedListItemView: View {

#Preview {
struct MockPreplannedMapArea: PreplannedMapAreaProtocol {
var packagingStatus: PreplannedMapArea.PackagingStatus? = .complete
var title: String = "Mock Preplanned Map Area"
var description: String = "This is the description text"
var thumbnail: LoadableImage? = nil
var packagingStatus: PreplannedMapArea.PackagingStatus? { .complete }
var title: String { "Mock Preplanned Map Area" }
var description: String { "This is the description text" }
var thumbnail: LoadableImage? { nil }

func retryLoad() async throws { }
func makeParameters(using offlineMapTask: OfflineMapTask) async throws -> DownloadPreplannedOfflineMapParameters {
Expand All @@ -154,6 +183,6 @@ struct PreplannedListItemView: View {
portalItemID: .init("preview")!,
preplannedMapAreaID: .init("preview")!
)
) { _ in }
) { _ in } onDeletion: { }
.padding()
}
100 changes: 67 additions & 33 deletions Sources/ArcGISToolkit/Components/Offline/PreplannedMapModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class PreplannedMapModel: ObservableObject, Identifiable {
/// The preplanned map area.
let preplannedMapArea: any PreplannedMapAreaProtocol

/// The mobile map package directory URL.
private let mmpkDirectoryURL: URL

/// The task to use to take the area offline.
private let offlineMapTask: OfflineMapTask

Expand All @@ -34,6 +37,9 @@ class PreplannedMapModel: ObservableObject, Identifiable {
/// The mobile map package for the preplanned map area.
private(set) var mobileMapPackage: MobileMapPackage?

/// The file size of the preplanned map area.
private(set) var directorySize = 0

/// The currently running download job.
@Published private(set) var job: DownloadPreplannedOfflineMapJob?

Expand All @@ -54,15 +60,20 @@ class PreplannedMapModel: ObservableObject, Identifiable {
preplannedMapArea = mapArea
self.portalItemID = portalItemID
self.preplannedMapAreaID = preplannedMapAreaID
mmpkDirectoryURL = FileManager.default.preplannedDirectory(
forPortalItemID: portalItemID,
preplannedMapAreaID: preplannedMapAreaID
)
self.showsUserNotificationOnCompletion = showsUserNotificationOnCompletion

if let foundJob = lookupDownloadJob() {
Logger.offlineManager.debug("Found executing job for area \(preplannedMapAreaID.rawValue, privacy: .public)")
observeJob(foundJob)
} else if let mmpk = lookupMobileMapPackage() {
Logger.offlineManager.debug("Found MMPK for area \(preplannedMapAreaID.rawValue, privacy: .public)")
self.mobileMapPackage = mmpk
self.status = .downloaded
mobileMapPackage = mmpk
directorySize = FileManager.default.sizeOfDirectory(at: mmpkDirectoryURL)
status = .downloaded
}
}

Expand Down Expand Up @@ -98,14 +109,12 @@ class PreplannedMapModel: ObservableObject, Identifiable {
}

/// Updates the status based on the download result of the mobile map package.
func updateDownloadStatus(for downloadResult: Result<DownloadPreplannedOfflineMapResult, any Error>?) {
func updateDownloadStatus(for downloadResult: Result<DownloadPreplannedOfflineMapResult, any Error>) {
switch downloadResult {
case .success:
status = .downloaded
case .failure(let error):
status = .downloadFailure(error)
case .none:
return
}
}

Expand Down Expand Up @@ -143,16 +152,12 @@ class PreplannedMapModel: ObservableObject, Identifiable {

do {
let parameters = try await preplannedMapArea.makeParameters(using: offlineMapTask)
let mmpkDirectory = FileManager.default.preplannedDirectory(
forPortalItemID: portalItemID,
preplannedMapAreaID: preplannedMapAreaID
)
try FileManager.default.createDirectory(at: mmpkDirectory, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: mmpkDirectoryURL, withIntermediateDirectories: true)

// Create the download preplanned offline map job.
let job = offlineMapTask.makeDownloadPreplannedOfflineMapJob(
parameters: parameters,
downloadDirectory: mmpkDirectory
downloadDirectory: mmpkDirectoryURL
)

OfflineManager.shared.start(job: job)
Expand All @@ -162,6 +167,14 @@ class PreplannedMapModel: ObservableObject, Identifiable {
}
}

/// Removes the downloaded preplanned map area from disk and resets the status.
func removeDownloadedPreplannedMapArea() {
try? FileManager.default.removeItem(at: mmpkDirectoryURL)
// Reload the model after local files removal.
status = .notLoaded
Task { await load() }
}

/// Sets the job property of this instance, starts the job, observes it, and
/// when it's done, updates the status, removes the job from the job manager,
/// and fires a user notification.
Expand All @@ -172,7 +185,10 @@ class PreplannedMapModel: ObservableObject, Identifiable {
let result = await job.result
guard let self else { return }
self.updateDownloadStatus(for: result)
yo1995 marked this conversation as resolved.
Show resolved Hide resolved
self.mobileMapPackage = try? result.map { $0.mobileMapPackage }.get()
if status.isDownloaded {
self.mobileMapPackage = try? result.get().mobileMapPackage
self.directorySize = FileManager.default.sizeOfDirectory(at: mmpkDirectoryURL)
yo1995 marked this conversation as resolved.
Show resolved Hide resolved
}
self.job = nil
}
}
Expand Down Expand Up @@ -223,6 +239,21 @@ extension PreplannedMapModel {
true
}
}

/// A Boolean value indicating whether the local files can be removed.
var allowsRemoval: Bool {
switch self {
case .downloaded, .mmpkLoadFailure, .downloadFailure, .loadFailure, .packageFailure:
true
default:
false
}
}

/// A Boolean value indicating whether the preplanned map area is downloaded.
var isDownloaded: Bool {
if case .downloaded = self { true } else { false }
}
yo1995 marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -286,24 +317,16 @@ extension PreplannedMapArea: PreplannedMapAreaProtocol {
}
}

extension FileManager {
private static let mmpkPathExtension: String = "mmpk"
private static let offlineMapAreasPath: String = "OfflineMapAreas"
private static let packageDirectoryPath: String = "Package"
private static let preplannedDirectoryPath: String = "Preplanned"

private extension FileManager {
/// The path to the documents folder.
private var documentsDirectory: URL {
URL.documentsDirectory
}

/// The path to the offline map areas directory within the documents directory.
/// `Documents/OfflineMapAreas`
/// `Documents/OfflineMapAreas/`
private var offlineMapAreasDirectory: URL {
documentsDirectory.appending(
path: Self.offlineMapAreasPath,
directoryHint: .isDirectory
)
documentsDirectory.appending(component: "OfflineMapAreas/")
}

/// The path to the web map directory for a specific portal item.
Expand All @@ -313,22 +336,33 @@ extension FileManager {
offlineMapAreasDirectory.appending(path: portalItemID.rawValue, directoryHint: .isDirectory)
}

/// Calculates the size of a directory and all its contents.
/// - Parameter url: The directory's URL.
/// - Returns: The total size in bytes.
func sizeOfDirectory(at url: URL) -> Int {
guard let enumerator = enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
var accumulatedSize = 0
for case let fileURL as URL in enumerator {
guard let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
continue
}
accumulatedSize += size
}
return accumulatedSize
}

/// The path to the preplanned map areas directory for a specific portal item.
/// `Documents/OfflineMapAreas/<Portal Item ID>/Preplanned/<Preplanned Area ID>`
/// - Parameter portalItemID: The ID of the web map portal item.
/// `Documents/OfflineMapAreas/<Portal Item ID>/Preplanned/<Preplanned Area ID>/`
/// - Parameters:
/// - portalItemID: The ID of the web map portal item.
/// - preplannedMapAreaID: The ID of the preplanned map area portal item.
/// - Returns: A URL to the preplanned map area directory.
func preplannedDirectory(
forPortalItemID portalItemID: PortalItem.ID,
preplannedMapAreaID: PortalItem.ID
) -> URL {
portalItemDirectory(forPortalItemID: portalItemID)
.appending(
path: Self.preplannedDirectoryPath,
directoryHint: .isDirectory
)
.appending(
path: preplannedMapAreaID.rawValue,
directoryHint: .isDirectory
)
.appending(components: "Preplanned", "\(preplannedMapAreaID)/")
}

/// Returns a Boolean value indicating if the specified directory is empty.
Expand Down
109 changes: 109 additions & 0 deletions Sources/ArcGISToolkit/Components/Offline/PreplannedMetadataView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2024 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArcGIS
import SwiftUI

struct PreplannedMetadataView: View {
/// The view model for the preplanned map.
@ObservedObject var model: PreplannedMapModel

/// The action to dismiss the view.
@Environment(\.dismiss) private var dismiss

var body: some View {
Form {
yo1995 marked this conversation as resolved.
Show resolved Hide resolved
Section {
if let image = model.preplannedMapArea.thumbnail {
HStack {
Spacer()
LoadableImageView(loadableImage: image)
.clipShape(.rect(cornerRadius: 10))
.padding(.vertical, 10)
Spacer()
}
}
VStack(alignment: .leading) {
Text("Name")
.font(.caption)
.foregroundStyle(.secondary)
Text(model.preplannedMapArea.title)
.font(.subheadline)
}
VStack(alignment: .leading) {
Text("Description")
.font(.caption)
.foregroundStyle(.secondary)
if model.preplannedMapArea.description.isEmpty {
Text("This area has no description.")
.font(.subheadline)
.foregroundStyle(.tertiary)
} else {
Text(model.preplannedMapArea.description)
.font(.subheadline)
}
}
VStack(alignment: .leading) {
Text("Size")
.font(.caption)
.foregroundStyle(.secondary)
Text(Int64(model.directorySize), format: .byteCount(style: .file, allowedUnits: [.kb, .mb]))
.font(.subheadline)
}
}
Section {
HStack {
Image(systemName: "trash.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.red, .gray.opacity(0.1))
.font(.title)
Button("Delete Map Area", role: .destructive) {
dismiss()
model.removeDownloadedPreplannedMapArea()
}
}
}
}
.navigationTitle(model.preplannedMapArea.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}

#Preview {
struct MockPreplannedMapArea: PreplannedMapAreaProtocol {
var packagingStatus: PreplannedMapArea.PackagingStatus? { .complete }
var title: String { "Mock Preplanned Map Area" }
var description: String { "This is the description text" }
var thumbnail: LoadableImage? { nil }

func retryLoad() async throws { }
func makeParameters(using offlineMapTask: OfflineMapTask) async throws -> DownloadPreplannedOfflineMapParameters {
DownloadPreplannedOfflineMapParameters()
}
}

return PreplannedMetadataView(
model: PreplannedMapModel(
offlineMapTask: OfflineMapTask(onlineMap: Map()),
mapArea: MockPreplannedMapArea(),
portalItemID: .init("preview")!,
preplannedMapAreaID: .init("preview")!
)
)
}
Loading