Skip to content

Share#170

Merged
amikhaylin merged 11 commits intomasterfrom
share
Feb 12, 2026
Merged

Share#170
amikhaylin merged 11 commits intomasterfrom
share

Conversation

@amikhaylin
Copy link
Copy Markdown
Owner

Share extension for iOS (Safari)

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Замечания и предложения по улучшению:

  • Ограниченность правила активации:

    • Сейчас расширение появится для текста и «web page» (Safari-специфичный тип). Если вы хотите получать обычные URL из большинства приложений (Messages, Mail, Notes, Files и т. д.), добавьте
      • NSExtensionActivationSupportsWebURLWithMaxCount = 1
    • Если вы не обрабатываете URL вовсе, уберите NSExtensionActivationSupportsWebPageWithMaxCount, чтобы не «подсвечиваться» зря в Safari.
  • Явность поддерживаемых типов:

    • Если планируется работа с файлами/изображениями/видео — добавьте соответствующие ключи:
      • NSExtensionActivationSupportsImageWithMaxCount
      • NSExtensionActivationSupportsMovieWithMaxCount
      • NSExtensionActivationSupportsAudioWithMaxCount
      • NSExtensionActivationSupportsFileWithMaxCount
    • Иначе — держите правило максимально узким (например, только Text), чтобы не попадать в лишние сценарии.
  • Количество элементов:

    • Вы задали maxCount = 1 только для WebPage. Если код внутри ожидает ровно один элемент, убедитесь, что вы корректно обрабатываете кейсы, когда прилетает больше одного вложения (даже если правило не идеально отфильтровало). Либо явно добавьте/уточните maxCount для нужных типов, либо переходите на NSPredicate-правило.
  • Точность фильтрации через NSPredicate:

    • Сложные условия (например, «ровно один URL ИЛИ текст без вложений») лучше описывать NSPredicate-строкой в NSExtensionActivationRule. Это дает более детальный контроль и уменьшает «шум» в системном листе шаринга. Если сейчас достаточно простых типов — можно оставить как есть, но имейте в виду переход на предикат при усложнении логики.
  • Principal class:

    • Убедитесь, что класс ShareViewController:
      • Находится в таргете расширения.
      • Видим для Obj‑C runtime (обычно достаточно наследования от NSObject/UIViewController; при кастомных именованиях можно добавить @objc(ShareViewController)).
      • Если используете SwiftUI, убедитесь, что контроллер корректно хостит SwiftUI-вью и не зависит от ресурсов, отсутствующих в бандле расширения.
  • Покрытие сценариев за пределами Safari:

    • Ключ WebPageWithMaxCount работает по сути для Safari. Если цель — появляться в большинстве приложений при шаринге ссылок, без WebURLWithMaxCount вы будете пропускать значительную часть сценариев.
  • Диагностика на этапе интеграции:

    • Для быстрой проверки «проводки» можно временно поставить NSExtensionActivationRule = TRUEPREDICATE, убедиться, что расширение поднимается, а затем вернуть точные условия.

Итог: уточните список реально поддерживаемых типов и отразите это в NSExtensionActivationRule (минимум добавьте NSExtensionActivationSupportsWebURLWithMaxCount = 1, если нужны обычные URL). При усложнении условий переключайтесь на NSPredicate для более точного фильтра. Убедитесь, что PrincipalClass корректно доступен из бандла расширения.

