Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions SwiftUI-WorkoutApp.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

12 changes: 0 additions & 12 deletions SwiftUI-WorkoutApp/Extensions/Array+.swift

This file was deleted.

12 changes: 12 additions & 0 deletions SwiftUI-WorkoutApp/Extensions/Date+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

extension Date: @retroactive RawRepresentable {
public var rawValue: String {
String(timeIntervalSinceReferenceDate)
}

public init?(rawValue: String) {
guard let interval = Double(rawValue) else { return nil }
self = Date(timeIntervalSinceReferenceDate: interval)
}
}
10 changes: 10 additions & 0 deletions SwiftUI-WorkoutApp/Extensions/String+localized.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

extension String {
/// Локализованный вариант с использованием `NSLocalizedString`
///
/// Нужен для особых случаев, когда не работает дефолтная локализация
var localized: String {
NSLocalizedString(self, comment: "")
}
}
24 changes: 24 additions & 0 deletions SwiftUI-WorkoutApp/Extensions/UIImage+toMediaFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SWModels
import UIKit.UIImage

extension UIImage {
/// Делает медиафайл из картинки
///
/// Сначала пробуем конвертацию через jpegData для скорости, но в случае проблем обращаемся к `UIGraphicsImageRenderer` для
/// гарантированного результата
func toMediaFile(with key: String = "") -> MediaFile? {
let data = jpegData(compressionQuality: 1) ?? UIGraphicsImageRenderer(size: size).jpegData(withCompressionQuality: 1) { _ in
draw(in: .init(), blendMode: .normal, alpha: 1)
}
return data.isEmpty ? nil : MediaFile(imageData: data, forKey: key)
}
}

extension [UIImage] {
/// Создает список медиафайлов из картинок для отправки на сервер
var toMediaFiles: [MediaFile] {
enumerated().compactMap { index, image in
image.toMediaFile(with: "\(index + 1)")
}
}
}
8 changes: 8 additions & 0 deletions SwiftUI-WorkoutApp/Libraries/SWAlert/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
11 changes: 11 additions & 0 deletions SwiftUI-WorkoutApp/Libraries/SWAlert/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "SWAlert",
platforms: [.iOS(.v15)],
products: [.library(name: "SWAlert", targets: ["SWAlert"])],
targets: [.target(name: "SWAlert")]
)
3 changes: 3 additions & 0 deletions SwiftUI-WorkoutApp/Libraries/SWAlert/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SWAlert

Содержит синглтон для отображения алерта с любого экрана поверх активного `UIWindow`.
74 changes: 74 additions & 0 deletions SwiftUI-WorkoutApp/Libraries/SWAlert/Sources/SWAlert/SWAlert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import SwiftUI
import UIKit

@MainActor
public final class SWAlert {
public static let shared = SWAlert()
private var currentAlert: UIViewController?

/// Показывает системный алерт с заданными параметрами
/// - Parameters:
/// - title: Заголовок. Если передать `nil`, то сообщение выделится жирным. Если передать текст или пустую строку, будет без
/// заголовка, и сообщение будет со стандартным шрифтом
/// - message: Текст сообщения
/// - closeButtonTitle: Заголовок кнопки для закрытия алерта
/// - closeButtonStyle: Стиль кнопки для закрытия алерта
/// - closeButtonTintColor: Цвет кнопки для закрытия алерта. Если не настроить явно, то при появлении будет системный (синий) цвет, а
/// при нажатии он изменится на `AccentColor` в проекте
public func presentDefaultUIKit(
title: String? = "",
message: String,
closeButtonTitle: String = "Ok",
closeButtonStyle: UIAlertAction.Style = .default,
closeButtonTintColor: UIColor? = .systemGreen
) {
guard currentAlert == nil, let topMostViewController else { return }
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.view.tintColor = closeButtonTintColor
alert.addAction(
.init(
title: closeButtonTitle,
style: closeButtonStyle,
handler: { [weak self] _ in
self?.dismiss()
}
)
)
currentAlert = alert
topMostViewController.present(alert, animated: true)
}

private func dismiss() {
currentAlert?.dismiss(animated: true)
currentAlert = nil
}

private var topMostViewController: UIViewController? {
UIApplication.shared.firstKeyWindow?.rootViewController?.topMostViewController
}
}

private extension UIApplication {
var firstKeyWindow: UIWindow? {
connectedScenes
.filter { $0.activationState == .foregroundActive }
.compactMap { $0 as? UIWindowScene }
.first?.windows
.first(where: \.isKeyWindow)
}
}

private extension UIViewController {
var topMostViewController: UIViewController {
if let presented = presentedViewController {
return presented.topMostViewController
}
if let navigation = self as? UINavigationController {
return navigation.visibleViewController?.topMostViewController ?? navigation
}
if let tab = self as? UITabBarController {
return tab.selectedViewController?.topMostViewController ?? tab
}
return self
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/// Используется во всех запросах, где нужна авторизация
public struct AuthData: Codable {
public let login, password: String

public var base64Encoded: String? {
(login + ":" + password).data(using: .utf8)?.base64EncodedString()
}
/// Используем для генерации токена
///
/// Например, при смене логина нужно сгенерировать новый токен, чтобы не выбросило из аккаунта - вот тут и используем этот пароль
public let password: String
/// Отправляем на сервер
public let token: String?

public init(login: String, password: String) {
self.login = login
self.token = (login + ":" + password).data(using: .utf8)?.base64EncodedString()
self.password = password
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,8 @@ import Foundation

@MainActor
public protocol DefaultsProtocol: AnyObject, Sendable {
var appTheme: AppColorTheme { get }
var mainUserInfo: UserResponse? { get }
var mainUserCountryID: Int { get }
var mainUserCityID: Int { get }
var needUpdateUser: Bool { get }
var isAuthorized: Bool { get }
var friendRequestsList: [UserResponse] { get }
var friendsIdsList: [Int] { get }
var blacklistedUsers: [UserResponse] { get }
var unreadMessagesCount: Int { get }
/// Дефолтная дата - предыдущее ручное обновление файла `countries.json`
var lastCountriesUpdateDate: Date { get }
func setAppTheme(_ theme: AppColorTheme)
func saveAuthData(_ info: AuthData) throws
func basicAuthInfo() throws -> AuthData
func setUserNeedUpdate(_ newValue: Bool)
/// Обновляет `lastCountriesUpdateDate`
func didUpdateCountries()
func saveUserInfo(_ info: UserResponse) throws
func saveFriendsIds(_ ids: [Int]) throws
func saveFriendRequests(_ array: [UserResponse]) throws
func saveUnreadMessagesCount(_ count: Int)
func saveBlacklist(_ array: [UserResponse]) throws
func updateBlacklist(option: BlacklistOption, user: UserResponse)
func setHasJournals(_ hasJournals: Bool)
func setHasParks(_ isAddedPark: Bool)
/// Токен авторизации для запросов к серверу
var authToken: String? { get }
/// Логаут с удалением всех данных пользователя
func triggerLogout()
}

extension Date: @retroactive RawRepresentable {
public var rawValue: String {
timeIntervalSinceReferenceDate.description
}

public init?(rawValue: String) {
self = Date(timeIntervalSinceReferenceDate: Double(rawValue) ?? 0.0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public struct MainUserForm: Codable, Equatable, Sendable {
public var genderCode: Int
public var country: Country
public var city: City
public var image: MediaFile?

public init(
userName: String,
Expand All @@ -17,7 +18,8 @@ public struct MainUserForm: Codable, Equatable, Sendable {
birthDate: Date,
gender: Int,
country: Country,
city: City
city: City,
image: MediaFile? = nil
) {
self.userName = userName
self.fullName = fullName
Expand All @@ -27,17 +29,20 @@ public struct MainUserForm: Codable, Equatable, Sendable {
self.country = country
self.city = city
self.genderCode = gender
self.image = image
}

public init(_ user: UserResponse) {
self.userName = user.userName ?? ""
self.fullName = user.fullName ?? ""
self.email = user.email ?? ""
self.password = ""
self.birthDate = user.birthDate
self.country = .init(cities: [], id: (user.countryID ?? 0).description, name: "")
self.city = .init(id: (user.cityID ?? 0).description)
self.genderCode = user.genderCode ?? 0
self.init(
userName: user.userName ?? "",
fullName: user.fullName ?? "",
email: user.email ?? "",
password: "",
birthDate: user.birthDate,
gender: user.genderCode ?? 0,
country: .init(cities: [], id: (user.countryID ?? 0).description, name: ""),
city: .init(id: (user.cityID ?? 0).description)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public struct MediaFile: Codable, Equatable, Sendable {
public let data: Data
public let mimeType: String

/// Инициализатор для добавления фото площадки/мероприятия
/// - Parameters:
/// - imageData: Данные для картинки
/// - key: Индекс
public init(imageData: Data, forKey key: String) {
self.key = "photo\(key)"
self.mimeType = "image/jpeg"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ public enum BodyMaker {
var body = Data()
if !parameters.isEmpty {
parameters.forEach { element in
body.append("--\(boundary + lineBreak)")
body.append("--\(boundary)\(lineBreak)")
body.append("Content-Disposition: form-data; name=\"\(element.key)\"\(lineBreak + lineBreak)")
body.append("\(element.value + lineBreak)")
body.append("\(element.value)\(lineBreak)")
}
}
if let media, !media.isEmpty {
media.forEach { photo in
body.append("--\(boundary + lineBreak)")
body.append("--\(boundary)\(lineBreak)")
body.append("Content-Disposition: form-data; name=\"\(photo.key)\"; filename=\"\(photo.filename)\"\(lineBreak)")
body.append("Content-Type: \(photo.mimeType + lineBreak + lineBreak)")
body.append("Content-Type: \(photo.mimeType)\(lineBreak + lineBreak)")
body.append(photo.data)
body.append(lineBreak)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

extension Data {
var prettyJson: String {
if let object = try? JSONSerialization.jsonObject(with: self, options: [.fragmentsAllowed]),
if let object = try? JSONSerialization.jsonObject(with: self, options: []),
let jsonData = try? JSONSerialization.data(withJSONObject: object, options: .prettyPrinted),
let json = String(data: jsonData, encoding: .utf8) {
json
Expand All @@ -11,7 +11,7 @@ extension Data {
}
}

public mutating func append(_ string: String) {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
append(data)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,4 @@ public struct HTTPHeaderField: Equatable {
self.key = key
self.value = value
}

public static func authorizationBasic(_ token: String) -> HTTPHeaderField {
.init(key: "Authorization", value: "Basic \(token)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,38 @@ public struct RequestComponents {
let path: String
let queryItems: [URLQueryItem]
let httpMethod: HTTPMethod
public var headerFields: [HTTPHeaderField]
let hasMultipartFormData: Bool
let body: Data?
/// Токен для авторизации
public var token: String?
let token: String?

/// Инициализатор
/// - Parameters:
/// - path: Путь запроса
/// - queryItems: Параметры `query`, по умолчанию отсутствуют
/// - httpMethod: Метод запроса
/// - headerFields: Параметры хедеров, по умолчанию отсутствуют
/// - hasMultipartFormData: Есть ли в запросе файлы для отправки (в нашем случае картинки), по умолчанию `false`
/// - body: Тело запроса, по умолчанию `nil`
/// - token: Токен для авторизации, по умолчанию `nil`
public init(
path: String,
queryItems: [URLQueryItem] = [],
httpMethod: HTTPMethod,
headerFields: [HTTPHeaderField] = [],
hasMultipartFormData: Bool = false,
body: Data? = nil,
token: String? = nil
) {
self.path = path
self.queryItems = queryItems
self.httpMethod = httpMethod
self.headerFields = headerFields
self.hasMultipartFormData = hasMultipartFormData
self.body = body
self.token = token
}

var url: URL? {
let scheme = "https"
let host = "workout.su/api/v3"
guard path.starts(with: "/") else { return nil }
let stringComponents = "\(scheme)://\(host)\(path)"
var components = URLComponents(string: stringComponents)
if !queryItems.isEmpty {
Expand All @@ -51,9 +51,16 @@ extension RequestComponents {
var request = URLRequest(url: url)
request.httpMethod = httpMethod.rawValue
request.httpBody = body
var allHeaders = headerFields
if let token {
allHeaders.append(.authorizationBasic(token))
var allHeaders = [HTTPHeaderField]()
// TODO: генерировать boundary в одном месте (вместо FFF)
if let body {
allHeaders.append(.init(key: "Content-Length", value: "\(body.count)"))
}
if hasMultipartFormData {
allHeaders.append(.init(key: "Content-Type", value: "multipart/form-data; boundary=FFF"))
}
if let token, !token.isEmpty {
allHeaders.append(.init(key: "Authorization", value: "Basic \(token)"))
}
request.allHTTPHeaderFields = Dictionary(uniqueKeysWithValues: allHeaders.map { ($0.key, $0.value) })
return request
Expand Down
Loading