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 all 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 @@ -27,8 +27,25 @@ struct OfflineMapAreasExampleView: View {
/// A Boolean value indicating whether the offline map ares view should be presented.
@State private var isShowingOfflineMapAreasView = false

/// The height of the map view's attribution bar.
@State private var attributionBarHeight = 0.0

var body: some View {
MapView(map: selectedMap ?? onlineMap)
.onAttributionBarHeightChanged { newHeight in
withAnimation { attributionBarHeight = newHeight }
}
.overlay(alignment: .bottom) {
if selectedMap != nil {
Button("Go Online") {
selectedMap = nil
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.vertical, 10 + attributionBarHeight)
}
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button("Offline Maps") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ public struct OfflineMapAreasView: View {
case .success(let models):
if !models.isEmpty {
List(models) { preplannedMapModel in
PreplannedListItemView(model: preplannedMapModel) { newMap in
selectedMap = newMap
dismiss()
}
PreplannedListItemView(model: preplannedMapModel, selectedMap: $selectedMap)
.onChange(of: selectedMap) { _ in
dismiss()
}
}
} else {
emptyPreplannedMapAreasView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ struct PreplannedListItemView: View {
/// The view model for the preplanned map.
@ObservedObject var model: PreplannedMapModel

/// The closure to perform when the map selection changes.
let onMapSelectionChanged: (Map) -> Void
/// The currently selected map.
@Binding var selectedMap: Map?

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

/// A Boolean value indicating whether the selected map area is the same
/// as the map area from this model.
/// The title of a preplanned map area is guaranteed to be unique when it
/// is created.
var isSelected: Bool {
selectedMap?.item?.title == model.preplannedMapArea.title
}

var body: some View {
HStack(alignment: .center, spacing: 10) {
Expand All @@ -37,6 +48,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,13 +80,23 @@ struct PreplannedListItemView: View {
.font(.body)
}

@ViewBuilder private var deleteButton: some View {
if model.status.allowsRemoval,
!isSelected {
Button("Delete") {
model.removeDownloadedPreplannedMapArea()
}
.tint(.red)
}
}

@ViewBuilder private var downloadButton: some View {
switch model.status {
case .downloaded:
Button {
Task {
if let map = await model.loadMobileMapPackage() {
onMapSelectionChanged(map)
if let map = await model.map {
selectedMap = map
}
}
} label: {
Expand All @@ -71,6 +106,7 @@ struct PreplannedListItemView: View {
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.disabled(isSelected)
case .downloading:
if let job = model.job {
ProgressView(job.progress)
Expand Down Expand Up @@ -136,10 +172,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 @@ -153,7 +189,8 @@ struct PreplannedListItemView: View {
mapArea: MockPreplannedMapArea(),
portalItemID: .init("preview")!,
preplannedMapAreaID: .init("preview")!
)
) { _ in }
),
selectedMap: .constant(nil)
)
.padding()
}
139 changes: 89 additions & 50 deletions Sources/ArcGISToolkit/Components/Offline/PreplannedMapModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,23 @@ class PreplannedMapModel: ObservableObject, Identifiable {
/// The preplanned map area.
let preplannedMapArea: any PreplannedMapAreaProtocol

/// The ID of the preplanned map area.
let preplannedMapAreaID: PortalItem.ID

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

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

/// The ID of the web map.
private let portalItemID: PortalItem.ID

/// The ID of the preplanned map area.
private let preplannedMapAreaID: PortalItem.ID

/// The mobile map package for the preplanned map area.
private(set) var mobileMapPackage: MobileMapPackage?
private 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 @@ -43,6 +49,24 @@ class PreplannedMapModel: ObservableObject, Identifiable {
/// A Boolean value indicating if a user notification should be shown when a job completes.
let showsUserNotificationOnCompletion: Bool

/// The first map from the mobile map package.
var map: Map? {
get async {
if let mobileMapPackage {
if mobileMapPackage.loadStatus != .loaded {
do {
try await mobileMapPackage.load()
} catch {
status = .mmpkLoadFailure(error)
}
}
return mobileMapPackage.maps.first
} else {
return nil
}
}
}

init(
offlineMapTask: OfflineMapTask,
mapArea: PreplannedMapAreaProtocol,
Expand All @@ -54,15 +78,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 +127,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 All @@ -122,19 +149,6 @@ class PreplannedMapModel: ObservableObject, Identifiable {
return MobileMapPackage.init(fileURL: fileURL)
}

/// Loads the mobile map package and updates the status.
/// - Returns: The mobile map package map.
func loadMobileMapPackage() async -> Map? {
guard let mobileMapPackage else { return nil }

do {
try await mobileMapPackage.load()
} catch {
status = .mmpkLoadFailure(error)
}
return mobileMapPackage.maps.first
}

/// Downloads the preplanned map area.
/// - Precondition: `canDownload`
func downloadPreplannedMapArea() async {
Expand All @@ -143,16 +157,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 +172,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 +190,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 +244,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 +322,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 +341,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
Loading