ios_app_v2/yobble/Views/Tab/Settings/ActiveSessionsView.swift
2025-10-24 10:20:20 +03:00

319 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct ActiveSessionsView: View {
@State private var sessions: [SessionViewData] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var revokeInProgress = false
@State private var activeAlert: SessionsAlert?
@State private var showRevokeConfirmation = false
private let sessionsService = SessionsService()
private var currentSession: SessionViewData? {
sessions.first { $0.isCurrent }
}
private var otherSessions: [SessionViewData] {
sessions.filter { !$0.isCurrent }
}
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 {
HStack {
Text(NSLocalizedString("Всего сессий", comment: "Сводка по количеству сессий"))
.font(.subheadline)
Spacer()
Text("\(sessions.count)")
.font(.subheadline.weight(.semibold))
}
if !otherSessions.isEmpty {
Text(String(format: NSLocalizedString("Сессий на других устройствах: %d", comment: "Количество сессий на других устройствах"), otherSessions.count))
.font(.footnote)
.foregroundColor(.secondary)
}
}
Section(header: Text(NSLocalizedString("Это устройство", comment: "Заголовок секции текущего устройства"))) {
if let currentSession {
sessionRow(for: currentSession)
} else {
Text(NSLocalizedString("Текущая сессия не найдена", comment: "Сообщение об отсутствии текущей сессии"))
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 8)
}
}
if !otherSessions.isEmpty {
Section(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) {
ForEach(otherSessions) { session in
sessionRow(for: session)
}
}
Section {
revokeOtherSessionsButton
}
}
}
}
.navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
.navigationBarTitleDisplayMode(.inline)
.task {
await loadSessions()
}
.refreshable {
await loadSessions(force: true)
}
.alert(item: $activeAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
)
}
.confirmationDialog(
NSLocalizedString("Завершить сессии на других устройствах?", comment: "Заголовок подтверждения завершения сессий"),
isPresented: $showRevokeConfirmation
) {
Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения других сессий"), role: .destructive) {
Task { await revokeOtherSessions() }
}
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
} message: {
Text(NSLocalizedString("Вы выйдете со всех устройств, кроме текущего.", comment: "Описание подтверждения завершения сессий"))
}
}
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.firstLoginText, systemImage: "calendar")
.font(.caption)
.foregroundColor(.secondary)
Label(session.lastLoginText, systemImage: "arrow.clockwise")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 6)
}
@MainActor
private func loadSessions(force: Bool = false) async {
if isLoading && !force {
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
}
@MainActor
private func revokeOtherSessions() async {
if revokeInProgress {
return
}
revokeInProgress = true
defer { revokeInProgress = false }
do {
let message = try await sessionsService.revokeAllExceptCurrent()
activeAlert = SessionsAlert(
title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
message: message
)
await loadSessions(force: true)
} catch {
activeAlert = SessionsAlert(
title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
message: error.localizedDescription
)
}
}
private var revokeOtherSessionsButton: some View {
let primaryColor: Color = revokeInProgress ? .secondary : .red
return Button {
if !revokeInProgress {
showRevokeConfirmation = true
}
} label: {
HStack(spacing: 12) {
if revokeInProgress {
ProgressView()
.progressViewStyle(.circular)
} else {
Image(systemName: "xmark.circle")
.foregroundColor(primaryColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(NSLocalizedString("Завершить другие сессии", comment: "Кнопка завершения других сессий"))
.foregroundColor(primaryColor)
Text(NSLocalizedString("Текущая сессия останется активной", comment: "Подсказка под кнопкой завершения других сессий"))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(revokeInProgress)
}
}
private struct SessionViewData: Identifiable, Equatable {
let id: UUID
let ipAddress: String?
let userAgent: String?
let clientType: String
let isActive: Bool
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.isActive = payload.isActive
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 firstLoginText: String {
let formatted = Self.dateFormatter.string(from: createdAt)
return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted)
}
var lastLoginText: 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
}()
}
private struct SessionsAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}