From 26534e88c16e98d2a34a437cc1e36ce257bda329 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Fri, 24 Oct 2025 10:06:26 +0300 Subject: [PATCH] add session list --- yobble/Network/SessionsService.swift | 179 ++++++++++++++++ yobble/Resources/Localizable.xcstrings | 37 +++- .../Tab/Settings/ActiveSessionsView.swift | 197 ++++++++++++++++++ yobble/Views/Tab/Settings/SettingsView.swift | 4 +- 4 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 yobble/Network/SessionsService.swift create mode 100644 yobble/Views/Tab/Settings/ActiveSessionsView.swift diff --git a/yobble/Network/SessionsService.swift b/yobble/Network/SessionsService.swift new file mode 100644 index 0000000..b6de65d --- /dev/null +++ b/yobble/Network/SessionsService.swift @@ -0,0 +1,179 @@ +import Foundation + +enum SessionsServiceError: LocalizedError { + case unexpectedStatus(String) + case decoding(debugDescription: String) + + var errorDescription: String? { + switch self { + case .unexpectedStatus(let message): + return message + case .decoding(let debugDescription): + return AppConfig.DEBUG + ? debugDescription + : NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service decoding error") + } + } +} + +struct UserSessionPayload: Decodable { + let id: UUID + let ipAddress: String? + let userAgent: String? + let clientType: String + let isActive: Bool + let createdAt: Date + let lastRefreshAt: Date + let isCurrent: Bool +} + +private struct SessionsListPayload: Decodable { + let sessions: [UserSessionPayload] +} + +final class SessionsService { + private let client: NetworkClient + private let decoder: JSONDecoder + + init(client: NetworkClient = .shared) { + self.client = client + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) + } + + func fetchSessions(completion: @escaping (Result<[UserSessionPayload], Error>) -> Void) { + client.request( + path: "/v1/auth/sessions/list", + method: .get, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service unexpected status") + completion(.failure(SessionsServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data.sessions)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[SessionsService] decode sessions failed: \(debugMessage)") + } + completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(SessionsServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func fetchSessions() async throws -> [UserSessionPayload] { + try await withCheckedThrowingContinuation { continuation in + fetchSessions { result in + continuation.resume(with: result) + } + } + } + + private static func decodeDate(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = iso8601WithFractionalSeconds.date(from: string) { + return date + } + if let date = iso8601Simple.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Невозможно декодировать дату: \(string)" + ) + } + + private static func describeDecodingError(error: Error, data: Data) -> String { + var parts: [String] = [] + + if let decodingError = error as? DecodingError { + parts.append(decodingDescription(from: decodingError)) + } else { + parts.append(error.localizedDescription) + } + + if let payload = truncatedPayload(from: data) { + parts.append("payload=\(payload)") + } + + return parts.joined(separator: "\n") + } + + private static func decodingDescription(from error: DecodingError) -> String { + switch error { + case .typeMismatch(let type, let context): + return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .valueNotFound(let type, let context): + return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .keyNotFound(let key, let context): + return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)" + case .dataCorrupted(let context): + return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)" + @unknown default: + return error.localizedDescription + } + } + + private static func codingPath(from context: DecodingError.Context) -> String { + let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty } + return path.isEmpty ? "root" : path.joined(separator: ".") + } + + private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? { + guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty else { + return nil + } + + if string.count <= limit { + return string + } + + let index = string.index(string.startIndex, offsetBy: limit) + return String(string[string.startIndex.. String? { + if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + if let detail = apiError.detail, !detail.isEmpty { + return detail + } + if let message = apiError.data?.message, !message.isEmpty { + return message + } + } + if let string = String(data: data, encoding: .utf8), !string.isEmpty { + return string + } + return nil + } + + private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601Simple: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index be23183..cf8e6b8 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -198,6 +198,7 @@ } }, "Активные сессии" : { + "comment" : "Заголовок экрана активных сессий", "localizations" : { "en" : { "stringUnit" : { @@ -207,6 +208,9 @@ } } }, + "Активные сессии не найдены" : { + "comment" : "Пустой список активных сессий" + }, "Аудио" : { "comment" : "Audio message placeholder" }, @@ -226,6 +230,9 @@ "Блокировка контакта \"%1$@\" появится позже." : { "comment" : "Contacts block placeholder message" }, + "Бот" : { + "comment" : "Тип сессии — бот" + }, "В чате пока нет сообщений." : { }, @@ -240,6 +247,9 @@ } } }, + "Веб" : { + "comment" : "Тип сессии — веб" + }, "Версия:" : { "localizations" : { "en" : { @@ -288,6 +298,9 @@ }, "Вложение" : { + }, + "Войдите с другого устройства, чтобы увидеть его здесь." : { + "comment" : "Подсказка при отсутствии активных сессий" }, "Войти" : { "localizations" : { @@ -390,6 +403,9 @@ } } }, + "Десктоп" : { + "comment" : "Тип сессии — десктоп" + }, "Добавить друзей" : { "comment" : "Add friends", "localizations" : { @@ -434,9 +450,6 @@ }, "Заглушка: Push-уведомления" : { - }, - "Заглушка: Активные сессии" : { - }, "Заглушка: Двухфакторная аутентификация" : { @@ -791,6 +804,9 @@ } } }, + "Мобильное приложение" : { + "comment" : "Тип сессии — мобильное приложение" + }, "Мои загрузки" : { "comment" : "My Downloads", "localizations" : { @@ -959,6 +975,9 @@ "Не удалось загрузить профиль." : { "comment" : "Profile service decoding error\nProfile unexpected status" }, + "Не удалось загрузить список сессий." : { + "comment" : "Sessions service decoding error\nSessions service unexpected status" + }, "Не удалось загрузить список чатов." : { "localizations" : { "en" : { @@ -1686,6 +1705,9 @@ }, "Попробуйте изменить запрос поиска." : { + }, + "Последнее обновление: %@" : { + "comment" : "Дата последнего обновления сессии" }, "Похвала" : { "comment" : "feedback category: praise", @@ -2044,6 +2066,9 @@ } } }, + "Сессии" : { + "comment" : "Активные сессии - заголовок секции" + }, "Сессия истекла. Войдите снова." : { "comment" : "Chat creation unauthorized\nSearch error unauthorized", "localizations" : { @@ -2123,6 +2148,9 @@ }, "Согласиться с правилами" : { + }, + "Создана: %@" : { + "comment" : "Дата создания сессии" }, "Сообщение" : { @@ -2196,6 +2224,9 @@ }, "Стикеры" : { + }, + "Текущая" : { + "comment" : "Маркер текущей сессии" }, "Тема: %@" : { "comment" : "feedback: success category", diff --git a/yobble/Views/Tab/Settings/ActiveSessionsView.swift b/yobble/Views/Tab/Settings/ActiveSessionsView.swift new file mode 100644 index 0000000..cf72945 --- /dev/null +++ b/yobble/Views/Tab/Settings/ActiveSessionsView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct ActiveSessionsView: View { + @State private var sessions: [SessionViewData] = [] + @State private var isLoading = false + @State private var loadError: String? + + private let sessionsService = SessionsService() + + var body: some View { + List { + if isLoading && sessions.isEmpty { + loadingState + } else if let loadError, sessions.isEmpty { + errorState(loadError) + } else if sessions.isEmpty { + emptyState + } else { + Section(header: Text(NSLocalizedString("Сессии", comment: "Активные сессии - заголовок секции"))) { + ForEach(sessions) { session in + sessionRow(for: session) + } + } + } + } + .navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий")) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadSessions() + } + .refreshable { + await loadSessions() + } + } + + private var loadingState: some View { + Section { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private func errorState(_ message: String) -> some View { + Section { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + Text(message) + .font(.body) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + } + + private var emptyState: some View { + Section { + VStack(spacing: 12) { + Image(systemName: "iphone") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text(NSLocalizedString("Активные сессии не найдены", comment: "Пустой список активных сессий")) + .font(.headline) + .multilineTextAlignment(.center) + Text(NSLocalizedString("Войдите с другого устройства, чтобы увидеть его здесь.", comment: "Подсказка при отсутствии активных сессий")) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + .listRowSeparator(.hidden) + } + + private func sessionRow(for session: SessionViewData) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(session.clientTypeDisplay) + .font(.headline) + if let ip = session.ipAddress, !ip.isEmpty { + Label(ip, systemImage: "globe") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + Spacer() + if session.isCurrent { + Text(NSLocalizedString("Текущая", comment: "Маркер текущей сессии")) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + } + + if let userAgent = session.userAgent, !userAgent.isEmpty { + Text(userAgent) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(3) + } + + VStack(alignment: .leading, spacing: 6) { + Label(session.createdAtText, systemImage: "calendar") + .font(.caption) + .foregroundColor(.secondary) + Label(session.lastRefreshText, systemImage: "arrow.clockwise") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 6) + } + + @MainActor + private func loadSessions() async { + if isLoading { + return + } + + isLoading = true + loadError = nil + + do { + let payloads = try await sessionsService.fetchSessions() + sessions = payloads.map(SessionViewData.init) + } catch { + loadError = error.localizedDescription + if AppConfig.DEBUG { + print("[ActiveSessionsView] load sessions failed: \(error)") + } + } + + isLoading = false + } +} + +private struct SessionViewData: Identifiable, Equatable { + let id: UUID + let ipAddress: String? + let userAgent: String? + let clientType: String + let createdAt: Date + let lastRefreshAt: Date + let isCurrent: Bool + + init(payload: UserSessionPayload) { + self.id = payload.id + self.ipAddress = payload.ipAddress + self.userAgent = payload.userAgent + self.clientType = payload.clientType + self.createdAt = payload.createdAt + self.lastRefreshAt = payload.lastRefreshAt + self.isCurrent = payload.isCurrent + } + + var clientTypeDisplay: String { + let normalized = clientType.lowercased() + switch normalized { + case "mobile": + return NSLocalizedString("Мобильное приложение", comment: "Тип сессии — мобильное приложение") + case "web": + return NSLocalizedString("Веб", comment: "Тип сессии — веб") + case "desktop": + return NSLocalizedString("Десктоп", comment: "Тип сессии — десктоп") + case "bot": + return NSLocalizedString("Бот", comment: "Тип сессии — бот") + default: + return clientType.capitalized + } + } + + var createdAtText: String { + let formatted = Self.dateFormatter.string(from: createdAt) + return String(format: NSLocalizedString("Создана: %@", comment: "Дата создания сессии"), formatted) + } + + var lastRefreshText: String { + let formatted = Self.dateFormatter.string(from: lastRefreshAt) + return String(format: NSLocalizedString("Последнее обновление: %@", comment: "Дата последнего обновления сессии"), formatted) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} diff --git a/yobble/Views/Tab/Settings/SettingsView.swift b/yobble/Views/Tab/Settings/SettingsView.swift index feacf72..3f4efec 100644 --- a/yobble/Views/Tab/Settings/SettingsView.swift +++ b/yobble/Views/Tab/Settings/SettingsView.swift @@ -35,8 +35,8 @@ struct SettingsView: View { NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) { Label("Двухфакторная аутентификация", systemImage: "lock.shield") } - NavigationLink(destination: Text("Заглушка: Активные сессии")) { - Label("Активные сессии", systemImage: "iphone") + NavigationLink(destination: ActiveSessionsView()) { + Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone") } }