wip: iOS refactor — iMessage extension, sticker browser, app icon assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:43:06 +01:00
parent 20b93df9b0
commit b5d50c45da
8 changed files with 410 additions and 23 deletions

View File

@@ -1,4 +1,5 @@
import Foundation
import UIKit
final class StickerStore {
static let shared = StickerStore()
@@ -6,6 +7,7 @@ final class StickerStore {
private let packsKey = "savedPacks"
private let suiteName = "group.de.felixfoertsch.StickerCloner"
private let fileManager = FileManager.default
private let thumbnailCache = NSCache<NSString, UIImage>()
private lazy var defaults: UserDefaults = {
guard let defaults = UserDefaults(suiteName: suiteName) else {
@@ -66,6 +68,7 @@ final class StickerStore {
var packs = savedPacks
packs.removeAll { $0.name == name }
savedPacks = packs
thumbnailCache.removeObject(forKey: name as NSString)
let packDir = packDirectory(name: name)
try? fileManager.removeItem(at: packDir)
@@ -89,6 +92,14 @@ final class StickerStore {
return urls
}
func stickerFileURLs(forPack name: String) -> [URL] {
let packDir = packDirectory(name: name)
guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { return [] }
return files
.filter { $0.pathExtension == "png" }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
func firstStickerURL(forPack name: String) -> URL? {
let packDir = packDirectory(name: name)
guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { return nil }
@@ -98,6 +109,16 @@ final class StickerStore {
.first
}
func thumbnail(forPack name: String) -> UIImage? {
let key = name as NSString
if let cached = thumbnailCache.object(forKey: key) { return cached }
guard let url = firstStickerURL(forPack: name),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
thumbnailCache.setObject(image, forKey: key)
return image
}
// MARK: - Migration from old sandbox
private func migrateIfNeeded() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -7,7 +7,6 @@ final class MessagesViewController: MSMessagesAppViewController {
override func viewDidLoad() {
super.viewDidLoad()
addStickerBrowser()
stickerBrowser.reloadStickers()
}
override func willBecomeActive(with conversation: MSConversation) {

View File

@@ -2,7 +2,23 @@ import Messages
import UIKit
final class StickerBrowserViewController: MSStickerBrowserViewController {
private var stickers: [MSSticker] = []
private struct Pack {
let savedPack: SavedPack
let thumbnail: UIImage?
let stickers: [MSSticker]
}
private var packs: [Pack] = []
private var selectedPackIndex: Int? // nil = all packs
private var displayedStickers: [MSSticker] = []
private var tabCollectionView: UICollectionView!
private let tabSize: CGFloat = 36
private let tabSpacing: CGFloat = 6
private let tabInset: CGFloat = 8
// MARK: - Lifecycle
init() {
super.init(stickerSize: .small)
@@ -13,24 +29,196 @@ final class StickerBrowserViewController: MSStickerBrowserViewController {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
setupTabBar()
}
// MARK: - Data
func reloadStickers() {
let urls = StickerStore.shared.stickerFileURLs()
stickers = urls.compactMap { url in
try? MSSticker(
contentsOfFileURL: url,
localizedDescription: url.deletingLastPathComponent().lastPathComponent
)
packs = StickerStore.shared.savedPacks
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
.compactMap { pack in
let urls = StickerStore.shared.stickerFileURLs(forPack: pack.name)
let stickers = urls.compactMap { url in
try? MSSticker(contentsOfFileURL: url, localizedDescription: pack.title)
}
guard !stickers.isEmpty else { return nil }
let thumbnail = StickerStore.shared.thumbnail(forPack: pack.name)
return Pack(savedPack: pack, thumbnail: thumbnail, stickers: stickers)
}
if let index = selectedPackIndex, index >= packs.count {
selectedPackIndex = nil
}
updateDisplayedStickers()
tabCollectionView?.reloadData()
}
private func updateDisplayedStickers() {
if let index = selectedPackIndex {
displayedStickers = packs[index].stickers
} else {
displayedStickers = packs.flatMap(\.stickers)
}
stickerBrowserView.reloadData()
}
// MARK: - Tab bar
private func setupTabBar() {
let tabBarHeight = tabSize + tabInset * 2
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = tabSpacing
layout.minimumLineSpacing = tabSpacing
layout.sectionInset = UIEdgeInsets(top: 0, left: tabInset, bottom: 0, right: tabInset)
layout.itemSize = CGSize(width: tabSize, height: tabSize)
tabCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
tabCollectionView.translatesAutoresizingMaskIntoConstraints = false
tabCollectionView.backgroundColor = .secondarySystemBackground
tabCollectionView.showsHorizontalScrollIndicator = false
tabCollectionView.dataSource = self
tabCollectionView.delegate = self
tabCollectionView.register(TabCell.self, forCellWithReuseIdentifier: TabCell.reuseID)
let separator = UIView()
separator.translatesAutoresizingMaskIntoConstraints = false
separator.backgroundColor = .separator
view.addSubview(tabCollectionView)
view.addSubview(separator)
NSLayoutConstraint.activate([
tabCollectionView.topAnchor.constraint(equalTo: view.topAnchor),
tabCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tabCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tabCollectionView.heightAnchor.constraint(equalToConstant: tabBarHeight),
separator.topAnchor.constraint(equalTo: tabCollectionView.bottomAnchor),
separator.leadingAnchor.constraint(equalTo: view.leadingAnchor),
separator.trailingAnchor.constraint(equalTo: view.trailingAnchor),
separator.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale),
])
additionalSafeAreaInsets = UIEdgeInsets(top: tabBarHeight, left: 0, bottom: 0, right: 0)
}
private func selectPack(at index: Int?) {
guard selectedPackIndex != index else { return }
selectedPackIndex = index
updateDisplayedStickers()
tabCollectionView.reloadData()
}
// MARK: - MSStickerBrowserViewDataSource
override func numberOfStickers(in stickerBrowserView: MSStickerBrowserView) -> Int {
stickers.count
displayedStickers.count
}
override func stickerBrowserView(_ stickerBrowserView: MSStickerBrowserView, stickerAt index: Int) -> MSSticker {
stickers[index]
displayedStickers[index]
}
}
// MARK: - UICollectionViewDataSource
extension StickerBrowserViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
packs.count + 1 // +1 for "All" tab
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabCell.reuseID, for: indexPath) as! TabCell
if indexPath.item == 0 {
let isSelected = selectedPackIndex == nil
cell.configureAsAll(isSelected: isSelected)
} else {
let pack = packs[indexPath.item - 1]
let isSelected = selectedPackIndex == indexPath.item - 1
cell.configure(image: pack.thumbnail, isSelected: isSelected)
}
return cell
}
}
// MARK: - UICollectionViewDelegate
extension StickerBrowserViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if indexPath.item == 0 {
selectPack(at: nil)
} else {
selectPack(at: indexPath.item - 1)
}
}
}
// MARK: - TabCell
private final class TabCell: UICollectionViewCell {
static let reuseID = "TabCell"
private let imageView = UIImageView()
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.layer.cornerRadius = 8
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
label.font = .systemFont(ofSize: 11, weight: .medium)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
label.isHidden = true
contentView.addSubview(label)
let inset: CGFloat = 4
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset),
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func configure(image: UIImage?, isSelected: Bool) {
imageView.image = image
imageView.isHidden = false
label.isHidden = true
contentView.backgroundColor = isSelected ? .systemGray5 : .clear
imageView.alpha = isSelected ? 1.0 : 0.5
}
func configureAsAll(isSelected: Bool) {
imageView.isHidden = true
label.isHidden = false
label.text = "All"
label.textColor = isSelected ? .label : .secondaryLabel
contentView.backgroundColor = isSelected ? .systemGray5 : .clear
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
imageView.isHidden = false
label.isHidden = true
}
}

View File

@@ -73,13 +73,6 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3FD2D01B2F57098E00B76B50 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3FD2CFFD2F57098D00B76B50 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -88,6 +81,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
3FD2D01B2F57098E00B76B50 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -226,14 +226,14 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3FD2D01A2F57098E00B76B50 /* Sources */ = {
3FD2CFFC2F57098D00B76B50 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3FD2CFFC2F57098D00B76B50 /* Sources */ = {
3FD2D01A2F57098E00B76B50 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -255,6 +255,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon";
ASSETCATALOG_COMPILER_STICKER_ICON_NAME = "iMessage App Icon";
CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -285,6 +286,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon";
ASSETCATALOG_COMPILER_STICKER_ICON_NAME = "iMessage App Icon";
CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;

View File

@@ -41,13 +41,30 @@ struct AddPackView: View {
Text("Paste a Telegram sticker link or pack name.")
}
if !packs.isEmpty {
if packs.isEmpty {
Section {
Button {
urlText = "https://t.me/addstickers/MrBat"
} label: {
HStack(spacing: 12) {
Image(systemName: "star")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text("MrBat")
Text("Tap to try this pack")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
} header: {
Text("Suggestions")
}
} else {
Section("Saved Packs") {
ForEach(packs, id: \.name) { pack in
HStack(spacing: 12) {
if let url = StickerStore.shared.firstStickerURL(forPack: pack.name),
let data = try? Data(contentsOf: url),
let uiImage = UIImage(data: data) {
if let uiImage = StickerStore.shared.thumbnail(forPack: pack.name) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)