mv-experiment #1
116
ConversationsClassic/AppData/Client/Client.swift
Normal file
116
ConversationsClassic/AppData/Client/Client.swift
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
enum ClientState: Equatable {
|
||||||
|
enum ClientConnectionState {
|
||||||
|
case connected
|
||||||
|
case disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
case disabled
|
||||||
|
case enabled(ClientConnectionState)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Client: ObservableObject {
|
||||||
|
@Published private(set) var state: ClientState = .enabled(.disconnected)
|
||||||
|
@Published private(set) var credentials: Credentials
|
||||||
|
|
||||||
|
private var connection: XMPPClient
|
||||||
|
private var connectionCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
private var rosterManager = RosterManager()
|
||||||
|
|
||||||
|
init(credentials: Credentials) {
|
||||||
|
self.credentials = credentials
|
||||||
|
state = credentials.isActive ? .enabled(.disconnected) : .disabled
|
||||||
|
connection = Self.prepareConnection(credentials, rosterManager)
|
||||||
|
connectionCancellable = connection.$state
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard self.credentials.isActive else {
|
||||||
|
self.state = .disabled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case .connected:
|
||||||
|
self.state = .enabled(.connected)
|
||||||
|
|
||||||
|
default:
|
||||||
|
self.state = .enabled(.disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRoster(_ roster: Roster) async throws {
|
||||||
|
_ = try await connection.module(.roster).addItem(
|
||||||
|
jid: JID(roster.contactBareJid),
|
||||||
|
name: roster.name,
|
||||||
|
groups: roster.data.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRoster(_ roster: Roster) async throws {
|
||||||
|
_ = try await connection.module(.roster).removeItem(jid: JID(roster.contactBareJid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Client {
|
||||||
|
enum ClientLoginResult {
|
||||||
|
case success(Client)
|
||||||
|
case failure
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tryLogin(with credentials: Credentials) async -> ClientLoginResult {
|
||||||
|
let client = Client(credentials: credentials)
|
||||||
|
do {
|
||||||
|
try await client.connection.loginAndWait()
|
||||||
|
return .success(client)
|
||||||
|
} catch {
|
||||||
|
return .failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Client {
|
||||||
|
static func prepareConnection(_ credentials: Credentials, _ roster: RosterManager) -> XMPPClient {
|
||||||
|
let client = XMPPClient()
|
||||||
|
|
||||||
|
// register modules
|
||||||
|
// core modules RFC 6120
|
||||||
|
client.modulesManager.register(StreamFeaturesModule())
|
||||||
|
client.modulesManager.register(SaslModule())
|
||||||
|
client.modulesManager.register(AuthModule())
|
||||||
|
client.modulesManager.register(SessionEstablishmentModule())
|
||||||
|
client.modulesManager.register(ResourceBinderModule())
|
||||||
|
client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName)))
|
||||||
|
|
||||||
|
// messaging modules RFC 6121
|
||||||
|
client.modulesManager.register(RosterModule(rosterManager: roster))
|
||||||
|
client.modulesManager.register(PresenceModule())
|
||||||
|
|
||||||
|
// client.modulesManager.register(PubSubModule())
|
||||||
|
// client.modulesManager.register(MessageModule(chatManager: manager))
|
||||||
|
// client.modulesManager.register(MessageArchiveManagementModule())
|
||||||
|
|
||||||
|
// client.modulesManager.register(MessageCarbonsModule())
|
||||||
|
|
||||||
|
// file transfer modules
|
||||||
|
// client.modulesManager.register(HttpFileUploadModule())
|
||||||
|
|
||||||
|
// extensions
|
||||||
|
client.modulesManager.register(SoftwareVersionModule())
|
||||||
|
client.modulesManager.register(PingModule())
|
||||||
|
client.connectionConfiguration.userJid = .init(credentials.bareJid)
|
||||||
|
client.connectionConfiguration.credentials = .password(password: credentials.pass)
|
||||||
|
|
||||||
|
// group chats
|
||||||
|
// client.modulesManager.register(MucModule(roomManager: manager))
|
||||||
|
|
||||||
|
// channels
|
||||||
|
// client.modulesManager.register(MixModule(channelManager: manager))
|
||||||
|
|
||||||
|
// add client to clients
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
152
ConversationsClassic/AppData/Client/RosterManager.swift
Normal file
152
ConversationsClassic/AppData/Client/RosterManager.swift
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
final class RosterManager: Martin.RosterManager {
|
||||||
|
func clear(for context: Martin.Context) {
|
||||||
|
do {
|
||||||
|
try Database.shared.dbQueue.write { db in
|
||||||
|
try Roster
|
||||||
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
|
.deleteAll(db)
|
||||||
|
|
||||||
|
try RosterVersion
|
||||||
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error clearing roster: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
|
||||||
|
do {
|
||||||
|
let rosters: [Roster] = try Database.shared.dbQueue.read { db in
|
||||||
|
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
|
||||||
|
}
|
||||||
|
return rosters.map { roster in
|
||||||
|
RosterItemBase(
|
||||||
|
jid: JID(roster.bareJid),
|
||||||
|
name: roster.name,
|
||||||
|
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
||||||
|
groups: roster.data.groups,
|
||||||
|
ask: roster.ask,
|
||||||
|
annotations: roster.data.annotations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error fetching roster items: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
||||||
|
do {
|
||||||
|
let roster: Roster? = try Database.shared.dbQueue.read { db in
|
||||||
|
try Roster
|
||||||
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
|
.filter(Column("contactBareJid") == jid.stringValue)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
if let roster {
|
||||||
|
return RosterItemBase(
|
||||||
|
jid: JID(roster.bareJid),
|
||||||
|
name: roster.name,
|
||||||
|
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
||||||
|
groups: roster.data.groups,
|
||||||
|
ask: roster.ask,
|
||||||
|
annotations: roster.data.annotations
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error fetching roster item: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateItem(for context: Martin.Context, jid: Martin.JID, name: String?, subscription: Martin.RosterItemSubscription, groups: [String], ask: Bool, annotations: [Martin.RosterItemAnnotation]) -> (any Martin.RosterItemProtocol)? {
|
||||||
|
do {
|
||||||
|
let roster = Roster(
|
||||||
|
bareJid: context.userBareJid.stringValue,
|
||||||
|
contactBareJid: jid.stringValue,
|
||||||
|
name: name,
|
||||||
|
subscription: subscription.rawValue,
|
||||||
|
ask: ask,
|
||||||
|
data: DBRosterData(
|
||||||
|
groups: groups,
|
||||||
|
annotations: annotations
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try Database.shared.dbQueue.write { db in
|
||||||
|
try roster.save(db)
|
||||||
|
}
|
||||||
|
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error updating roster item: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
||||||
|
do {
|
||||||
|
let roster: Roster? = try Database.shared.dbQueue.read { db in
|
||||||
|
try Roster
|
||||||
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
|
.filter(Column("contactBareJid") == jid.stringValue)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
if let roster {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try roster.delete(db)
|
||||||
|
}
|
||||||
|
return RosterItemBase(
|
||||||
|
jid: JID(roster.bareJid),
|
||||||
|
name: roster.name,
|
||||||
|
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
||||||
|
groups: roster.data.groups,
|
||||||
|
ask: roster.ask,
|
||||||
|
annotations: roster.data.annotations
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func version(for context: Martin.Context) -> String? {
|
||||||
|
do {
|
||||||
|
let version: RosterVersion? = try Database.shared.dbQueue.read { db in
|
||||||
|
try RosterVersion
|
||||||
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
return version?.version
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(version: String?, for context: Martin.Context) {
|
||||||
|
guard let version else { return }
|
||||||
|
do {
|
||||||
|
try Database.shared.dbQueue.write { db in
|
||||||
|
let rosterVersion = RosterVersion(
|
||||||
|
bareJid: context.userBareJid.stringValue,
|
||||||
|
version: version
|
||||||
|
)
|
||||||
|
try rosterVersion.save(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error setting roster version: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialize(context _: Martin.Context) {}
|
||||||
|
func deinitialize(context _: Martin.Context) {}
|
||||||
|
}
|
33
ConversationsClassic/AppData/Model/Credentials.swift
Normal file
33
ConversationsClassic/AppData/Model/Credentials.swift
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Credentials: DBStorable, Hashable {
|
||||||
|
static let databaseTableName = "credentials"
|
||||||
|
|
||||||
|
var id: String { bareJid }
|
||||||
|
var bareJid: String
|
||||||
|
var pass: String
|
||||||
|
var isActive: Bool
|
||||||
|
|
||||||
|
func save() async throws {
|
||||||
|
let db = Database.shared.dbQueue
|
||||||
|
try await db.write { db in
|
||||||
|
try self.save(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() async throws {
|
||||||
|
let db = Database.shared.dbQueue
|
||||||
|
_ = try await db.write { db in
|
||||||
|
try self.delete(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extension Account: UniversalInputSelectionElement {
|
||||||
|
// var text: String? { bareJid }
|
||||||
|
// var icon: Image? { nil }
|
||||||
|
// }
|
||||||
|
//
|
71
ConversationsClassic/AppData/Model/Roster.swift
Normal file
71
ConversationsClassic/AppData/Model/Roster.swift
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
struct RosterVersion: DBStorable {
|
||||||
|
static let databaseTableName = "rosterVersions"
|
||||||
|
|
||||||
|
var bareJid: String
|
||||||
|
var version: String
|
||||||
|
var id: String { bareJid }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Roster: DBStorable {
|
||||||
|
static let databaseTableName = "rosters"
|
||||||
|
|
||||||
|
var bareJid: String = ""
|
||||||
|
var contactBareJid: String
|
||||||
|
var name: String?
|
||||||
|
var subscription: String
|
||||||
|
var ask: Bool
|
||||||
|
var data: DBRosterData
|
||||||
|
var locallyDeleted: Bool = false
|
||||||
|
|
||||||
|
var id: String { "\(bareJid)-\(contactBareJid)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DBRosterData: Codable, DatabaseValueConvertible {
|
||||||
|
let groups: [String]
|
||||||
|
let annotations: [RosterItemAnnotation]
|
||||||
|
|
||||||
|
public var databaseValue: DatabaseValue {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
let data = try! encoder.encode(self)
|
||||||
|
return data.databaseValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? {
|
||||||
|
guard let data = Data.fromDatabaseValue(dbValue) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
return try! decoder.decode(Self.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: DBRosterData, rhs: DBRosterData) -> Bool {
|
||||||
|
lhs.groups == rhs.groups && lhs.annotations == rhs.annotations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RosterItemAnnotation: Equatable {
|
||||||
|
public static func == (lhs: RosterItemAnnotation, rhs: RosterItemAnnotation) -> Bool {
|
||||||
|
lhs.type == rhs.type && lhs.values == rhs.values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Roster: Equatable {
|
||||||
|
static func == (lhs: Roster, rhs: Roster) -> Bool {
|
||||||
|
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Roster {
|
||||||
|
static func fetchAll(for jid: String) async throws -> [Roster] {
|
||||||
|
let rosters = try await Database.shared.dbQueue.read { db in
|
||||||
|
try Roster.filter(Column("bareJid") == jid).fetchAll(db)
|
||||||
|
}
|
||||||
|
return rosters
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
extension Database {
|
||||||
|
static var migrator: DatabaseMigrator = {
|
||||||
|
var migrator = DatabaseMigrator()
|
||||||
|
|
||||||
|
// flush db on schema change (only in DEV mode)
|
||||||
|
#if DEBUG
|
||||||
|
migrator.eraseDatabaseOnSchemaChange = true
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 1st migration - basic tables
|
||||||
|
migrator.registerMigration("Add basic tables") { db in
|
||||||
|
// credentials
|
||||||
|
try db.create(table: "credentials", options: [.ifNotExists]) { table in
|
||||||
|
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
||||||
|
table.column("pass", .text).notNull()
|
||||||
|
table.column("isActive", .boolean).notNull().defaults(to: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosters
|
||||||
|
try db.create(table: "rosterVersions", options: [.ifNotExists]) { table in
|
||||||
|
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
||||||
|
table.column("version", .text).notNull()
|
||||||
|
}
|
||||||
|
try db.create(table: "rosters", options: [.ifNotExists]) { table in
|
||||||
|
table.column("bareJid", .text).notNull()
|
||||||
|
table.column("contactBareJid", .text).notNull()
|
||||||
|
table.column("name", .text)
|
||||||
|
table.column("subscription", .text).notNull()
|
||||||
|
table.column("ask", .boolean).notNull().defaults(to: false)
|
||||||
|
table.column("data", .text).notNull()
|
||||||
|
table.primaryKey(["bareJid", "contactBareJid"], onConflict: .replace)
|
||||||
|
table.column("locallyDeleted", .boolean).notNull().defaults(to: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return migrator
|
||||||
|
return migrator
|
||||||
|
}()
|
||||||
|
}
|
55
ConversationsClassic/AppData/Services/Database.swift
Normal file
55
ConversationsClassic/AppData/Services/Database.swift
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Models protocol
|
||||||
|
typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRecord & TableRecord
|
||||||
|
|
||||||
|
// MARK: - Database init
|
||||||
|
final class Database {
|
||||||
|
static let shared = Database()
|
||||||
|
let dbQueue: DatabaseQueue
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
do {
|
||||||
|
// Create db folder if not exists
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let appSupportURL = try fileManager.url(
|
||||||
|
for: .applicationSupportDirectory, in: .userDomainMask,
|
||||||
|
appropriateFor: nil, create: true
|
||||||
|
)
|
||||||
|
let directoryURL = appSupportURL.appendingPathComponent("ConversationsClassic", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// Open or create the database
|
||||||
|
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
||||||
|
dbQueue = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
|
||||||
|
|
||||||
|
// Some debug info
|
||||||
|
#if DEBUG
|
||||||
|
print("Database path: \(databaseURL.path)")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Apply migrations
|
||||||
|
try Database.migrator.migrate(dbQueue)
|
||||||
|
} catch {
|
||||||
|
fatalError("Database initialization failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Config
|
||||||
|
private extension Database {
|
||||||
|
static let config: Configuration = {
|
||||||
|
var config = Configuration()
|
||||||
|
#if DEBUG
|
||||||
|
// verbose and debugging in DEBUG builds only.
|
||||||
|
config.publicStatementArguments = true
|
||||||
|
config.prepareDatabase { db in
|
||||||
|
db.trace { print("SQL> \($0)\n") }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return config
|
||||||
|
}()
|
||||||
|
}
|
42
ConversationsClassic/AppData/Services/Logger.swift
Normal file
42
ConversationsClassic/AppData/Services/Logger.swift
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
let isConsoleLoggingEnabled = false
|
||||||
|
|
||||||
|
enum LogLevels: String {
|
||||||
|
case info = "\u{F449}"
|
||||||
|
case warning = "\u{F071}"
|
||||||
|
case error = "\u{EA76}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For database errors logging
|
||||||
|
func logIt(_ level: LogLevels, _ message: String) {
|
||||||
|
#if DEBUG
|
||||||
|
let timeStr = dateFormatter.string(from: Date())
|
||||||
|
let str = "\(timeStr) \(level.rawValue) \(message)"
|
||||||
|
print(str)
|
||||||
|
if isConsoleLoggingEnabled {
|
||||||
|
NSLog(str)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateFormatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
|
||||||
|
formatter.dateFormat = "MM-dd HH:mm:ss.SSS"
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For thread debugging
|
||||||
|
func ptInfo(_ message: String) {
|
||||||
|
#if DEBUG
|
||||||
|
let timeStr = dateFormatter.string(from: Date())
|
||||||
|
let str = "\(timeStr) \(message) -> \(Thread.current), \(String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "no queue label")"
|
||||||
|
print(str)
|
||||||
|
if isConsoleLoggingEnabled {
|
||||||
|
NSLog(str)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal file
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import Combine
|
||||||
|
import Network
|
||||||
|
|
||||||
|
extension NWPathMonitor {
|
||||||
|
func paths() -> AsyncStream<NWPath> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
pathUpdateHandler = { path in
|
||||||
|
continuation.yield(path)
|
||||||
|
}
|
||||||
|
continuation.onTermination = { [weak self] _ in
|
||||||
|
self?.cancel()
|
||||||
|
}
|
||||||
|
start(queue: DispatchQueue(label: "NSPathMonitor.paths"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final actor NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
|
@Published private(set) var isOnline: Bool = false
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
Task(priority: .background) {
|
||||||
|
await startMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMonitoring() async {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
for await path in monitor.paths() {
|
||||||
|
isOnline = path.status == .satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
ConversationsClassic/AppData/Stores/ClientsStore.swift
Normal file
41
ConversationsClassic/AppData/Stores/ClientsStore.swift
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ClientsStore: ObservableObject {
|
||||||
|
static let shared = ClientsStore()
|
||||||
|
|
||||||
|
@Published private(set) var ready = false
|
||||||
|
@Published private(set) var clients: [Client] = []
|
||||||
|
|
||||||
|
func startFetching() {
|
||||||
|
Task {
|
||||||
|
let observation = ValueObservation.tracking(Credentials.fetchAll)
|
||||||
|
do {
|
||||||
|
for try await credentials in observation.values(in: Database.shared.dbQueue) {
|
||||||
|
processCredentials(credentials)
|
||||||
|
ready = true
|
||||||
|
print("Fetched \(credentials.count) credentials")
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNewClient(_ client: Client) {
|
||||||
|
clients.append(client)
|
||||||
|
Task(priority: .background) {
|
||||||
|
try? await client.credentials.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processCredentials(_ credentials: [Credentials]) {
|
||||||
|
let existsJids = clients.map { $0.credentials.bareJid }
|
||||||
|
let forAdd = credentials.filter { !existsJids.contains($0.bareJid) }
|
||||||
|
let forRemove = existsJids.filter { !credentials.map { $0.bareJid }.contains($0) }
|
||||||
|
|
||||||
|
var newClients = clients.filter { !forRemove.contains($0.credentials.bareJid) }
|
||||||
|
newClients.append(contentsOf: forAdd.map { Client(credentials: $0) })
|
||||||
|
clients = newClients
|
||||||
|
}
|
||||||
|
}
|
25
ConversationsClassic/AppData/Stores/NavigationStore.swift
Normal file
25
ConversationsClassic/AppData/Stores/NavigationStore.swift
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NavigationStore: ObservableObject {
|
||||||
|
enum Flow: Equatable {
|
||||||
|
enum Entering {
|
||||||
|
case welcome
|
||||||
|
case login
|
||||||
|
case registration
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Main {
|
||||||
|
case contacts
|
||||||
|
case conversations
|
||||||
|
case settings
|
||||||
|
}
|
||||||
|
|
||||||
|
case start
|
||||||
|
case entering(Entering)
|
||||||
|
case main(Main)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var flow: Flow = .start
|
||||||
|
}
|
21
ConversationsClassic/AppData/Stores/RostersStore.swift
Normal file
21
ConversationsClassic/AppData/Stores/RostersStore.swift
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RostersStore: ObservableObject {
|
||||||
|
@Published private(set) var rosters: [Roster] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Task {
|
||||||
|
// let observation = ValueObservation.tracking(Roster.fetchAll)
|
||||||
|
// do {
|
||||||
|
// for try await credentials in observation.values(in: Database.shared.dbQueue) {
|
||||||
|
// processCredentials(credentials)
|
||||||
|
// ready = true
|
||||||
|
// print("Fetched \(credentials.count) credentials")
|
||||||
|
// }
|
||||||
|
// } catch {}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,17 @@
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
let appState = AppState()
|
|
||||||
let store = AppStore(
|
|
||||||
initialState: appState,
|
|
||||||
reducer: AppState.reducer,
|
|
||||||
middlewares: [
|
|
||||||
loggerMiddleware(),
|
|
||||||
StartMiddleware.shared.middleware,
|
|
||||||
DatabaseMiddleware.shared.middleware,
|
|
||||||
AccountsMiddleware.shared.middleware,
|
|
||||||
XMPPMiddleware.shared.middleware,
|
|
||||||
RostersMiddleware.shared.middleware,
|
|
||||||
ChatsMiddleware.shared.middleware,
|
|
||||||
ArchivedMessagesMiddleware.shared.middleware,
|
|
||||||
ConversationMiddleware.shared.middleware,
|
|
||||||
SharingMiddleware.shared.middleware,
|
|
||||||
FileMiddleware.shared.middleware
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
|
@MainActor
|
||||||
struct ConversationsClassic: App {
|
struct ConversationsClassic: App {
|
||||||
|
private var clientsStore = ClientsStore()
|
||||||
|
private var navigationStore = NavigationStore()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
BaseNavigationView()
|
AppRootView()
|
||||||
.environmentObject(store)
|
.environmentObject(navigationStore)
|
||||||
|
.environmentObject(clientsStore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,28 +36,28 @@ extension String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
// extension String {
|
||||||
var attachmentType: MessageAttachmentType {
|
// var attachmentType: MessageAttachmentType {
|
||||||
let ext = (self as NSString).pathExtension.lowercased()
|
// let ext = (self as NSString).pathExtension.lowercased()
|
||||||
|
//
|
||||||
switch ext {
|
// switch ext {
|
||||||
case "mov", "mp4", "avi":
|
// case "mov", "mp4", "avi":
|
||||||
return .movie
|
// return .movie
|
||||||
|
//
|
||||||
case "jpg", "png", "gif":
|
// case "jpg", "png", "gif":
|
||||||
return .image
|
// return .image
|
||||||
|
//
|
||||||
case "mp3", "wav", "m4a":
|
// case "mp3", "wav", "m4a":
|
||||||
return .audio
|
// return .audio
|
||||||
|
//
|
||||||
case "txt", "doc", "pdf":
|
// case "txt", "doc", "pdf":
|
||||||
return .file
|
// return .file
|
||||||
|
//
|
||||||
default:
|
// default:
|
||||||
return .file
|
// return .file
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
var firstLetterColor: Color {
|
var firstLetterColor: Color {
|
||||||
|
|
|
@ -5,21 +5,39 @@
|
||||||
"Global.cancel" = "Cancel";
|
"Global.cancel" = "Cancel";
|
||||||
"Global.save" = "Save";
|
"Global.save" = "Save";
|
||||||
"Global.Error.title" = "Error";
|
"Global.Error.title" = "Error";
|
||||||
"Global.Error.genericText" = "Something went wrong";
|
|
||||||
"Global.Error.genericDbError" = "Database error";
|
|
||||||
|
|
||||||
// MARK: Onboar screen
|
// MARK: Welcome screen
|
||||||
"Start.subtitle" = "Free and secure messaging and calls between any existed messengers";
|
"Start.subtitle" = "Free and secure messaging and calls between any existed messengers";
|
||||||
"Start.Btn.login" = "Enter with JID";
|
"Start.Btn.login" = "Enter with JID";
|
||||||
"Start.Btn.register" = "New Account";
|
"Start.Btn.register" = "New Account";
|
||||||
|
|
||||||
|
// MARK: Login
|
||||||
"Login.title" = "Let\'s go!";
|
"Login.title" = "Let\'s go!";
|
||||||
"Login.subtitle" = "Enter your JID, it should looks like email address";
|
"Login.subtitle" = "Enter your JID, it should looks like email address";
|
||||||
"Login.Hint.jid" = "user@domain.im";
|
"Login.Hint.jid" = "user@domain.im";
|
||||||
"Login.Hint.password" = "password";
|
"Login.Hint.password" = "password";
|
||||||
"Login.btn" = "Continue";
|
"Login.btn" = "Continue";
|
||||||
"Login.Error.wrongPassword" = "Wrong password or JID";
|
"Login.error" = "Check internet connection, and make sure that JID and password are correct";
|
||||||
"Login.Error.noServer" = "Server not exists";
|
|
||||||
"Login.Error.serverError" = "Server error. Check internet connection";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Onboar screen
|
||||||
|
//"Login.title" = "Let\'s go!";
|
||||||
|
//"Login.subtitle" = "Enter your JID, it should looks like email address";
|
||||||
|
//"Login.Hint.jid" = "user@domain.im";
|
||||||
|
//"Login.Hint.password" = "password";
|
||||||
|
//"Login.btn" = "Continue";
|
||||||
|
//"Login.Error.wrongPassword" = "Wrong password or JID";
|
||||||
|
//"Login.Error.noServer" = "Server not exists";
|
||||||
|
//"Login.Error.serverError" = "Server error. Check internet connection";
|
||||||
|
|
||||||
// MARK: Contacts screen
|
// MARK: Contacts screen
|
||||||
"Contacts.title" = "Contacts";
|
"Contacts.title" = "Contacts";
|
||||||
|
|
36
ConversationsClassic/UIToolkit/View+Loader.swift
Normal file
36
ConversationsClassic/UIToolkit/View+Loader.swift
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
func loadingOverlay() -> some View {
|
||||||
|
modifier(LoadingOverlay())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadingOverlay: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
ZStack {
|
||||||
|
content
|
||||||
|
loadingView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
GeometryReader { proxyReader in
|
||||||
|
ZStack {
|
||||||
|
Color.Material.Elements.active.opacity(0.3)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
// loader
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(
|
||||||
|
CircularProgressViewStyle(tint: .Material.Elements.active)
|
||||||
|
)
|
||||||
|
.position(x: proxyReader.size.width / 2, y: proxyReader.size.height / 2)
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.1)))
|
||||||
|
}
|
||||||
|
}
|
38
ConversationsClassic/View/AppRootView.swift
Normal file
38
ConversationsClassic/View/AppRootView.swift
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppRootView: View {
|
||||||
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch navigation.flow {
|
||||||
|
case .start:
|
||||||
|
StartScreen()
|
||||||
|
|
||||||
|
case .entering(let entering):
|
||||||
|
switch entering {
|
||||||
|
case .welcome:
|
||||||
|
WelcomeScreen()
|
||||||
|
|
||||||
|
case .login:
|
||||||
|
LoginScreen()
|
||||||
|
|
||||||
|
case .registration:
|
||||||
|
RegistrationScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .main(let main):
|
||||||
|
switch main {
|
||||||
|
case .contacts:
|
||||||
|
ContactsScreen()
|
||||||
|
|
||||||
|
case .conversations:
|
||||||
|
EmptyView()
|
||||||
|
|
||||||
|
case .settings:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
172
ConversationsClassic/View/Contacts/ContactsScreen.swift
Normal file
172
ConversationsClassic/View/Contacts/ContactsScreen.swift
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContactsScreen: View {
|
||||||
|
@EnvironmentObject var clientsStore: ClientsStore
|
||||||
|
// @State private var addPanelPresented = false
|
||||||
|
// @State private var isErrorAlertPresented = false
|
||||||
|
// @State private var errorAlertMessage = ""
|
||||||
|
// @State private var isShowingLoader = false
|
||||||
|
|
||||||
|
@State private var rosters: [Roster] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background color
|
||||||
|
Color.Material.Background.light
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Content
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
SharedNavigationBar(
|
||||||
|
centerText: .init(text: L10n.Contacts.title),
|
||||||
|
rightButton: .init(
|
||||||
|
image: Image(systemName: "plus"),
|
||||||
|
action: {
|
||||||
|
// addPanelPresented = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contacts list
|
||||||
|
if !rosters.isEmpty {
|
||||||
|
List {
|
||||||
|
ForEach(rosters) { roster in
|
||||||
|
ContactsScreenRow(
|
||||||
|
roster: roster
|
||||||
|
// isErrorAlertPresented: $isErrorAlertPresented,
|
||||||
|
// errorAlertMessage: $errorAlertMessage,
|
||||||
|
// isShowingLoader: $isShowingLoader
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.background(Color.Material.Background.light)
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
SharedTabBar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await fetchRosters()
|
||||||
|
}
|
||||||
|
// .loadingIndicator(isShowingLoader)
|
||||||
|
// .fullScreenCover(isPresented: $addPanelPresented) {
|
||||||
|
// AddContactOrChannelScreen(isPresented: $addPanelPresented)
|
||||||
|
// }
|
||||||
|
// .alert(isPresented: $isErrorAlertPresented) {
|
||||||
|
// Alert(
|
||||||
|
// title: Text(L10n.Global.Error.title),
|
||||||
|
// message: Text(errorAlertMessage),
|
||||||
|
// dismissButton: .default(Text(L10n.Global.ok))
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRosters() async {
|
||||||
|
let jids = clientsStore.clients
|
||||||
|
.filter { $0.state != .disabled }
|
||||||
|
.map { $0.credentials.bareJid }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await withThrowingTaskGroup(of: [Roster].self) { group in
|
||||||
|
for jid in jids {
|
||||||
|
group.addTask {
|
||||||
|
try await Roster.fetchAll(for: jid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allRosters: [Roster] = []
|
||||||
|
for try await rosters in group {
|
||||||
|
allRosters.append(contentsOf: rosters)
|
||||||
|
}
|
||||||
|
self.rosters = allRosters.sorted { $0.contactBareJid < $1.contactBareJid }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactsScreenRow: View {
|
||||||
|
var roster: Roster
|
||||||
|
// @State private var isShowingMenu = false
|
||||||
|
// @State private var isDeleteAlertPresented = false
|
||||||
|
//
|
||||||
|
// @Binding var isErrorAlertPresented: Bool
|
||||||
|
// @Binding var errorAlertMessage: String
|
||||||
|
// @Binding var isShowingLoader: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SharedListRow(
|
||||||
|
iconType: .charCircle(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter),
|
||||||
|
text: roster.contactBareJid
|
||||||
|
)
|
||||||
|
// .onTapGesture {
|
||||||
|
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
|
||||||
|
// }
|
||||||
|
// .onLongPressGesture {
|
||||||
|
// isShowingMenu.toggle()
|
||||||
|
// }
|
||||||
|
// .swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
// Button {
|
||||||
|
// isDeleteAlertPresented = true
|
||||||
|
// } label: {
|
||||||
|
// Label(L10n.Contacts.sendMessage, systemImage: "trash")
|
||||||
|
// }
|
||||||
|
// .tint(Color.red)
|
||||||
|
// }
|
||||||
|
// .contextMenu {
|
||||||
|
// Button(L10n.Contacts.sendMessage, systemImage: "message") {
|
||||||
|
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
|
||||||
|
// }
|
||||||
|
// Divider()
|
||||||
|
//
|
||||||
|
// Button(L10n.Contacts.editContact) {
|
||||||
|
// print("Edit contact")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Button(L10n.Contacts.selectContact) {
|
||||||
|
// print("Select contact")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Divider()
|
||||||
|
// Button(L10n.Contacts.deleteContact, systemImage: "trash", role: .destructive) {
|
||||||
|
// isDeleteAlertPresented = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// .actionSheet(isPresented: $isDeleteAlertPresented) {
|
||||||
|
// ActionSheet(
|
||||||
|
// title: Text(L10n.Contacts.Delete.title),
|
||||||
|
// message: Text(L10n.Contacts.Delete.message),
|
||||||
|
// buttons: [
|
||||||
|
// .destructive(Text(L10n.Contacts.Delete.deleteFromDevice)) {
|
||||||
|
// store.dispatch(.rostersAction(.markRosterAsLocallyDeleted(ownerJID: roster.bareJid, contactJID: roster.contactBareJid)))
|
||||||
|
// },
|
||||||
|
// .destructive(Text(L10n.Contacts.Delete.deleteCompletely)) {
|
||||||
|
// isShowingLoader = true
|
||||||
|
// store.dispatch(.rostersAction(.deleteRoster(ownerJID: roster.bareJid, contactJID: roster.contactBareJid)))
|
||||||
|
// },
|
||||||
|
// .cancel(Text(L10n.Global.cancel))
|
||||||
|
// ]
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// .onChange(of: store.state.rostersState.rosters) { _ in
|
||||||
|
// endOfDeleting()
|
||||||
|
// }
|
||||||
|
// .onChange(of: store.state.rostersState.deleteRosterError) { _ in
|
||||||
|
// endOfDeleting()
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// private func endOfDeleting() {
|
||||||
|
// if isShowingLoader {
|
||||||
|
// isShowingLoader = false
|
||||||
|
// if let error = store.state.rostersState.deleteRosterError {
|
||||||
|
// errorAlertMessage = error
|
||||||
|
// isErrorAlertPresented = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
141
ConversationsClassic/View/Entering/LoginScreen.swift
Normal file
141
ConversationsClassic/View/Entering/LoginScreen.swift
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import Combine
|
||||||
|
import Martin
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginScreen: View {
|
||||||
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
@EnvironmentObject var clientsStore: ClientsStore
|
||||||
|
|
||||||
|
enum Field {
|
||||||
|
case userJid
|
||||||
|
case password
|
||||||
|
}
|
||||||
|
|
||||||
|
@FocusState private var focus: Field?
|
||||||
|
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var isError = false
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@State private var jidStr: String = "nartest1@conversations.im"
|
||||||
|
@State private var pass: String = "nartest12345"
|
||||||
|
// @State private var jidStr: String = "test1@test.anal.company"
|
||||||
|
// @State private var pass: String = "12345"
|
||||||
|
#else
|
||||||
|
@State private var jidStr: String = ""
|
||||||
|
@State private var pass: String = ""
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// background
|
||||||
|
Color.Material.Background.light
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// content
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
// icon
|
||||||
|
Image.logo
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
// texts
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text(L10n.Login.title)
|
||||||
|
.font(.head1l)
|
||||||
|
.foregroundColor(.Material.Text.main)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
Text(L10n.Login.subtitle)
|
||||||
|
.font(.body2)
|
||||||
|
.foregroundColor(.Material.Text.sub)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
UniversalInputCollection.TextField(
|
||||||
|
prompt: L10n.Login.Hint.jid,
|
||||||
|
text: $jidStr,
|
||||||
|
focus: $focus,
|
||||||
|
fieldType: .userJid,
|
||||||
|
contentType: .emailAddress,
|
||||||
|
keyboardType: .emailAddress,
|
||||||
|
submitLabel: .next,
|
||||||
|
action: {
|
||||||
|
focus = .password
|
||||||
|
}
|
||||||
|
)
|
||||||
|
UniversalInputCollection.SecureField(
|
||||||
|
prompt: L10n.Login.Hint.password,
|
||||||
|
text: $pass,
|
||||||
|
focus: $focus,
|
||||||
|
fieldType: .password,
|
||||||
|
submitLabel: .go,
|
||||||
|
action: {
|
||||||
|
focus = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await tryLogin()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Login.btn)
|
||||||
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
.disabled(!loginInputValid)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
navigation.flow = .entering(.welcome)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)")
|
||||||
|
.foregroundColor(.Material.Elements.active)
|
||||||
|
.font(.body2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.if(isLoading) {
|
||||||
|
$0.loadingOverlay()
|
||||||
|
}
|
||||||
|
.alert(isPresented: $isError) {
|
||||||
|
Alert(
|
||||||
|
title: Text(L10n.Global.Error.title),
|
||||||
|
message: Text(L10n.Login.error),
|
||||||
|
dismissButton: .default(Text(L10n.Global.ok))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loginInputValid: Bool {
|
||||||
|
!jidStr.isEmpty && !pass.isEmpty && UniversalInputCollection.Validators.isEmail(jidStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tryLogin() async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
// login with fake timeout
|
||||||
|
async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||||
|
async let request = await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true))
|
||||||
|
let result = await(request, sleep).0
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let client):
|
||||||
|
clientsStore.addNewClient(client)
|
||||||
|
isLoading = false
|
||||||
|
isError = false
|
||||||
|
if navigation.flow == .entering(.login) {
|
||||||
|
navigation.flow = .main(.contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure:
|
||||||
|
isLoading = false
|
||||||
|
isError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
ConversationsClassic/View/Entering/RegistrationScreen.swift
Normal file
22
ConversationsClassic/View/Entering/RegistrationScreen.swift
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RegistrationScreen: View {
|
||||||
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.Material.Background.light
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
navigation.flow = .entering(.welcome)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack {
|
||||||
|
Text("Not yet implemented")
|
||||||
|
Text(L10n.Global.back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
56
ConversationsClassic/View/Entering/WelcomeScreen.swift
Normal file
56
ConversationsClassic/View/Entering/WelcomeScreen.swift
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WelcomeScreen: View {
|
||||||
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// background
|
||||||
|
Color.Material.Background.light
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// content
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
// icon
|
||||||
|
Image.logo
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
// texts
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text(L10n.Global.name)
|
||||||
|
.font(.head1r)
|
||||||
|
.foregroundColor(.Material.Text.main)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
Text(L10n.Start.subtitle)
|
||||||
|
.font(.body2)
|
||||||
|
.foregroundColor(.Material.Text.sub)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buttons
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
navigation.flow = .entering(.login)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Start.Btn.login)
|
||||||
|
}
|
||||||
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
navigation.flow = .entering(.registration)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Start.Btn.register)
|
||||||
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,9 +8,9 @@ struct SharedTabBar: View {
|
||||||
.frame(height: 0.2)
|
.frame(height: 0.2)
|
||||||
.foregroundColor(.Material.Shape.separator)
|
.foregroundColor(.Material.Shape.separator)
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
SharedTabBarButton(buttonFlow: .contacts)
|
SharedTabBarButton(buttonFlow: .main(.contacts))
|
||||||
SharedTabBarButton(buttonFlow: .chats)
|
SharedTabBarButton(buttonFlow: .main(.conversations))
|
||||||
SharedTabBarButton(buttonFlow: .settings)
|
SharedTabBarButton(buttonFlow: .main(.settings))
|
||||||
}
|
}
|
||||||
.background(Color.Material.Background.dark)
|
.background(Color.Material.Background.dark)
|
||||||
}
|
}
|
||||||
|
@ -19,38 +19,40 @@ struct SharedTabBar: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SharedTabBarButton: View {
|
private struct SharedTabBarButton: View {
|
||||||
@EnvironmentObject var store: AppStore
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
|
||||||
let buttonFlow: AppFlow
|
let buttonFlow: NavigationStore.Flow
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
buttonImg
|
buttonImg
|
||||||
.foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Elements.active : .Material.Elements.inactive)
|
.foregroundColor(buttonFlow == navigation.flow ? .Material.Elements.active : .Material.Elements.inactive)
|
||||||
.font(.system(size: 24, weight: .light))
|
.font(.system(size: 24, weight: .light))
|
||||||
.symbolRenderingMode(.hierarchical)
|
.symbolRenderingMode(.hierarchical)
|
||||||
Text(buttonTitle)
|
Text(buttonTitle)
|
||||||
.font(.sub1)
|
.font(.sub1)
|
||||||
.foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Text.main : .Material.Elements.inactive)
|
.foregroundColor(buttonFlow == navigation.flow ? .Material.Text.main : .Material.Elements.inactive)
|
||||||
}
|
}
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.foregroundColor(.white.opacity(0.01))
|
.foregroundColor(.white.opacity(0.01))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
store.dispatch(.changeFlow(buttonFlow))
|
withAnimation {
|
||||||
|
navigation.flow = buttonFlow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buttonImg: Image {
|
var buttonImg: Image {
|
||||||
switch buttonFlow {
|
switch buttonFlow {
|
||||||
case .contacts:
|
case .main(.contacts):
|
||||||
return Image(systemName: "person.2.fill")
|
return Image(systemName: "person.2.fill")
|
||||||
|
|
||||||
case .chats:
|
case .main(.conversations):
|
||||||
return Image(systemName: "bubble.left.fill")
|
return Image(systemName: "bubble.left.fill")
|
||||||
|
|
||||||
case .settings:
|
case .main(.settings):
|
||||||
return Image(systemName: "gearshape.fill")
|
return Image(systemName: "gearshape.fill")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -60,13 +62,13 @@ private struct SharedTabBarButton: View {
|
||||||
|
|
||||||
var buttonTitle: String {
|
var buttonTitle: String {
|
||||||
switch buttonFlow {
|
switch buttonFlow {
|
||||||
case .contacts:
|
case .main(.contacts):
|
||||||
return "Contacts"
|
return "Contacts"
|
||||||
|
|
||||||
case .chats:
|
case .main(.conversations):
|
||||||
return "Chats"
|
return "Chats"
|
||||||
|
|
||||||
case .settings:
|
case .main(.settings):
|
||||||
return "Settings"
|
return "Settings"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
28
ConversationsClassic/View/StartScreen.swift
Normal file
28
ConversationsClassic/View/StartScreen.swift
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StartScreen: View {
|
||||||
|
@EnvironmentObject var clientsStore: ClientsStore
|
||||||
|
@EnvironmentObject var navigation: NavigationStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.Material.Background.light
|
||||||
|
Image.logo
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
clientsStore.startFetching()
|
||||||
|
}
|
||||||
|
.onChange(of: clientsStore.ready) { ready in
|
||||||
|
if ready {
|
||||||
|
let flow: NavigationStore.Flow = clientsStore.clients.isEmpty ? .entering(.welcome) : .main(.conversations)
|
||||||
|
withAnimation {
|
||||||
|
navigation.flow = flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
old/Generated/.gitignore
vendored
Normal file
2
old/Generated/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
7
old/Helpers/Bool+Extensions.swift
Normal file
7
old/Helpers/Bool+Extensions.swift
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Bool {
|
||||||
|
var intValue: Int {
|
||||||
|
self ? 1 : 0
|
||||||
|
}
|
||||||
|
}
|
53
old/Helpers/Const.swift
Normal file
53
old/Helpers/Const.swift
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum Const {
|
||||||
|
// // Network
|
||||||
|
// #if DEBUG
|
||||||
|
// static let baseUrl = "staging.some.com/api"
|
||||||
|
// #else
|
||||||
|
// static let baseUrl = "prod.some.com/api"
|
||||||
|
// #endif
|
||||||
|
// static let requestTimeout = 15.0
|
||||||
|
// static let networkLogging = true
|
||||||
|
|
||||||
|
// App
|
||||||
|
static var appVersion: String {
|
||||||
|
let info = Bundle.main.infoDictionary
|
||||||
|
let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||||
|
let appBuild = info?[kCFBundleVersionKey as String] as? String ?? "Unknown"
|
||||||
|
return "v \(appVersion)(\(appBuild))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var appName: String {
|
||||||
|
Bundle.main.bundleIdentifier ?? "Conversations Classic iOS"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trusted servers
|
||||||
|
enum TrustedServers: String {
|
||||||
|
case narayana = "narayana.im"
|
||||||
|
case conversations = "conversations.im"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit for video for sharing
|
||||||
|
static let videoDurationLimit = 60.0
|
||||||
|
|
||||||
|
// Upload/download file folder
|
||||||
|
static let fileFolder = "Downloads"
|
||||||
|
|
||||||
|
// Grid size for gallery preview (3 in a row)
|
||||||
|
static let galleryGridSize = UIScreen.main.bounds.width / 3
|
||||||
|
|
||||||
|
// Size for map preview for location messages
|
||||||
|
static let mapPreviewSize = UIScreen.main.bounds.width * 0.5
|
||||||
|
|
||||||
|
// Size for attachment preview
|
||||||
|
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
|
||||||
|
|
||||||
|
// Lenght in days for MAM request
|
||||||
|
static let mamRequestDaysLength = 30
|
||||||
|
|
||||||
|
// Limits for messages pagination
|
||||||
|
static let messagesPageMin = 20
|
||||||
|
static let messagesPageMax = 100
|
||||||
|
}
|
16
old/Helpers/Map+Extensions.swift
Normal file
16
old/Helpers/Map+Extensions.swift
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import MapKit
|
||||||
|
|
||||||
|
extension MKCoordinateRegion: Equatable {
|
||||||
|
public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool {
|
||||||
|
lhs.center.latitude == rhs.center.latitude &&
|
||||||
|
lhs.center.longitude == rhs.center.longitude &&
|
||||||
|
lhs.span.latitudeDelta == rhs.span.latitudeDelta &&
|
||||||
|
lhs.span.longitudeDelta == rhs.span.longitudeDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CLLocationCoordinate2D: Identifiable {
|
||||||
|
public var id: String {
|
||||||
|
"\(latitude)-\(longitude)"
|
||||||
|
}
|
||||||
|
}
|
106
old/Helpers/String+Extensions.swift
Normal file
106
old/Helpers/String+Extensions.swift
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var firstLetter: String {
|
||||||
|
String(prefix(1)).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
var makeReply: String {
|
||||||
|
let allLines = components(separatedBy: .newlines)
|
||||||
|
let nonBlankLines = allLines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
var result = nonBlankLines.joined(separator: "\n")
|
||||||
|
result = "> \(result)"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLocation: Bool {
|
||||||
|
hasPrefix("geo:")
|
||||||
|
}
|
||||||
|
|
||||||
|
var getLatLon: CLLocationCoordinate2D {
|
||||||
|
let geo = components(separatedBy: ":")[1]
|
||||||
|
let parts = geo.components(separatedBy: ",")
|
||||||
|
let lat = Double(parts[0]) ?? 0.0
|
||||||
|
let lon = Double(parts[1]) ?? 0.0
|
||||||
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isContact: Bool {
|
||||||
|
hasPrefix("contact:")
|
||||||
|
}
|
||||||
|
|
||||||
|
var getContactJid: String {
|
||||||
|
components(separatedBy: ":")[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var attachmentType: MessageAttachmentType {
|
||||||
|
let ext = (self as NSString).pathExtension.lowercased()
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case "mov", "mp4", "avi":
|
||||||
|
return .movie
|
||||||
|
|
||||||
|
case "jpg", "png", "gif":
|
||||||
|
return .image
|
||||||
|
|
||||||
|
case "mp3", "wav", "m4a":
|
||||||
|
return .audio
|
||||||
|
|
||||||
|
case "txt", "doc", "pdf":
|
||||||
|
return .file
|
||||||
|
|
||||||
|
default:
|
||||||
|
return .file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var firstLetterColor: Color {
|
||||||
|
let firstLetter = self.firstLetter
|
||||||
|
switch firstLetter {
|
||||||
|
case "A", "M", "Y":
|
||||||
|
return Color.Rainbow.tortoiseLight500
|
||||||
|
|
||||||
|
case "B", "N", "Z":
|
||||||
|
return Color.Rainbow.orangeLight500
|
||||||
|
|
||||||
|
case "C", "O":
|
||||||
|
return Color.Rainbow.yellowLight500
|
||||||
|
|
||||||
|
case "D", "P":
|
||||||
|
return Color.Rainbow.greenLight500
|
||||||
|
|
||||||
|
case "E", "Q":
|
||||||
|
return Color.Rainbow.blueLight500
|
||||||
|
|
||||||
|
case "F", "R":
|
||||||
|
return Color.Rainbow.magentaLight500
|
||||||
|
|
||||||
|
case "G", "S":
|
||||||
|
return Color.Rainbow.tortoiseDark500
|
||||||
|
|
||||||
|
case "H", "T":
|
||||||
|
return Color.Rainbow.orangeDark500
|
||||||
|
|
||||||
|
case "I", "U":
|
||||||
|
return Color.Rainbow.yellowDark500
|
||||||
|
|
||||||
|
case "J", "V":
|
||||||
|
return Color.Rainbow.greenDark500
|
||||||
|
|
||||||
|
case "K", "W":
|
||||||
|
return Color.Rainbow.blueDark500
|
||||||
|
|
||||||
|
case "L", "X":
|
||||||
|
return Color.Rainbow.magentaDark500
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Color.Rainbow.tortoiseLight500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
old/Helpers/TimeInterval+Extensions.swift
Normal file
9
old/Helpers/TimeInterval+Extensions.swift
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
var minAndSec: String {
|
||||||
|
let minutes = Int(self) / 60
|
||||||
|
let seconds = Int(self) % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
10
old/Helpers/UIApplication+Extensions.swift
Normal file
10
old/Helpers/UIApplication+Extensions.swift
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
func openAppSettings() {
|
||||||
|
if
|
||||||
|
let appSettingsUrl = URL(string: UIApplication.openSettingsURLString),
|
||||||
|
UIApplication.shared.canOpenURL(appSettingsUrl)
|
||||||
|
{
|
||||||
|
UIApplication.shared.open(appSettingsUrl, completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
13
old/Helpers/URL+Extensions.swift
Normal file
13
old/Helpers/URL+Extensions.swift
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
var mimeType: String {
|
||||||
|
let pathExtension = self.pathExtension
|
||||||
|
|
||||||
|
if let uti = UTType(filenameExtension: pathExtension) {
|
||||||
|
return uti.preferredMIMEType ?? "application/octet-stream"
|
||||||
|
} else {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
old/Helpers/UserDefaultsWrapper.swift
Normal file
32
old/Helpers/UserDefaultsWrapper.swift
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Wrapper
|
||||||
|
@propertyWrapper
|
||||||
|
struct Storage<T> {
|
||||||
|
private let key: String
|
||||||
|
private let defaultValue: T
|
||||||
|
|
||||||
|
init(key: String, defaultValue: T) {
|
||||||
|
self.key = key
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedValue: T {
|
||||||
|
get {
|
||||||
|
// Read value from UserDefaults
|
||||||
|
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
// Set value to UserDefaults
|
||||||
|
UserDefaults.standard.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
private let keyLocalizationSelected = "conversations.classic.user.defaults.localizationSelected"
|
||||||
|
|
||||||
|
enum UserSettings {
|
||||||
|
@Storage(key: keyLocalizationSelected, defaultValue: false)
|
||||||
|
static var localizationSelectedByUser: Bool
|
||||||
|
}
|
6
old/Resources/Assets/Colors.xcassets/Contents.json
Normal file
6
old/Resources/Assets/Colors.xcassets/Contents.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xE4",
|
||||||
|
"green" : "0xE4",
|
||||||
|
"red" : "0xE4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue