EditPictureControl.swift (28509B)
1 // 2 // EditPictureControl.swift 3 // damus 4 // 5 // Created by Joel Klabo on 3/30/23. 6 // 7 8 import SwiftUI 9 import Kingfisher 10 import SwiftyCrop 11 12 // MARK: - Main view 13 14 /// A view that shows an existing picture, and allows a user to upload a new one. 15 struct EditPictureControl: View { 16 17 // MARK: Type aliases 18 19 typealias T = ImageUploadModel 20 typealias Model = EditPictureControlViewModel<T> 21 22 23 // MARK: Properties and state 24 25 @StateObject var model: Model 26 @Binding var current_image_url: URL? 27 let style: Style 28 let callback: (URL?) -> Void 29 30 @Environment(\.dismiss) var dismiss 31 32 33 // MARK: Initializers 34 35 init(model: Model, style: Style? = nil, callback: @escaping (URL?) -> Void) { 36 self._model = StateObject.init(wrappedValue: model) 37 self.style = style ?? Style(size: nil, first_time_setup: false) 38 self.callback = callback 39 self._current_image_url = model.$current_image_url 40 } 41 42 init( 43 uploader: any MediaUploaderProtocol, 44 context: Model.Context, 45 keypair: Keypair?, 46 pubkey: Pubkey, 47 style: Style? = nil, 48 current_image_url: Binding<URL?>, 49 upload_observer: ImageUploadingObserver? = nil, 50 callback: @escaping (URL?) -> Void 51 ) { 52 let model = EditPictureControlViewModel( 53 context: context, 54 pubkey: pubkey, 55 current_image_url: current_image_url, 56 keypair: keypair, 57 uploader: uploader, 58 callback: callback 59 ) 60 self.init(model: model, style: style, callback: callback) 61 } 62 63 64 // MARK: View definitions 65 66 var body: some View { 67 Menu { 68 self.menu_options 69 } label: { 70 if self.style.first_time_setup { 71 self.first_time_setup_view 72 } 73 else { 74 self.default_view 75 } 76 } 77 .accessibilityLabel(self.accessibility_label) 78 .accessibilityHint(self.accessibility_hint) 79 .maybeAccessibilityValue(self.accessibility_value) 80 .sheet(isPresented: self.model.show_camera) { 81 CameraController(uploader: model.uploader, mode: .handle_image(handler: { image in 82 self.model.request_upload_authorization(PreUploadedMedia.uiimage(image)) 83 })) 84 } 85 .sheet(isPresented: self.model.show_library) { 86 MediaPicker(mediaPickerEntry: .editPictureControl) { media in 87 self.model.request_upload_authorization(media) 88 } 89 } 90 .alert( 91 NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."), 92 isPresented: Binding.constant(self.model.state.is_confirming_upload) 93 ) { 94 Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) { 95 self.model.confirm_upload_authorization() 96 } 97 Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {} 98 } 99 .fullScreenCover(isPresented: self.model.show_image_cropper) { 100 self.image_cropper 101 } 102 .sheet(isPresented: self.model.show_url_sheet) { 103 ImageURLSelector(callback: { url in 104 self.model.choose_url(url) 105 }, cancel: { self.model.cancel() }) 106 .presentationDetents([.height(300)]) 107 .presentationDragIndicator(.visible) 108 } 109 .sheet(item: self.model.error_message, onDismiss: { self.model.cancel() }, content: { error in 110 Text(error.rawValue) 111 }) 112 } 113 114 var progress_view: some View { 115 ProgressView() 116 .progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple)) 117 .frame(width: style.size, height: style.size) 118 .padding(10) 119 .background(DamusColors.white.opacity(0.7)) 120 .clipShape(Circle()) 121 .shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0) 122 } 123 124 var menu_options: some View { 125 Group { 126 Button(action: { self.model.select_image_from_url() }) { 127 Text("Image URL", comment: "Option to enter a url") 128 } 129 .accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue) 130 131 Button(action: { self.model.select_image_from_library() }) { 132 Text("Choose from Library", comment: "Option to select photo from library") 133 } 134 135 Button(action: { self.model.select_image_from_camera() }) { 136 Text("Take Photo", comment: "Option to take a photo with the camera") 137 } 138 } 139 } 140 141 /// We show this on non-onboarding places such as profile edit page 142 var default_view: some View { 143 Group { 144 switch self.model.state { 145 case .uploading: 146 self.progress_view 147 default: 148 Image("camera") 149 .resizable() 150 .scaledToFit() 151 .frame(width: style.size ?? 25, height: style.size ?? 25) 152 .foregroundColor(DamusColors.purple) 153 .padding(10) 154 .background(DamusColors.white.opacity(0.7)) 155 .clipShape(Circle()) 156 .background { 157 Circle() 158 .fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2) 159 } 160 .shadow(radius: 3) 161 } 162 } 163 } 164 165 /// We show this on onboarding 166 var first_time_setup_view: some View { 167 Group { 168 switch self.model.state { 169 case .uploading: 170 self.progress_view 171 default: 172 if let url = current_image_url { 173 KFAnimatedImage(url) 174 .imageContext(.pfp, disable_animation: false) 175 .onFailure(fallbackUrl: URL(string: robohash(model.pubkey)), cacheKey: url.absoluteString) 176 .cancelOnDisappear(true) 177 .configure { view in 178 view.framePreloadCount = 3 179 } 180 .scaledToFill() 181 .frame(width: (style.size ?? 25) + 30, height: (style.size ?? 25) + 30) 182 .kfClickable() 183 .foregroundColor(DamusColors.white) 184 .clipShape(Circle()) 185 .overlay(Circle().stroke(.white, lineWidth: 4)) 186 } 187 else { 188 self.first_time_setup_no_image_view 189 } 190 } 191 } 192 } 193 194 /// We show this on onboarding before the user enters any image 195 var first_time_setup_no_image_view: some View { 196 Image(systemName: "person.fill") 197 .resizable() 198 .scaledToFit() 199 .frame(width: style.size, height: style.size) 200 .foregroundColor(DamusColors.white) 201 .padding(20) 202 .clipShape(Circle()) 203 .background { 204 Circle() 205 .fill(PinkGradient, strokeBorder: .white, lineWidth: 4) 206 } 207 .overlay( 208 Image(systemName: "plus.circle.fill") 209 .resizable() 210 .frame( 211 width: max((style.size ?? 30)/3, 20), 212 height: max((style.size ?? 30)/3, 20) 213 ) 214 .background(.damusDeepPurple) 215 .clipShape(Circle()) 216 .padding(.leading, -10) 217 .padding(.top, -10) 218 .foregroundStyle(.white) 219 .shadow(color: .black.opacity(0.2), radius: 4) 220 , alignment: .bottomTrailing 221 ) 222 } 223 224 225 var crop_configuration: SwiftyCropConfiguration = SwiftyCropConfiguration(rotateImage: false, zoomSensitivity: 5) 226 227 var image_cropper: some View { 228 Group { 229 if case .cropping(let preUploadedMedia) = model.state { 230 switch preUploadedMedia { 231 case .uiimage(let image): 232 SwiftyCropView( 233 imageToCrop: image, 234 maskShape: .circle 235 ) { croppedImage in 236 self.model.finished_cropping(croppedImage: croppedImage) 237 } 238 case .unprocessed_image(let url), .processed_image(let url): 239 if let image = try? UIImage.from(url: url) { 240 SwiftyCropView( 241 imageToCrop: image, 242 maskShape: .circle, 243 configuration: crop_configuration 244 ) { croppedImage in 245 self.model.finished_cropping(croppedImage: croppedImage) 246 } 247 } 248 else { 249 self.cropping_error_screen // Cannot load image 250 } 251 case .unprocessed_video(_), .processed_video(_): 252 self.cropping_error_screen // No support for video profile pictures 253 } 254 } 255 else { 256 self.cropping_error_screen // Some form of internal logical inconsistency 257 } 258 } 259 } 260 261 var cropping_error_screen: some View { 262 VStack(spacing: 5) { 263 Text("Error while cropping image", comment: "Heading on cropping error page") 264 .font(.headline) 265 Text("Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)", comment: "Cropping error message") 266 Button(action: { self.model.cancel() }, label: { 267 Text("Dismiss", comment: "Button to dismiss error") 268 }) 269 } 270 } 271 272 273 // MARK: Accesibility helpers 274 275 var accessibility_label: String { 276 switch self.model.context { 277 case .normal: 278 return NSLocalizedString("Edit Image", comment: "Accessibility label for a button that edits an image") 279 case .profile_picture: 280 return NSLocalizedString("Edit profile picture", comment: "Accessibility label for a button that edits a profile picture") 281 } 282 } 283 284 var accessibility_hint: String { 285 return NSLocalizedString("Shows options to edit the image", comment: "Accessibility hint for a button that edits an image") 286 } 287 288 var accessibility_value: String? { 289 if style.first_time_setup { 290 if let current_image_url = model.current_image_url { 291 switch self.model.context { 292 case .normal: 293 return NSLocalizedString("Image is setup", comment: "Accessibility value on image control") 294 case .profile_picture: 295 return NSLocalizedString("Profile picture is setup", comment: "Accessibility value on profile picture image control") 296 } 297 } 298 else { 299 switch self.model.context { 300 case .normal: 301 return NSLocalizedString("No image is currently setup", comment: "Accessibility value on image control") 302 case .profile_picture: 303 return NSLocalizedString("No profile picture is currently setup", comment: "Accessibility value on profile picture image control") 304 } 305 } 306 } 307 else { 308 return nil // Image is shown outside this control and will have its accessibility defined outside this view. 309 } 310 } 311 } 312 313 314 // MARK: - View model 315 316 317 /// Tracks the state, and provides the logic needed for the EditPictureControl view 318 /// 319 /// ## Implementation notes 320 /// 321 /// - This makes it easier to test the logic as well as the view, and makes the view easier to work with by separating concerns. 322 @MainActor 323 class EditPictureControlViewModel<T: ImageUploadModelProtocol>: ObservableObject { 324 // MARK: Properties 325 // Properties are designed to reduce statefulness and hopefully increase predictability. 326 327 /// The context of the upload. Is it a profile picture? A regular picture? 328 let context: Context 329 /// Pubkey of the user 330 let pubkey: Pubkey 331 /// The currently loaded image URL 332 @Binding var current_image_url: URL? 333 /// The state of the picture selection process 334 @Published private(set) var state: PictureSelectionState 335 /// User's keypair 336 let keypair: Keypair? 337 /// The uploader service to be used when uploading 338 let uploader: any MediaUploaderProtocol 339 /// An image upload observer, that can be set when the parent view wants to keep track of the upload process 340 let image_upload_observer: ImageUploadingObserver? 341 /// A callback to receive new image urls once the picture selection and upload is complete. 342 let callback: (URL?) -> Void 343 344 345 // MARK: Constants 346 347 /// The desired profile image size 348 var profile_image_size: CGSize = CGSize(width: 400, height: 400) 349 350 351 // MARK: Initializers 352 353 init( 354 context: Context, 355 pubkey: Pubkey, 356 setup: Bool? = nil, 357 current_image_url: Binding<URL?>, 358 state: PictureSelectionState = .ready, 359 keypair: Keypair?, 360 uploader: any MediaUploaderProtocol, 361 image_upload_observer: ImageUploadingObserver? = nil, 362 callback: @escaping (URL?) -> Void 363 ) { 364 self.context = context 365 self.pubkey = pubkey 366 self._current_image_url = current_image_url 367 self.state = state 368 self.keypair = keypair 369 self.uploader = uploader 370 self.image_upload_observer = image_upload_observer 371 self.callback = callback 372 } 373 374 375 // MARK: Convenience bindings to be used in views 376 377 var show_camera: Binding<Bool> { 378 Binding( 379 get: { self.state.show_camera }, 380 set: { newShowCamera in 381 switch self.state { 382 case .selecting_picture_from_camera: 383 self.state = newShowCamera ? .selecting_picture_from_camera : .ready 384 default: 385 if newShowCamera == true { self.state = .selecting_picture_from_camera } 386 else { return } // Leave state as-is 387 } 388 } 389 ) 390 } 391 392 var show_library: Binding<Bool> { 393 Binding( 394 get: { self.state.show_library }, 395 set: { newValue in 396 switch self.state { 397 case .selecting_picture_from_library: 398 self.state = newValue ? .selecting_picture_from_library : .ready 399 default: 400 if newValue == true { self.state = .selecting_picture_from_library } 401 else { return } // Leave state as-is 402 } 403 } 404 ) 405 } 406 407 var show_url_sheet: Binding<Bool> { 408 Binding( 409 get: { self.state.show_url_sheet }, 410 set: { newValue in self.state = newValue ? .selecting_picture_from_url : .ready } 411 ) 412 } 413 414 var show_image_cropper: Binding<Bool> { 415 Binding( 416 get: { self.state.show_image_cropper }, 417 set: { newValue in 418 switch self.state { 419 case .cropping(let media): 420 self.state = newValue ? .cropping(media) : .ready 421 default: 422 return // Leave state as-is 423 } 424 } 425 ) 426 } 427 428 fileprivate var error_message: Binding<IdentifiableString?> { 429 Binding( 430 get: { IdentifiableString(text: self.state.error_message) }, 431 set: { newValue in 432 if let newValue { 433 self.state = .failed(message: newValue.rawValue) 434 } 435 else { 436 self.state = .ready 437 } 438 } 439 ) 440 } 441 442 443 // MARK: Control methods 444 // These are methods to be used by the view or a test program to represent user actions. 445 446 /// Ask user if they are sure they want to upload an image 447 func request_upload_authorization(_ media: PreUploadedMedia) { 448 self.state = .confirming_upload(media) 449 } 450 451 /// Confirm on behalf of the user that we have their permission to upload image 452 func confirm_upload_authorization() { 453 guard case .confirming_upload(let preUploadedMedia) = state else { 454 return 455 } 456 switch self.context { 457 case .normal: 458 self.upload(media: preUploadedMedia) 459 case .profile_picture: 460 self.state = .cropping(preUploadedMedia) 461 } 462 } 463 464 /// Indicate the image has finished being cropped. This will resize the image and upload it 465 func finished_cropping(croppedImage: UIImage?) { 466 guard let croppedImage else { return } 467 let resizedCroppedImage = croppedImage.resized(to: profile_image_size) 468 let newPreUploadedMedia: PreUploadedMedia = .uiimage(resizedCroppedImage) 469 self.upload(media: newPreUploadedMedia) 470 } 471 472 /// Upload the media 473 func upload(media: PreUploadedMedia) { 474 if let mediaToUpload = generateMediaUpload(media) { 475 self.handle_upload(media: mediaToUpload) 476 } 477 else { 478 self.state = .failed(message: NSLocalizedString("Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io", comment: "Error label forming media for upload after user crops the image.")) 479 } 480 } 481 482 /// Cancel the picture selection process 483 func cancel() { 484 self.state = .ready 485 } 486 487 /// Mark the picture selection process as failed 488 func failed(message: String) { 489 self.state = .failed(message: message) 490 } 491 492 /// Choose an image based on a URL 493 func choose_url(_ url: URL?) { 494 self.current_image_url = url 495 callback(url) 496 self.state = .ready 497 } 498 499 /// Select an image from the gallery 500 func select_image_from_library() { 501 self.state = .selecting_picture_from_library 502 } 503 504 /// Select an image by taking a photo 505 func select_image_from_camera() { 506 self.state = .selecting_picture_from_camera 507 } 508 509 /// Select an image by specifying a URL 510 func select_image_from_url() { 511 self.state = .selecting_picture_from_url 512 } 513 514 515 // MARK: Internal logic 516 517 /// Handles the upload process 518 private func handle_upload(media: MediaUpload) { 519 let image_upload = T() 520 let upload_observer = ImageUploadingObserver() 521 self.state = .uploading(media: media, upload: image_upload, uploadObserver: upload_observer) 522 upload_observer.isLoading = true 523 Task { 524 let res = await image_upload.start(media: media, uploader: uploader, mediaType: self.context.mediaType, keypair: keypair) 525 526 switch res { 527 case .success(let urlString): 528 let url = URL(string: urlString) 529 current_image_url = url 530 self.state = .ready 531 callback(url) 532 case .failed(let error): 533 if let error { 534 Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription) 535 } else { 536 Log.info("Failed to upload profile image without error", for: .image_uploading) 537 } 538 self.state = .failed(message: NSLocalizedString("Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).", comment: "Error label when uploading profile image")) 539 } 540 upload_observer.isLoading = false 541 } 542 } 543 } 544 545 546 // MARK: - Helper views 547 548 /// A view that can be used for inputting a URL. 549 struct ImageURLSelector: View { 550 @State var image_url_temp: String = "" 551 @State var error: String? = nil 552 @State var image_url: URL? = nil 553 let callback: (URL?) -> Void 554 let cancel: () -> Void 555 556 var body: some View { 557 ZStack { 558 DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all) 559 VStack { 560 Text("Image URL", comment: "Label for image url text field") 561 .bold() 562 563 Divider() 564 .padding(.horizontal) 565 566 HStack { 567 Image(systemName: "doc.on.clipboard") 568 .foregroundColor(.gray) 569 .onTapGesture { 570 if let pastedURL = UIPasteboard.general.string { 571 image_url_temp = URL(string: pastedURL)?.absoluteString ?? "" 572 } 573 } 574 TextField(image_url_temp, text: $image_url_temp) 575 } 576 .padding(12) 577 .background { 578 RoundedRectangle(cornerRadius: 12) 579 .stroke(.gray.opacity(0.5), lineWidth: 1) 580 .background { 581 RoundedRectangle(cornerRadius: 12) 582 .foregroundColor(.damusAdaptableWhite) 583 } 584 } 585 .padding(10) 586 587 if let error { 588 Text(error) 589 .foregroundStyle(.red) 590 } 591 592 Button(action: { 593 self.cancel() 594 }, label: { 595 Text("Cancel", comment: "Cancel button text for dismissing updating image url.") 596 .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) 597 .padding(10) 598 }) 599 .buttonStyle(NeutralButtonStyle()) 600 .padding(10) 601 602 Button(action: { 603 guard let the_url = URL(string: image_url_temp) else { 604 error = NSLocalizedString("Invalid URL", comment: "Error label when user enters an invalid URL") 605 return 606 } 607 image_url = the_url 608 callback(the_url) 609 }, label: { 610 Text("Update", comment: "Update button text for updating image url.") 611 .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) 612 }) 613 .buttonStyle(GradientButtonStyle(padding: 10)) 614 .padding(.horizontal, 10) 615 .disabled(image_url_temp == image_url?.absoluteString) 616 .opacity(image_url_temp == image_url?.absoluteString ? 0.5 : 1) 617 } 618 } 619 .onAppear { 620 image_url_temp = image_url?.absoluteString ?? "" 621 } 622 } 623 } 624 625 // MARK: - Helper structures 626 627 extension EditPictureControlViewModel { 628 /// Tracks the state of the picture selection process in the picture control view and provides convenient computed properties for the view 629 /// 630 /// ## Implementation notes 631 /// 632 /// Made as an enum with associated values to reduce the amount of independent variables in the view model, and enforce the presence of certain values in certain steps of the process. 633 enum PictureSelectionState { 634 case ready 635 case selecting_picture_from_library 636 case selecting_picture_from_url 637 case selecting_picture_from_camera 638 case confirming_upload(PreUploadedMedia) 639 case cropping(PreUploadedMedia) 640 case uploading(media: MediaUpload, upload: any ImageUploadModelProtocol, uploadObserver: ImageUploadingObserver) 641 case failed(message: String) 642 643 // MARK: Convenience computed properties 644 // Translates the information in the state, in a way that does not introduce further statefulness 645 646 var is_confirming_upload: Bool { self.step == .confirming_upload } 647 var show_image_cropper: Bool { self.step == .cropping } 648 var show_library: Bool { self.step == .selecting_picture_from_library } 649 var show_camera: Bool { self.step == .selecting_picture_from_camera } 650 var show_url_sheet: Bool { self.step == .selecting_picture_from_url } 651 var is_uploading: Bool { self.step == .uploading } 652 var error_message: String? { if case .failed(let message) = self { return message } else { return nil } } 653 var step: Step { 654 switch self { 655 case .ready: .ready 656 case .selecting_picture_from_library: .selecting_picture_from_library 657 case .selecting_picture_from_url: .selecting_picture_from_url 658 case .selecting_picture_from_camera: .selecting_picture_from_camera 659 case .confirming_upload(_): .confirming_upload 660 case .cropping(_): .cropping 661 case .uploading(_,_,_): .uploading 662 case .failed(_): .failed 663 } 664 } 665 666 /// Tracks the specific step of the picture selection state, without any associated values, to make easy comparisons on where in the process we are 667 enum Step: String, RawRepresentable, Equatable { 668 case ready 669 case selecting_picture_from_library 670 case selecting_picture_from_url 671 case selecting_picture_from_camera 672 case confirming_upload 673 case cropping 674 case uploading 675 case failed 676 } 677 } 678 } 679 680 extension EditPictureControlViewModel { 681 /// Defines the context of this picture. Is it a profile picture? A normal picture? 682 enum Context { 683 case normal 684 case profile_picture 685 686 var mediaType: ImageUploadMediaType { 687 switch self { 688 case .normal: .normal 689 case .profile_picture: .profile_picture 690 } 691 } 692 } 693 } 694 695 /// An object that can be used for tracking the status of an upload across the view hierarchy. 696 /// For example, a parent view can instantiate this object and pass it to a child view that handles uploads, 697 /// and that parent view can change its own style accordingly 698 /// 699 /// ## Implementation note: 700 /// 701 /// It would be correct to put this entire class in the MainActor, but for some reason adding `@MainActor` crashes the Swift compiler with no helpful messages (on Xcode 16.2 (16C5032a)), so individual members of this class need to be manually put into the main actor. 702 //@MainActor 703 class ImageUploadingObserver: ObservableObject { 704 @MainActor @Published var isLoading: Bool = false 705 } 706 707 fileprivate struct IdentifiableString: Identifiable, RawRepresentable { 708 var id: String { return rawValue } 709 typealias RawValue = String 710 var rawValue: String 711 712 init?(rawValue: String) { 713 self.rawValue = rawValue 714 } 715 716 init?(text: String?) { 717 guard let text else { return nil } 718 self.rawValue = text 719 } 720 } 721 722 extension EditPictureControl { 723 struct Style { 724 let size: CGFloat? 725 let first_time_setup: Bool 726 } 727 } 728 729 // MARK: - Convenience extensions 730 731 fileprivate extension UIImage { 732 /// Convenience function to easily get an UIImage from a URL 733 static func from(url: URL) throws -> UIImage? { 734 let data = try Data(contentsOf: url) 735 return UIImage(data: data) 736 } 737 } 738 739 fileprivate extension View { 740 func maybeAccessibilityValue(_ value: String?) -> some View { 741 Group { 742 if let value { self.accessibilityValue(value) } else { self } 743 } 744 } 745 } 746 747 // MARK: - Previews 748 749 struct EditPictureControl_Previews: PreviewProvider { 750 static var previews: some View { 751 let url = Binding<URL?>.constant(URL(string: "https://damus.io")!) 752 ZStack { 753 Color.gray 754 EditPictureControl(uploader: MediaUploader.nostrBuild, context: .profile_picture, keypair: test_keypair, pubkey: test_pubkey, style: .init(size: 100, first_time_setup: false), current_image_url: url) { _ in 755 // 756 } 757 } 758 } 759 }