<string>group.com.amikhaylin.PomPadDo</string>
</array>
</dict>
</plist>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ниже — только то, что может быть проблемой или требует улучшения.

  • Убедитесь, что файл действительно подключен как entitlements-файл:

    • Имя файла должно иметь расширение .entitlements, а не .plist.
    • В Build Settings каждого нужного таргета (и для Debug, и для Release) проверьте CODE_SIGN_ENTITLEMENTS указывает на этот файл.
  • Подпись и портал разработчика:

    • App Group group.com.amikhaylin.PomPadDo должен быть создан в Apple Developer Portal и привязан к нужным App ID.
    • Профили подписи должны быть обновлены/пересозданы, иначе на устройстве получите mismatch по entitlements.
  • Все таргеты, которые будут использовать общий контейнер, должны иметь одинаковый App Group:

    • Основное приложение + все расширения (Widget, Intents, Share и т.д.).
    • Если какой-то из них не имеет эту группу, FileManager.containerURL(...) и UserDefaults(suiteName:) вернут nil.
  • macOS/Catalyst: без App Sandbox эта группа будет игнорироваться.

    • Добавьте com.apple.security.app-sandbox = true для соответствующих таргетов macOS/Catalyst.
  • Кодовая сторона (устойчивость):

    • Не делайте force unwrap для UserDefaults(suiteName:) и FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:). В превью/тестах/при сбое в подписи они могут вернуть nil — логируйте/фейлите только в DEBUG.
    • Имеет смысл добавить runtime-проверку в DEBUG и чёткое сообщение, если контейнер не получен.
  • Разделение сред:

    • Рассмотрите отдельные группы для dev/stage/prod (например, group.com.amikhaylin.PomPadDo.dev), переключаемые через .xcconfig. Это спасает от «засорения» боевых данных тестовыми.
  • WatchOS нюанс:

    • App Group не шарит файлы между iPhone и Apple Watch (разные девайсы). Для синхронизации с часами используйте WatchConnectivity. App Group актуален для расширений на том же устройстве (например, Widget).
  • Миграции данных:

    • Если переносите хранилище в App Group (например, с UserDefaults по умолчанию на shared UserDefaults), продумайте одноразовую миграцию, иначе пользователи «потеряют» старые данные.
  • Проверка сборки:

    • После сборки проверьте фактические entitlements: codesign -d --entitlements :- /path/to/App.app (или к расширению) и убедитесь, что группа действительно вшита.
  • Нотация идентификатора:

    • Старайтесь придерживаться единообразного стиля (обычно нижний регистр) и не менять регистр в разных местах проекта — идентификаторы чувствительны к точному совпадению.


link = url
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ниже — только существенные замечания и предложения улучшений.

  • Отмена через cancelRequest: сейчас создается кастомная ошибка. Для корректной семантики “пользователь отменил” используйте стандартный NSError с доменом/кодом расширений. Например: NSError(domain: NSExtensionErrorDomain, code: NSExtensionErrorCode.userCanceled.rawValue, userInfo: nil). Это важно для хост-приложения и системных логов.

  • Надежность сохранения SwiftData:

    • После modelContext.insert(task) явно вызывайте save() и обрабатывайте ошибку перед completeRequest. В расширении процесс может быстро выгружаться, на автосейв лучше не полагаться.
    • При ошибке сохранения корректно завершайте через cancelRequest(withError:), чтобы не создать ложное впечатление, что “сохранилось”.
  • Изоляция по актору:

    • Пометьте View и экшены как @mainactor (или хотя бы методы, где меняется @State и используется modelContext/NSExtensionContext). Это снимет потенциальные гонки, особенно после await в loadWebpageMetadata, и успокоит статический анализатор.
  • Нейминг и читаемость:

    • В loadWebpageMetadata(for context:) параметр затеняет одноименное свойство. Переименуйте параметр в extensionContext.
    • В saveAction переменная task конфликтует по смыслу с Swift Concurrency “Task” и .task модификатором. Лучше назвать todo или item.
  • Обработка вложений:

    • Код, судя по комментариям, ожидает только URL. На практике в Share Extension часто прилетают: public.url, public.plain-text, fragmentы текста, изображения. Имеет смысл:
      • Попробовать URL, затем падать обратно на текст (plainText) и заполнять text им.
      • Если URL нет, но есть текст — всё равно дать сохранить.
      • Если есть несколько вложений — брать первое подходящее по приоритету.
    • Если расширения для firstAttachment/ofType и loadURL не покрывают эти кейсы — расширьте.
  • UX/валидация ввода:

    • Запретите “Save”, если text пустой или из пробелов; подсказка/placeholder для TextEditor улучшит UX.
    • Пока грузится metadata — либо покажите ProgressView, либо предварительно заполните text чем-то быстрым (например, host из URL), а потом обновите заголовком.
    • Хорошая идея — дизейблить “Save” до окончания первой попытки метаданных либо до того, как пользователь что-то введет.
  • LPMetadataProvider:

    • Добавьте защиту от долгих запросов/отмены: проверяйте Task.isCancelled и корректно выходите. В случае CancellationError не печатайте ошибки.
    • На ошибке сейчас просто print(error). Используйте os.Logger, чтобы не засорять консоль продакшена. И желательно деградировать к базовому заполнению (например, text = url.host ?? url.absoluteString).
  • Превью-изображение:

    • Порядок модификаторов лучше сделать как .resizable().scaledToFill().frame(...).clipped(). Иначе возможны артефакты кадрирования/неожиданная геометрия.
    • Добавьте .accessibilityLabel для картинки.
  • Layout TextEditor в HStack:

    • Вероятно потребуется .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) у TextEditor, чтобы он не схлопывался из-за фиксированного превью.
    • Подумайте о .scrollContentBackground(.hidden) и отдельном фоне, если нужен визуальный паритет со стандартным контроллером Share.
  • Модель данных:

    • Если возможно, храните ссылку как URL в модели (SwiftData умеет), а не String. Это даст типовую безопасность и меньше ручной сериализации.
    • Тримминг текста перед сохранением (whitespacesAndNewlines).
  • Импорт и мелочи:

    • UniformTypeIdentifiers в этом файле напрямую не используется — можно удалить импорт (если утилиты расширений в другом файле — ок, но держите импорт там).
    • В completeRequest можно передавать nil вместо пустого массива, если возвращать нечего.
    • Локализация строк “Cancel”/“Save”.
  • Интеграция с основной БД:

    • Убедитесь, что контейнер SwiftData для расширения настроен на App Group, иначе сохранения будут уходить в отдельное хранилище расширения и не появятся в основном приложении.

Эти правки повысят надежность (сохранение/отмена), устойчивость к разным типам шэринга, улучшат UX и уберут потенциальные проблемы с акторами и лэйаутом.

}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ниже только существенные замечания и рекомендации по улучшению/исправлению.

Критичные проблемы

  • Нет импорта UIKit. Класс наследуется от UIViewController и использует UIHostingController, но UIKit закомментирован. Это не скомпилируется. Уберите import Social и верните import UIKit. Social.framework сейчас не нужен и помечен как deprecated для Share Extensions.
  • Жизненный цикл дочернего контроллера. После addChild(contentView) нужно вызывать contentView.didMove(toParent: self), иначе нарушен контракт контейнерного контроллера.
  • SwiftData в экстеншне: конфигурация хранилища.
    • Если цель — шарить данные с основным приложением, нужно хранить базу в App Group, иначе у экстеншна будет свой отдельный стор. Задайте URL из контейнера App Group (или соответствующий параметр groupContainer, если используете этот API).
    • Не полагайтесь на дефолтный путь. Примерно: получить URL через FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.your.id") и передать его в ModelConfiguration(url:).
    • Избегайте fatalError при создании контейнера в экстеншне. Лучше показать пользователю понятную ошибку и завершить контекст: extensionContext.cancelRequest(withError:).
  • Завершение экстеншна. Убедитесь, что из SwiftUI-экрана в конце вызывается extensionContext.completeRequest(returningItems:completionHandler:) или cancelRequest(withError:). Без этого система может убить экстеншн по таймауту.
  • NSItemProvider.loadURL: вы используете неизвестный async-API loadItem(forTypeIdentifier:) как await. В стандартном SDK этот метод колбэчный. Сделайте собственный async-обертку через withCheckedThrowingContinuation, как вы сделали для картинок, или используйте современные typed-API (см. ниже).

Надежность и совместимость

  • Поиск вложений: сейчас вы проверяете только первый NSExtensionItem. В шэршите часто бывает несколько inputItems и несколько attachments. Лучше пройти по всем и взять первое подходящее:
    • Итерируйте по всем inputItems и вложениям, проверяя hasItemConforming…
    • Используйте typed-варианты API, где возможно: hasItemConforming(to: UTType) вместо строкового hasItemConformingToTypeIdentifier.
  • Загрузка URL:
    • Для файлов разумнее сначала пробовать loadInPlaceFileRepresentation(for:) (даёт file URL без копирования), а для веб-URL fallback на loadItem/данные.
    • Будьте готовы, что провайдер вернёт не URL, а, например, NSString со строкой URL — добавьте распознавание таких кейсов (String/NSData -> URL).
  • Загрузка изображений:
    • Предпочтительно использовать canLoadObject(ofClass:) и loadObject(ofClass: UIImage.self) — это снимает с вас декодирование и лучше работает с разными типами (HEIC/PNG/JPEG).
    • Если остаётесь на loadDataRepresentation(for: .image), оставьте как есть, но добавьте ограничение размера/ресемплинг больших изображений, чтобы не перегружать экстеншн по памяти.
  • Потоки/акторы: колбэки NSItemProvider не гарантируют главный поток. Если из результата обновляете UI/модель, переключайтесь на MainActor в нужных местах.
  • Ошибки. ShareExtensionError лучше сделать LocalizedError с понятными описаниями для показа пользователю (и логов). Сейчас .itemTypecastFailed(.image) не даёт дружественного текста.

Архитектура/устойчивость

  • Инициализация ModelContainer:
    • Сейчас контейнер создаётся как let-свойство с немедленной инициализацией. Сделайте lazy var инициализацию, чтобы не тратить время/память, если контроллер внезапно выгружается до показа UI.
    • Сведите конфигурацию SwiftData (Schema, ModelConfiguration) в общий модуль, который использует и приложение, и экстеншн. Это снизит риск размытия схемы между таргетами.
  • Обработка сбоев вместо крашей: fatalError в экстеншне — плохой UX. Любые неожиданные ошибки — показать компактный экран ошибки и корректно отменить запрос.

Мелкие замечания

  • Явно распаковуйте extensionContext: лучше читается guard let context = self.extensionContext else { … }.
  • При добавлении UIHostingController можно задать backgroundColor у корневого view (иногда иначе виден “черный фон” на переходах).
  • firstAttachment(ofType:) возвращает только NSItemProvider; возможно, удобнее сразу вернуть загруженный объект/данные с учётом типа (например, URL или UIImage) вторыми методами, чтобы не дублировать логику загрузки в вызывающем коде.

Намётки правок (без лишней болтовни)

  • Импорты:
    • Удалить import Social.
    • Раскомментировать import UIKit.
  • Добавить contentView.didMove(toParent: self) после добавления дочернего VC.
  • Конфигурация SwiftData с App Group:
    • Получить URL общего контейнера App Group и передать в ModelConfiguration(url: …). Обрабатывать ошибки без fatalError и завершать экстеншн.
  • NSItemProvider:
    • Переписать loadURL на withCheckedThrowingContinuation обертку для loadItem или использовать loadInPlaceFileRepresentation для файлов, плюс фолбэк на строку.
    • Для изображений — loadObject(ofClass: UIImage.self) с canLoadObject проверкой.
  • NSExtensionContext:
    • firstAttachment(ofType:) — проход по всем inputItems/attachments.
  • ShareExtensionError: реализовать LocalizedError.
  • В конечной точке сценария — обязательно completeRequest/cancelRequest.

};
4D2867632D61F5C20064ADF3 /* Build configuration list for PBXNativeTarget "PomPadDo.mobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ниже — только те замечания, которые реально могут повлиять на сборку, корректность расширения и последующую публикацию.

Критичные проблемы

  • IPHONEOS_DEPLOYMENT_TARGET = 26.2 в новом таргете PomPadDo.mobile.share — это неверное значение (такой версии iOS не существует). Поставьте реальную минимальную версию iOS, согласованную с основным приложением (например 16.0/17.0/18.0). Сейчас таргет не соберётся/не подпишется корректно.
  • MARKETING_VERSION = 26.1 у расширения не согласована с остальными таргетами (у них 1.0). Приведите к единому семантическому versioning (либо поднимите всем, либо используйте отдельную схему версионирования осознанно).
  • В таргете расширения нет APPLICATION_EXTENSION_API_ONLY = YES. Это обязательно для app extension, чтобы отловить запрещённые API (UIApplication.shared и т.п.) на этапе компиляции.
  • Отображаемое имя расширения. INFOPLIST_KEY_CFBundleDisplayName = PomPadDo.mobile.share — это то, что увидит пользователь в системном шите. Лучше задать человекочитаемое имя (например «В PomPadDo» или «PomPadDo Share»).
  • Info.plist: вы одновременно указываете GENERATE_INFOPLIST_FILE = YES и INFOPLIST_FILE = PomPadDo.mobile.share/Info.plist. Оставьте один источник правды. Если храните собственный Info.plist, выключите автогенерацию и убедитесь, что:
    • Прописан NSExtension с корректным NSExtensionPointIdentifier (для Share Extension это com.apple.share-services).
    • Задан NSExtensionPrincipalClass или NSExtensionMainStoryboard (если используете SwiftUI — обычно это хостинг-контроллер вашего корневого вью).
    • Указаны нужные NSExtensionAttributes и правило активации NSExtensionActivationRule.
  • Подпись. В новом таргете не задан DEVELOPMENT_TEAM. Если вы рассчитываете на проектный дефолт — проверьте, что он реально выставлен на уровне проекта. Иначе автоматический кодсайн расширения будет падать.
  • App Groups. Для обмена данными между приложением и шаринг-расширением нужны App Groups в энтайтламентах обоих таргетов. Сейчас в новом таргете есть entitlements-файл, но нет упоминания про группы. Если планируете сохранять/читать общие данные (SwiftData/CoreData/файлы/Keychain), добавьте общий App Group и синхронизируйте в обоих таргетах.
  • Пустые билд-фазы Sources/Resources у PomPadDo.mobile.share. Даже если вы используете «синхронизированные группы» Xcode, убедитесь, что:
    • В Sources действительно попал файл точки входа расширения (ShareViewController/ShareView и т.п.).
    • Подключены необходимые ресурсы (Assets, локализации) и фреймворки (SwiftUI, UniformTypeIdentifiers, LinkPresentation и т.д. — обычно подтягиваются автоматически при импорте).
    • Иначе соберётся «пустой» appex без точки входа.

Архитектура и согласованность

  • PRODUCT_BUNDLE_IDENTIFIER = com.amikhaylin.PomPadDo.PomPadDo-mobile-share. Технически дефис допустим, но для единообразия и читабельности лучше использовать точечное пространство имён, например com.amikhaylin.PomPadDo.share или com.amikhaylin.PomPadDo.ShareExtension.
  • TARGETED_DEVICE_FAMILY = "1,2". Синхронизируйте с хост-приложением: если мобильное приложение только для iPhone, расширение тоже можно ограничить «1». Если обоих — оставляйте «1,2».
  • SWIFT_VERSION = 5.0 у расширения, тогда как проект, судя по Xcode 16.x, фактически компилируется Swift 5.9/5.10. Лучше выровнять с остальными таргетами или вовсе не переопределять, чтобы не получить неожиданные ворнинги/диагностику.
  • SWIFT_APPROACHABLE_CONCURRENCY и SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY включены только для расширения. Либо включайте их консистентно для всех таргетов, либо отключите в расширении, чтобы не поймать рассинхронизацию поведения компилятора.
  • PBXTargetDependency на расширение из главного приложения, при том что appex уже добавлен в «Embed Foundation Extensions», избыточен. Встраивание appex само добавляет зависимость на сборку. Можно удалить PBXTargetDependency, чтобы упростить граф зависимостей.
  • PBXFileSystemSynchronizedBuildFileExceptionSet:
    • Есть набор 4D7AF8122F32417F00FBFFFA, привязанный к группе PomPadDo.mobile, но относится к таргету расширения. Это выглядит как случайная привязка к неправильной группе. Держите исключения для одного таргета в соответствующем корневом group (Shared или PomPadDo.mobile.share), чтобы Xcode не «перетасовывал» membership неожиданно.
    • Проверьте, что в расширение не «протекли» файлы, использующие запрещённые API приложения (например, ваш Utils/NotificationManager может использовать UIApplication — для extension это нельзя). Либо исключите такие файлы из membership расширения, либо выделите «extension-safe» обёртки.
  • Frameworks в таргете расширения пуст — это ок, если вы используете только Swift модульные зависимости, которые подтянутся автоматически. Но если используете пакеты/библиотеки (например, ваш SwiftDataTransferrable), не забудьте добавить product dependency в packageProductDependencies у расширения.
  • Known regions: добавлен Base в PBXProject. Убедитесь, что соответствующие Base.lproj реально добавлены, иначе получите ворнинги локализации.

Практические правки (рекомендуемый минимум)

  • Для таргета PomPadDo.mobile.share:
    • APPLICATION_EXTENSION_API_ONLY = YES
    • IPHONEOS_DEPLOYMENT_TARGET = 17.0 (или ваш проектный минимум)
    • MARKETING_VERSION = 1.0 (или общий для проекта)
    • DEVELOPMENT_TEAM = 9Z68336878 (если это дефолт команды)
    • Выберите один вариант Info.plist: либо уберите GENERATE_INFOPLIST_FILE, либо уберите INFOPLIST_FILE и вынесенные ключи — и убедитесь, что NSExtension настроен для share extension.
    • Задайте человекочитаемый INFOPLIST_KEY_CFBundleDisplayName (например «PomPadDo»).
    • Добавьте APPLICATION_GROUPS в entitlements и синхронизируйте с хостом.
  • Приведите исключения PBXFileSystemSynchronizedBuildFileExceptionSet к ожидаемым группам и исключите из membership расширения всё, что использует не extension-safe API.
  • Пересмотрите необходимость PBXTargetDependency от хоста к appex — вероятно, можно удалить.
  • Проверьте, что у расширения есть как минимум 1 исходный файл с точкой входа, и он действительно попадает в Sources этого таргета.

Что проверить дополнительно вручную

  • Что расширение видит общий стор (через App Group) и корректно работает с вашим storage-слоем (SwiftData/CoreData/файлы), не лезет в контейнер приложения напрямую.
  • Что на симуляторе и на девайсе appex корректно появляется в системном шаринге (правильное правило активации NSExtensionActivationRule).
  • Что размер appex разумный (не протащили тяжёлые ресурсы из основного таргета по ошибке).

Исправив перечисленное, вы избавитесь от фэйлов сборки/подписания и от потенциального ревью-реджекта за использование не extension-safe API.

import Combine

enum FocusTimerState: String {
case idle
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Наблюдения и предложения по улучшению:

  • Импорт Combine

    • Если Combine в этом файле не используется — удалите импорт.
    • Если вы добавляете Combine только ради таймера (Timer.publish), в iOS 17+/Swift Concurrency лучше использовать Task + Clock/Task.sleep или AsyncTimerSequence (swift-async-algorithms). Это проще, не требует AnyCancellable и лучше сочетается с Observation.
    • Если всё‑таки используете Combine, убедитесь, что:
      • Подписки хранятся в стабильном reference‑типе (final class), а не в @observable struct, иначе AnyCancellable потеряется при копировании.
      • Отписываетесь вовремя (очищаете набор cancellables) и избегаете retain‑циклов (weak self в sink).
      • Мутации состояния происходят на главном потоке (@mainactor), иначе SwiftUI/Observation может получать гонки.
  • Сочетание Observation и Combine

    • Не смешивайте @observable и @Published/ObservableObject в одном модельном типе без крайней необходимости. Выберите один подход. Для iOS 17+ предпочтительнее Observation.
    • Если используете Combine‑паблишеры только для связи со SwiftUI, часто хватит .onReceive в View, без протаскивания AnyCancellable в модель.
  • FocusTimerState

    • Задумайтесь, нужен ли rawValue = String. Часто достаточно простого enum без rawValue. String‑raw значения хрупкие при рефакторинге.
    • Если требуется персистенция/логирование — лучше Codable с явным контролем над версиями. При декодировании добавьте обработку неизвестных значений (например, маппить на .idle).
    • Если есть конечный автомат (idle/running/paused/finished), имеет смысл:
      • Явно зафиксировать допустимые переходы (например, методом canTransition(to:) или инкапсулировать логику переходов в модель).
      • Добавить удобные вычисления, типа isActive (running || paused), вместо работы со строковыми rawValue.
  • Потокобезопасность и аннотации

    • Если модель/состояние меняются из UI, пометьте модель @mainactor. Это избавит от неожиданных предупреждений и гонок при работе с Observation/SwiftUI.
  • Организация кода

    • Импорты держите минимальными и локальными. Если Combine нужен только в реализации таймера — импортируйте его там, где он реально используется.
    • Добавьте модульные тесты на переходы состояний таймера (особенно на граничные случаи: повторный старт, пауза после завершения, и т.д.).

Если покажете остальную часть файла (модель таймера, хранение cancellables/Task, точки мутаций), смогу дать более точечные рекомендации по архитектуре и управлению временем/отписками.

import Combine

struct Common {
static func formatSeconds(_ seconds: Int) -> String {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ниже — только предметные замечания по диффу и типичному содержимому такого утилитарного файла.

  • Лишние импорты:

    • Combine добавлен, но по фрагменту он не используется. Уберите, чтобы не тянуть лишние зависимости и ускорить билд.
    • Аналогично, если в этом файле реально не нужны SwiftData-типы, вынесите Common в отдельный файл без import SwiftData. Утилиты должны зависеть минимумом.
  • Антипаттерн “Common”:

    • Общие «свалки» растут бесконтрольно и ухудшают связность. Переименуйте в что-то предметное (например, TimeFormatting) или, лучше, используйте расширения соответствующих типов.
  • Сигнатура и типы:

    • formatSeconds(_ seconds: Int) ограничивает вас целыми секундами. Рассмотрите TimeInterval (Double) или стандартный Duration (Swift 5.9+) — гибче для будущего (доли секунды, точность).
    • Если по домену допустимы отрицательные значения/большие интервалы — определите поведение (обрезать до 0, показывать знак, поддерживать часы/дни).
  • Локализация и форматирование:

    • Если строка UI-направленная, предпочтительнее DateComponentsFormatter — он заботится о локали и нулях:
      • allowedUnits: [.hour, .minute, .second] по необходимости
      • unitsStyle: .positional
      • zeroFormattingBehavior: .pad
    • Если строго «мм:сс» и нужен предсказуемый вывод без оверхеда — вручную через String(format: "%02d:%02d"), но зафиксируйте требования к локали/часам в тестах и документации.
  • Производительность:

    • Не создавайте DateComponentsFormatter на каждый вызов. Кешируйте статически:
      static let mmss: DateComponentsFormatter = { ... }()
    • Если вызывается на таймере с высокой частотой, ручное форматирование через деление и String(format:) будет быстрее.
  • Дизайн API:

    • Избегайте статических «утилитных» типов. Лучше расширение:
      • extension Duration { var mmss: String { ... } }
      • или extension TimeInterval { var mmss: String { ... } }
    • Современный путь — собственный FormatStyle, чтобы использовать .formatted():
      • Text(duration, format: .mmss)
    • Если оставляете «простую» функцию — дайте более точное имя: formatSecondsToMMSS или format(duration:units:style:).
  • Доступность/инстанцирование:

    • Если тип остаётся «неймспейсом», сделайте его enum без кейсов, чтобы исключить создание экземпляров.
    • Явно задайте access control (internal/public) — по умолчанию internal может быть неочевиден для межмодульного использования.
  • Тесты и крайние случаи:

    • Тесты для 0, 1, 59, 60, 61, -1, больших значений (часы/дни), и стабильности локали.
    • Если поддерживаете часы, проверьте переход 3599→3600.

Пример улучшённой реализации (минимум зависимостей, быстрый путь, с часами и отрицательными значениями):

  • Вариант через расширение TimeInterval:

    • extension TimeInterval {
      func formattedMMSS(includeHours: Bool = false) -> String {
      var t = Int(self.rounded())
      let sign = t < 0 ? "-" : ""
      t = abs(t)
      let s = t % 60
      let m = (t / 60) % 60
      let h = t / 3600
      if includeHours || h > 0 {
      return "(sign)(String(format: "%d:%02d:%02d", h, m, s))"
      } else {
      return "(sign)(String(format: "%02d:%02d", m, s))"
      }
      }
      }
  • Вариант через DateComponentsFormatter c кешированием:

    • enum TimeFormatting {
      private static let mmss: DateComponentsFormatter = {
      let f = DateComponentsFormatter()
      f.allowedUnits = [.minute, .second]
      f.unitsStyle = .positional
      f.zeroFormattingBehavior = .pad
      return f
      }()
      private static let hhmmss: DateComponentsFormatter = {
      let f = DateComponentsFormatter()
      f.allowedUnits = [.hour, .minute, .second]
      f.unitsStyle = .positional
      f.zeroFormattingBehavior = .pad
      return f
      }()
      static func format(seconds: TimeInterval, includeHours: Bool = false) -> String {
      let absVal = abs(seconds)
      let base = includeHours || absVal >= 3600 ? hhmmss : mmss
      let sign = seconds < 0 ? "-" : ""
      return sign + (base.string(from: absVal) ?? "00:00")
      }
      }

Если проект на Swift 5.9+, рассмотрите кастомный FormatStyle для Duration — он хорошо вписывается в .formatted() и SwiftUI Text.

Итог по диффу: импорт Combine (и, вероятно, SwiftData) в этом файле лишний; «Common» стоит переосмыслить; улучшите сигнатуру и локализацию форматирования, добавьте кеширование форматтера и тесты на крайние случаи.

@amikhaylin amikhaylin merged commit b22703d into master Feb 12, 2026
1 check passed
@amikhaylin amikhaylin deleted the share branch February 12, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant