diff --git a/GIFCollector MessagesExtension/Assets.xcassets/Contents.json b/GIFCollector MessagesExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/GIFCollector MessagesExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GIFCollector MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Contents.json b/GIFCollector MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Contents.json new file mode 100644 index 0000000..bc3c4ee --- /dev/null +++ b/GIFCollector MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Contents.json @@ -0,0 +1,78 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x45" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x45" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "67x50" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "74x55" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "27x20" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "27x20" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "32x24" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "32x24" + }, + { + "idiom" : "ios-marketing", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x768" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GIFCollector MessagesExtension/Base.lproj/MainInterface.storyboard b/GIFCollector MessagesExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 0000000..36e2d49 --- /dev/null +++ b/GIFCollector MessagesExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GIFCollector MessagesExtension/GIFCollectionViewCell.swift b/GIFCollector MessagesExtension/GIFCollectionViewCell.swift new file mode 100644 index 0000000..f05b53c --- /dev/null +++ b/GIFCollector MessagesExtension/GIFCollectionViewCell.swift @@ -0,0 +1,107 @@ +import UIKit + +class GIFCollectionViewCell: UICollectionViewCell { + + private let gifPlayerView: GIFPlayerView = { + let player = GIFPlayerView() + player.layer.cornerRadius = 8 + player.clipsToBounds = true + return player + }() + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + private let placeholderLabel: UILabel = { + let label = UILabel() + label.text = "GIF" + label.textAlignment = .center + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = .systemGray + return label + }() + + private var currentTask: URLSessionDataTask? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + override func prepareForReuse() { + super.prepareForReuse() + currentTask?.cancel() + currentTask = nil + gifPlayerView.stopAnimating() + placeholderLabel.isHidden = false + loadingIndicator.stopAnimating() + } + + private func setupUI() { + contentView.addSubview(gifPlayerView) + contentView.addSubview(loadingIndicator) + contentView.addSubview(placeholderLabel) + + gifPlayerView.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + gifPlayerView.topAnchor.constraint(equalTo: contentView.topAnchor), + gifPlayerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + gifPlayerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + gifPlayerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + loadingIndicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + placeholderLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + contentView.layer.cornerRadius = 8 + contentView.layer.borderWidth = 1 + contentView.layer.borderColor = UIColor.systemGray4.cgColor + } + + func configure(with urlString: String) { + // Cancel any existing task + currentTask?.cancel() + + // Reset UI + gifPlayerView.stopAnimating() + placeholderLabel.isHidden = false + loadingIndicator.startAnimating() + + guard let url = URL(string: urlString) else { + loadingIndicator.stopAnimating() + return + } + + // Load the GIF + currentTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + guard let self = self else { return } + self.loadingIndicator.stopAnimating() + + if let data = data, error == nil { + self.gifPlayerView.loadGIF(from: data) + self.gifPlayerView.startAnimating() + self.placeholderLabel.isHidden = true + } else { + self.placeholderLabel.isHidden = false + } + } + } + + currentTask?.resume() + } +} \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Info.plist b/GIFCollector MessagesExtension/Info.plist new file mode 100644 index 0000000..52e0429 --- /dev/null +++ b/GIFCollector MessagesExtension/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.message-payload-provider + + + diff --git a/GIFCollector MessagesExtension/MessagesViewController.swift b/GIFCollector MessagesExtension/MessagesViewController.swift new file mode 100644 index 0000000..8b1206b --- /dev/null +++ b/GIFCollector MessagesExtension/MessagesViewController.swift @@ -0,0 +1,143 @@ +// +// MessagesViewController.swift +// GIFCollector MessagesExtension +// +// Created by Joshua Higgins on 6/2/25. +// + +import UIKit +import Messages + +class MessagesViewController: MSMessagesAppViewController { + + private var gifCollectionVC: GIFCollectionViewController? + + override func viewDidLoad() { + super.viewDidLoad() + setupChildViewController() + } + + private func setupChildViewController() { + let collectionVC = GIFCollectionViewController() + collectionVC.onSelectGIF = { [weak self] gif in + self?.sendGIF(gif) + } + + addChild(collectionVC) + collectionVC.view.frame = view.bounds + collectionVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(collectionVC.view) + collectionVC.didMove(toParent: self) + + gifCollectionVC = collectionVC + } + + private func sendGIF(_ gif: GIF) { + guard let conversation = activeConversation, + let gifURL = gif.url else { return } + + // Show a loading indicator + let loadingAlert = UIAlertController(title: "Preparing GIF", message: "Please wait...", preferredStyle: .alert) + present(loadingAlert, animated: true) + + // Download the GIF data + GIFDownloadService.shared.downloadGIF(from: gif.urlString) { data, error in + DispatchQueue.main.async { + // Dismiss the loading indicator + self.dismiss(animated: true) { + if let error = error { + self.showErrorAlert(error: error) + return + } + + guard let gifData = data else { + self.showErrorAlert(message: "Failed to download GIF") + return + } + + // Create a temporary file URL for the GIF + let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let tempFileURL = tempDirectoryURL.appendingPathComponent(UUID().uuidString).appendingPathExtension("gif") + + do { + // Write GIF data to temporary file + try gifData.write(to: tempFileURL) + + // Insert the GIF directly as a standard attachment into the message field + conversation.insertAttachment(tempFileURL, withAlternateFilename: "animated.gif") { error in + if let error = error { + self.showErrorAlert(error: error) + } else { + // Successfully inserted the attachment + self.requestPresentationStyle(.compact) + } + } + } catch { + self.showErrorAlert(error: error) + } + } + } + } + } + + private func showErrorAlert(error: Error? = nil, message: String? = nil) { + let errorMessage = message ?? error?.localizedDescription ?? "An unknown error occurred" + let alertController = UIAlertController(title: "Error", message: errorMessage, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default)) + present(alertController, animated: true) + } + + // MARK: - Conversation Handling + + override func willBecomeActive(with conversation: MSConversation) { + // Called when the extension is about to move from the inactive to active state. + // This will happen when the extension is about to present UI. + + // Refresh GIFs list when becoming active + gifCollectionVC?.viewWillAppear(true) + + // We don't need to check for custom message URLs anymore since + // we're sending standard GIF attachments + } + + override func didResignActive(with conversation: MSConversation) { + // Called when the extension is about to move from the active to inactive state. + // This will happen when the user dismisses the extension, changes to a different + // conversation or quits Messages. + + // Use this method to release shared resources, save user data, invalidate timers, + // and store enough state information to restore your extension to its current state + // in case it is terminated later. + } + + override func didReceive(_ message: MSMessage, conversation: MSConversation) { + // Called when a message arrives that was generated by another instance of this + // extension on a remote device. + + // Since we're now sending GIFs as standard attachments rather than + // custom messages, we don't need special handling for received messages + } + + override func didStartSending(_ message: MSMessage, conversation: MSConversation) { + // Called when the user taps the send button. + } + + override func didCancelSending(_ message: MSMessage, conversation: MSConversation) { + // Called when the user deletes the message without sending it. + + // Use this to clean up state related to the deleted message. + } + + override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) { + // Called before the extension transitions to a new presentation style. + + // Use this method to prepare for the change in presentation style. + } + + override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) { + // Called after the extension transitions to a new presentation style. + + // Use this method to finalize any behaviors associated with the change in presentation style. + } + +} diff --git a/GIFCollector MessagesExtension/Models/GIF.swift b/GIFCollector MessagesExtension/Models/GIF.swift new file mode 100644 index 0000000..3a75d50 --- /dev/null +++ b/GIFCollector MessagesExtension/Models/GIF.swift @@ -0,0 +1,22 @@ +import Foundation +import UIKit + +struct GIF: Codable, Identifiable, Equatable { + let id: UUID + let urlString: String + let createdAt: Date + + var url: URL? { + return URL(string: urlString) + } + + init(urlString: String) { + self.id = UUID() + self.urlString = urlString + self.createdAt = Date() + } + + static func == (lhs: GIF, rhs: GIF) -> Bool { + return lhs.id == rhs.id + } +} \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Services/GIFDownloadService.swift b/GIFCollector MessagesExtension/Services/GIFDownloadService.swift new file mode 100644 index 0000000..e213fd4 --- /dev/null +++ b/GIFCollector MessagesExtension/Services/GIFDownloadService.swift @@ -0,0 +1,60 @@ +import Foundation +import UIKit + +class GIFDownloadService { + static let shared = GIFDownloadService() + + private let cache = NSCache() + private var activeTasks: [URL: URLSessionDataTask] = [:] + + private init() { + cache.totalCostLimit = 100 * 1024 * 1024 // 100 MB cache limit + } + + func downloadGIF(from urlString: String, completion: @escaping (Data?, Error?) -> Void) { + guard let url = URL(string: urlString) else { + completion(nil, NSError(domain: "GIFDownloadService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])) + return + } + + // Check if the GIF is already cached + let cacheKey = url.absoluteString as NSString + if let cachedData = cache.object(forKey: cacheKey) { + completion(cachedData as Data, nil) + return + } + + // Cancel any existing tasks for this URL + activeTasks[url]?.cancel() + + // Create a new download task + let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + defer { + self?.activeTasks.removeValue(forKey: url) + } + + guard let self = self, error == nil, let data = data else { + DispatchQueue.main.async { + completion(nil, error ?? NSError(domain: "GIFDownloadService", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to download GIF"])) + } + return + } + + // Cache the downloaded data + self.cache.setObject(data as NSData, forKey: cacheKey, cost: data.count) + + DispatchQueue.main.async { + completion(data, nil) + } + } + + // Store and start the task + activeTasks[url] = task + task.resume() + } + + func cancelAllTasks() { + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() + } +} \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Services/GIFStorageService.swift b/GIFCollector MessagesExtension/Services/GIFStorageService.swift new file mode 100644 index 0000000..74e83b8 --- /dev/null +++ b/GIFCollector MessagesExtension/Services/GIFStorageService.swift @@ -0,0 +1,49 @@ +import Foundation + +class GIFStorageService { + static let shared = GIFStorageService() + + private let userDefaults = UserDefaults(suiteName: "group.gifcollector") + private let savedGIFsKey = "savedGIFs" + + private init() { + // Make sure the shared UserDefaults exists + if userDefaults == nil { + print("Error: Could not create UserDefaults with app group") + } + } + + func saveGIF(_ gif: GIF) { + var savedGIFs = fetchGIFs() + + // Don't save duplicate URLs + if !savedGIFs.contains(where: { $0.urlString == gif.urlString }) { + savedGIFs.append(gif) + saveToUserDefaults(gifs: savedGIFs) + } + } + + func fetchGIFs() -> [GIF] { + guard let data = userDefaults?.data(forKey: savedGIFsKey), + let gifs = try? JSONDecoder().decode([GIF].self, from: data) else { + return [] + } + + return gifs.sorted(by: { $0.createdAt > $1.createdAt }) + } + + func deleteGIF(with id: UUID) { + var savedGIFs = fetchGIFs() + savedGIFs.removeAll(where: { $0.id == id }) + saveToUserDefaults(gifs: savedGIFs) + } + + func clearAllGIFs() { + saveToUserDefaults(gifs: []) + } + + private func saveToUserDefaults(gifs: [GIF]) { + guard let data = try? JSONEncoder().encode(gifs) else { return } + userDefaults?.set(data, forKey: savedGIFsKey) + } +} \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Views/AddGIFViewController.swift b/GIFCollector MessagesExtension/Views/AddGIFViewController.swift new file mode 100644 index 0000000..d2b1fdf --- /dev/null +++ b/GIFCollector MessagesExtension/Views/AddGIFViewController.swift @@ -0,0 +1,186 @@ +import UIKit + +class AddGIFViewController: UIViewController { + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = "Add New GIF" + label.font = .systemFont(ofSize: 18, weight: .bold) + label.textAlignment = .center + return label + }() + + private let urlTextField: UITextField = { + let textField = UITextField() + textField.placeholder = "Enter GIF URL" + textField.borderStyle = .roundedRect + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.keyboardType = .URL + textField.returnKeyType = .next + textField.clearButtonMode = .whileEditing + return textField + }() + + + + private let previewGIFPlayer: GIFPlayerView = { + let player = GIFPlayerView() + player.clipsToBounds = true + player.layer.cornerRadius = 8 + player.backgroundColor = .systemGray6 + return player + }() + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + private let saveButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Save", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 8 + button.isEnabled = false + return button + }() + + private let cancelButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Cancel", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + return button + }() + + private var currentTask: URLSessionDataTask? + var onSaveGIF: ((GIF) -> Void)? + var onCancel: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupActions() + } + + private func setupUI() { + view.backgroundColor = .systemBackground + + view.addSubview(titleLabel) + view.addSubview(urlTextField) + view.addSubview(previewGIFPlayer) + view.addSubview(loadingIndicator) + view.addSubview(saveButton) + view.addSubview(cancelButton) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + urlTextField.translatesAutoresizingMaskIntoConstraints = false + previewGIFPlayer.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + saveButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + urlTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + urlTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + urlTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + previewGIFPlayer.topAnchor.constraint(equalTo: urlTextField.bottomAnchor, constant: 16), + previewGIFPlayer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + previewGIFPlayer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + previewGIFPlayer.heightAnchor.constraint(equalToConstant: 150), + + loadingIndicator.centerXAnchor.constraint(equalTo: previewGIFPlayer.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: previewGIFPlayer.centerYAnchor), + + saveButton.topAnchor.constraint(equalTo: previewGIFPlayer.bottomAnchor, constant: 24), + saveButton.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: 8), + saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + saveButton.heightAnchor.constraint(equalToConstant: 44), + + cancelButton.topAnchor.constraint(equalTo: previewGIFPlayer.bottomAnchor, constant: 24), + cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + cancelButton.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: -8), + cancelButton.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + private func setupActions() { + urlTextField.delegate = self + + urlTextField.addTarget(self, action: #selector(urlTextDidChange), for: .editingChanged) + + saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) + cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + } + + @objc private func urlTextDidChange() { + guard let urlString = urlTextField.text, !urlString.isEmpty else { + previewGIFPlayer.stopAnimating() + saveButton.isEnabled = false + return + } + + loadGIFPreview(from: urlString) + } + + private func loadGIFPreview(from urlString: String) { + // Cancel any existing task + currentTask?.cancel() + + guard let url = URL(string: urlString) else { + saveButton.isEnabled = false + return + } + + loadingIndicator.startAnimating() + previewGIFPlayer.stopAnimating() + + currentTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + guard let self = self else { return } + + self.loadingIndicator.stopAnimating() + + if let data = data, error == nil { + self.previewGIFPlayer.loadGIF(from: data) + self.previewGIFPlayer.startAnimating() + self.saveButton.isEnabled = true + } else { + self.previewGIFPlayer.stopAnimating() + self.saveButton.isEnabled = false + } + } + } + + currentTask?.resume() + } + + @objc private func saveButtonTapped() { + guard let urlString = urlTextField.text, !urlString.isEmpty else { return } + + let gif = GIF(urlString: urlString) + + onSaveGIF?(gif) + dismiss(animated: true) + } + + @objc private func cancelButtonTapped() { + onCancel?() + dismiss(animated: true) + } +} + +extension AddGIFViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift b/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift new file mode 100644 index 0000000..9c8f2dc --- /dev/null +++ b/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift @@ -0,0 +1,136 @@ +import UIKit +import Messages + +class GIFCollectionViewController: UIViewController { + + private var collectionView: UICollectionView! + private let addButton = UIButton(type: .system) + private let emptyStateLabel = UILabel() + + private let reuseIdentifier = "GIFCell" + private var gifs: [GIF] = [] + + var onSelectGIF: ((GIF) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + setupUI() + loadGIFs() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + loadGIFs() + } + + private func setupCollectionView() { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 10 + layout.minimumInteritemSpacing = 10 + + // Calculate cell size to fit 2 cells per row with spacing + let cellWidth = (view.bounds.width - 40) / 2 // 40 = padding (10 + 10) + spacing between cells (10) + extra margins (10) + layout.itemSize = CGSize(width: cellWidth, height: cellWidth) + layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register(GIFCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + collectionView.alwaysBounceVertical = true + } + + private func setupUI() { + view.backgroundColor = .systemBackground + + // Add Collection View + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + + // Setup Add Button + addButton.setImage(UIImage(systemName: "plus.circle.fill"), for: .normal) + addButton.tintColor = .systemBlue + addButton.contentHorizontalAlignment = .fill + addButton.contentVerticalAlignment = .fill + addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) + view.addSubview(addButton) + addButton.translatesAutoresizingMaskIntoConstraints = false + + // Setup Empty State Label + emptyStateLabel.text = "No GIFs saved yet. Add your first GIF!" + emptyStateLabel.textAlignment = .center + emptyStateLabel.textColor = .systemGray + emptyStateLabel.numberOfLines = 0 + view.addSubview(emptyStateLabel) + emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + addButton.widthAnchor.constraint(equalToConstant: 44), + addButton.heightAnchor.constraint(equalToConstant: 44), + addButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + addButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + + emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyStateLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + emptyStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + emptyStateLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32) + ]) + + updateEmptyState() + } + + private func loadGIFs() { + gifs = GIFStorageService.shared.fetchGIFs() + collectionView.reloadData() + updateEmptyState() + } + + private func updateEmptyState() { + emptyStateLabel.isHidden = !gifs.isEmpty + } + + @objc private func addButtonTapped() { + let addGIFVC = AddGIFViewController() + addGIFVC.onSaveGIF = { [weak self] gif in + GIFStorageService.shared.saveGIF(gif) + self?.loadGIFs() + } + addGIFVC.onCancel = { [weak self] in + self?.dismiss(animated: true) + } + + let navController = UINavigationController(rootViewController: addGIFVC) + navController.modalPresentationStyle = .formSheet + present(navController, animated: true) + } +} + +extension GIFCollectionViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return gifs.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? GIFCollectionViewCell else { + return UICollectionViewCell() + } + + let gif = gifs[indexPath.item] + cell.configure(with: gif.urlString) + + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let gif = gifs[indexPath.item] + onSelectGIF?(gif) + } +} \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Views/GIFPlayerView.swift b/GIFCollector MessagesExtension/Views/GIFPlayerView.swift new file mode 100644 index 0000000..c1dc267 --- /dev/null +++ b/GIFCollector MessagesExtension/Views/GIFPlayerView.swift @@ -0,0 +1,179 @@ +import UIKit +import ImageIO + +class GIFPlayerView: UIView { + private var imageView: UIImageView! + private var displayLink: CADisplayLink? + private var imageSource: CGImageSource? + private var frameCount: Int = 0 + private var currentFrameIndex: Int = 0 + private var frameDurations: [TimeInterval] = [] + private var totalDuration: TimeInterval = 0 + private var currentTime: TimeInterval = 0 + private var previousTimestamp: TimeInterval = 0 + + // Default configuration + private var loopCount: Int = 0 // 0 means loop forever + private var currentLoopCount: Int = 0 + + // Public properties + var isPlaying: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + deinit { + stopAnimating() + } + + private func commonInit() { + imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + addSubview(imageView) + + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Public Methods + + func loadGIF(from data: Data) { + stopAnimating() + + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return } + imageSource = source + + // Get frame count + frameCount = CGImageSourceGetCount(source) + guard frameCount > 0 else { return } + + // Calculate frame durations + frameDurations.removeAll() + totalDuration = 0 + + for i in 0.. 1 else { return } + + isPlaying = true + previousTimestamp = CACurrentMediaTime() + + displayLink = CADisplayLink(target: self, selector: #selector(updateFrame)) + displayLink?.add(to: .main, forMode: .common) + } + + func stopAnimating() { + isPlaying = false + displayLink?.invalidate() + displayLink = nil + } + + func loadGIF(from url: URL, completion: @escaping (Bool) -> Void) { + URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + guard let self = self, let data = data, error == nil else { + DispatchQueue.main.async { + completion(false) + } + return + } + + DispatchQueue.main.async { + self.loadGIF(from: data) + completion(true) + } + }.resume() + } + + // MARK: - Private Methods + + @objc private func updateFrame(displayLink: CADisplayLink) { + let timestamp = CACurrentMediaTime() + let elapsed = timestamp - previousTimestamp + previousTimestamp = timestamp + + currentTime += elapsed + + // Check if we need to show the next frame + while currentTime >= frameDurations[currentFrameIndex], frameCount > 0 { + currentTime -= frameDurations[currentFrameIndex] + currentFrameIndex = (currentFrameIndex + 1) % frameCount + + // Handle loop count + if currentFrameIndex == 0 && loopCount > 0 { + currentLoopCount += 1 + if currentLoopCount >= loopCount { + stopAnimating() + break + } + } + + // Update the image + if let image = imageAtIndex(currentFrameIndex) { + imageView.image = image + } + } + } + + private func imageAtIndex(_ index: Int) -> UIImage? { + guard let source = imageSource, index < frameCount, + let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else { + return nil + } + + return UIImage(cgImage: cgImage) + } + + private func frameDurationAtIndex(_ index: Int) -> TimeInterval { + guard let source = imageSource, index < frameCount else { + return 0.1 // Default duration + } + + // Get frame properties + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any], + let gifProperties = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] else { + return 0.1 // Default duration + } + + // Get delay time + var delayTime: TimeInterval = 0.1 // Default duration + + if let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime as String] as? TimeInterval, + unclampedDelay > 0 { + delayTime = unclampedDelay + } else if let delay = gifProperties[kCGImagePropertyGIFDelayTime as String] as? TimeInterval, + delay > 0 { + delayTime = delay + } + + // Clamp to minimum delay (ensures reasonable frame rate) + return max(delayTime, 0.02) + } +} diff --git a/GIFCollector.xcodeproj/project.pbxproj b/GIFCollector.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d51dc94 --- /dev/null +++ b/GIFCollector.xcodeproj/project.pbxproj @@ -0,0 +1,486 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1B3192822DEDCF86007850B9 /* GIFCollector MessagesExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1B3192812DEDCF86007850B9 /* GIFCollector MessagesExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1B3192872DEDCF86007850B9 /* Messages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B3192862DEDCF86007850B9 /* Messages.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1B3192832DEDCF86007850B9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1B3192722DEDCF83007850B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1B3192802DEDCF86007850B9; + remoteInfo = "GIFCollector MessagesExtension"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 1B3192972DEDCF87007850B9 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 1B3192822DEDCF86007850B9 /* GIFCollector MessagesExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1B3192782DEDCF83007850B9 /* GIFCollector.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GIFCollector.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1B3192812DEDCF86007850B9 /* GIFCollector MessagesExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "GIFCollector MessagesExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 1B3192862DEDCF86007850B9 /* Messages.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Messages.framework; path = System/Library/Frameworks/Messages.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 1B3192912DEDCF87007850B9 /* Exceptions for "GIFCollector MessagesExtension" folder in "GIFCollector MessagesExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 1B3192802DEDCF86007850B9 /* GIFCollector MessagesExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1B31927A2DEDCF83007850B9 /* GIFCollector */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = GIFCollector; + sourceTree = ""; + }; + 1B3192882DEDCF86007850B9 /* GIFCollector MessagesExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 1B3192912DEDCF87007850B9 /* Exceptions for "GIFCollector MessagesExtension" folder in "GIFCollector MessagesExtension" target */, + ); + path = "GIFCollector MessagesExtension"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1B31927E2DEDCF86007850B9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1B3192872DEDCF86007850B9 /* Messages.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1B3192712DEDCF83007850B9 = { + isa = PBXGroup; + children = ( + 1B31927A2DEDCF83007850B9 /* GIFCollector */, + 1B3192882DEDCF86007850B9 /* GIFCollector MessagesExtension */, + 1B3192852DEDCF86007850B9 /* Frameworks */, + 1B3192792DEDCF83007850B9 /* Products */, + ); + sourceTree = ""; + }; + 1B3192792DEDCF83007850B9 /* Products */ = { + isa = PBXGroup; + children = ( + 1B3192782DEDCF83007850B9 /* GIFCollector.app */, + 1B3192812DEDCF86007850B9 /* GIFCollector MessagesExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 1B3192852DEDCF86007850B9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1B3192862DEDCF86007850B9 /* Messages.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1B3192772DEDCF83007850B9 /* GIFCollector */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1B3192982DEDCF87007850B9 /* Build configuration list for PBXNativeTarget "GIFCollector" */; + buildPhases = ( + 1B3192762DEDCF83007850B9 /* Resources */, + 1B3192972DEDCF87007850B9 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 1B3192842DEDCF86007850B9 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 1B31927A2DEDCF83007850B9 /* GIFCollector */, + ); + name = GIFCollector; + packageProductDependencies = ( + ); + productName = GIFCollector; + productReference = 1B3192782DEDCF83007850B9 /* GIFCollector.app */; + productType = "com.apple.product-type.application.messages"; + }; + 1B3192802DEDCF86007850B9 /* GIFCollector MessagesExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1B3192922DEDCF87007850B9 /* Build configuration list for PBXNativeTarget "GIFCollector MessagesExtension" */; + buildPhases = ( + 1B31927D2DEDCF86007850B9 /* Sources */, + 1B31927E2DEDCF86007850B9 /* Frameworks */, + 1B31927F2DEDCF86007850B9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 1B3192882DEDCF86007850B9 /* GIFCollector MessagesExtension */, + ); + name = "GIFCollector MessagesExtension"; + packageProductDependencies = ( + ); + productName = "GIFCollector MessagesExtension"; + productReference = 1B3192812DEDCF86007850B9 /* GIFCollector MessagesExtension.appex */; + productType = "com.apple.product-type.app-extension.messages"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1B3192722DEDCF83007850B9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 1B3192772DEDCF83007850B9 = { + CreatedOnToolsVersion = 16.4; + }; + 1B3192802DEDCF86007850B9 = { + CreatedOnToolsVersion = 16.4; + }; + }; + }; + buildConfigurationList = 1B3192752DEDCF83007850B9 /* Build configuration list for PBXProject "GIFCollector" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1B3192712DEDCF83007850B9; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1B3192792DEDCF83007850B9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1B3192772DEDCF83007850B9 /* GIFCollector */, + 1B3192802DEDCF86007850B9 /* GIFCollector MessagesExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1B3192762DEDCF83007850B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1B31927F2DEDCF86007850B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1B31927D2DEDCF86007850B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 1B3192842DEDCF86007850B9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1B3192802DEDCF86007850B9 /* GIFCollector MessagesExtension */; + targetProxy = 1B3192832DEDCF86007850B9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1B3192932DEDCF87007850B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "GIFCollector MessagesExtension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "GIF Collector"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.abunchofknowitalls.GIFCollector.MessagesExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1B3192942DEDCF87007850B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "GIFCollector MessagesExtension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "GIF Collector"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.abunchofknowitalls.GIFCollector.MessagesExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 1B3192952DEDCF87007850B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1B3192962DEDCF87007850B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1B3192992DEDCF87007850B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "GIF Collector"; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.abunchofknowitalls.GIFCollector; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1B31929A2DEDCF87007850B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "GIF Collector"; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.abunchofknowitalls.GIFCollector; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1B3192752DEDCF83007850B9 /* Build configuration list for PBXProject "GIFCollector" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1B3192952DEDCF87007850B9 /* Debug */, + 1B3192962DEDCF87007850B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1B3192922DEDCF87007850B9 /* Build configuration list for PBXNativeTarget "GIFCollector MessagesExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1B3192932DEDCF87007850B9 /* Debug */, + 1B3192942DEDCF87007850B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1B3192982DEDCF87007850B9 /* Build configuration list for PBXNativeTarget "GIFCollector" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1B3192992DEDCF87007850B9 /* Debug */, + 1B31929A2DEDCF87007850B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1B3192722DEDCF83007850B9 /* Project object */; +} diff --git a/GIFCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/GIFCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/GIFCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/GIFCollector/Assets.xcassets/AppIcon.appiconset/Contents.json b/GIFCollector/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/GIFCollector/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GIFCollector/Assets.xcassets/Contents.json b/GIFCollector/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/GIFCollector/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +}