add session list
This commit is contained in:
parent
7034503983
commit
26534e88c1
179
yobble/Network/SessionsService.swift
Normal file
179
yobble/Network/SessionsService.swift
Normal file
@ -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<SessionsListPayload>.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..<index]) + "…"
|
||||
}
|
||||
|
||||
private static func errorMessage(from data: Data) -> 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
|
||||
}()
|
||||
}
|
||||
@ -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",
|
||||
|
||||
197
yobble/Views/Tab/Settings/ActiveSessionsView.swift
Normal file
197
yobble/Views/Tab/Settings/ActiveSessionsView.swift
Normal file
@ -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
|
||||
}()
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user