Compare commits

...

31 Commits

Author SHA1 Message Date
Diogo Cunha 387faa0c27 android: implemented one hand ui for chat list screen 2024-07-12 23:02:14 +01:00
Diogo Cunha aa990da17c android, desktop: added setting for one hand ui 2024-07-11 21:25:03 +01:00
Diogo Cunha a48c82f4a1 android, desktop: added action buttons and delete to contact card, added toolbar 2024-07-10 22:23:43 +01:00
spaced4ndy f41c04735b ios: lookup group members via map (#4431) 2024-07-10 16:15:14 +04:00
spaced4ndy a8da9b9cd9 core: fix invitee sending redundant pending messages whey they should be forwarded (#4430) 2024-07-10 13:52:04 +04:00
Evgeny Poberezkin 64a0f509f7 6.0-beta.0: ios 227, android 225, desktop 56 2024-07-09 22:38:08 +01:00
spaced4ndy c4f8a50f0d ios: show inactive and forwarded group message status; show inactive members (#4423)
* ios: differentiate inactive and forwarded group snd statuses; show member connection disabled/inactive

* lazy

* simplify

* Update apps/ios/SimpleXChat/ChatTypes.swift

* export localizations, update strings

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 21:52:33 +01:00
spaced4ndy 93a4c0854e desktop, android: show inactive and forwarded group message status; show inactive members (#4426)
* multiplatform: differentiate inactive and forwarded group snd statuses; show member connection disabled/inactive

* forward icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 21:28:01 +01:00
Evgeny Poberezkin 49c29c74df core: 6.0.0.0 2024-07-09 19:41:42 +01:00
Evgeny Poberezkin e6ee5df158 core: update simplexmq 6.0.0.0 2024-07-09 19:07:44 +01:00
Stanislav Dmitrenko f4be0278b6 desktop: zoom and font size (#4421)
* desktop: font scale

* new line

* moved to slider

* default value highlighting

* clickable

* more places with adapted scale

* attachment and edit icons

* verified

* icons in chat view

* zoom

* new chat button size

* preview icons

* android support

* preview

* text scale in chat view's text field

* paddings

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 18:41:23 +01:00
spaced4ndy a9d2535292 core: differentiate inactive and forwarded group snd statuses (#4420)
* core: GroupSndStatus

* rfc

* encoding, db apis

* pending, forwarded statuses

* encoding
2024-07-09 18:29:36 +01:00
spaced4ndy 3e623684bc android, desktop: servers summary (#4398)
* multiplatform: subscription icon (#4397)

* multiplatform: added network setting to control display of subscription percentage

* multiplatform: moved filter to search bar and scan to button

* multiplatform: added types and calls for new apis

* Update apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* chore: minor changes in API connections and usage

* fix: removed show sub setting from net cfg

* multiplatform: added subscription status to chats screen

* multiplatform: added reconnect all servers api

* multiplatform: added basic view for single user messages

* multiplatform: added basic xftp server info view

* multiplatform: added detailed stats view for SMP server

* multiplatform: added detailed stats view for XFTP server

* multiplatform: added individual server view for XFTP servers

* multiplatform: added individual server view for SMP servers

* multiplatform: added custom coloring for connections

* multiplatform: added all translation strings

* multiplatform: added support for multi user in serve summary

* multiplatform: added missing translations

* multiplatform: added share button to servers info

* better type safety for server summaries

* multiplatform: fixed action arrow paddings in server summary views

* multiplatform: serverSummaryView padding and icon fixes

* multiplatform: reused shared section divider

* move and rename

* remove tab icons, text

* colors

* filter button

* paddings

* fix translation keys

* text

* fix buttons clickable area, alerts

* stats view

* remove chevrons

* colors

* remove id, fix open server button

* don't log terminal items

* desktop left modal

* single timer, pass state variable

* remove unused

* fix no summary view

* net cfg

* ability to hide servers screen by clicking outside servers screen

* addressed review feedback

* move user/all users selector under tabs

* disable horizontal scroll on desktop

---------

Co-authored-by: Diogo Cunha <diogofncunha@gmail.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 12:45:09 +01:00
Evgeny Poberezkin fd90b47194 Merge branch 'stable' 2024-07-09 11:23:54 +01:00
Stanislav Dmitrenko acd3467d10 desktop: change libffi dependency (#4416) 2024-07-08 21:23:25 +01:00
Evgeny Poberezkin 71ce598355 ios: make message corners rounded in theme preview (#4410)
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
2024-07-05 14:43:36 +01:00
Arturs Krumins 63393eaf0b ios: make theme wallpaper visible on iOS15, further improve scrolling (#4408) 2024-07-05 13:34:03 +01:00
Evgeny Poberezkin f90de83215 ios: corrections, localizations (#4406) 2024-07-05 08:23:29 +01:00
Evgeny Poberezkin 1c10209a31 blog: future of privacy (#4396)
* blog: future of privacy

* update
2024-07-04 21:13:48 +01:00
Arturs Krumins 5d7abf31ce ios: make message corners rounded when selecting context menus (#4401)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-04 18:37:03 +01:00
spaced4ndy 44c0861fe4 ios: apply theming to servers summary view (#4404) 2024-07-04 21:11:20 +04:00
sh 1e6dc8002c flatpak: update metainfo (#4400) 2024-07-04 17:39:16 +04:00
Narasimha-sc 0509ff9a1b docs: Flatpak download (#4393)
* docs: Flathub

* correction

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-04 08:00:12 +01:00
Evgeny Poberezkin f12e504828 core: update simplexmq (fast connection handshake) (#4369)
* core: update simplexmq (fast connection handshake)

* core: update simplexmq (fast connection handshake)

* fix encoding tests

* fix tests
2024-07-04 07:58:13 +01:00
Evgeny Poberezkin 6a273677f1 docs: contributing (#4392)
* docs: contributing

* corrections
2024-07-03 22:51:21 +01:00
Evgeny Poberezkin f5eea018d9 ios: chat themes and wallpapers (#4376)
* ios: wallpapers (#4304)

* ios: wallpapers

* theme selection

* applied theme colors and preset wallpaper

* more places with background

* one more

* accent color

* defaults

* rename

* background

* no change to cell color

* unneeded

* changes

* no global tint

* defaults

* removed unneeded class

* for merging

* ios: wallpapers types (#4325)

* types and api

* divided types per target

* creating directory for wallpapers

* creating wallpaper dir at launch

* ios: wallpapers appearance (#4335)

* appearance

* changes

* refactor

* scale

* lambda to function

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* ios: wallpapers user/chat overrides (#4345)

* ios: wallpapers user/chat overrides

* chat overrides

* color picker updates colors correctly

* fix state update

* labels

* background for light theme

* small optimization

* removed commented code

* ios: enhancements to wallpapers (#4361)

* ios: enhancements to wallpapers

* colors for background

* ios: wallpapers import/export (#4362)

* ios: wallpapers import/export

* comment

* ios: wallpapers theme updates (#4365)

* ios: wallpapers theme updates

* group member background

* colors

* profile picture colors

* unneeded

* optimizations, images, state fixes

* fixes

* no editing of title color

* rename Menus and alerts, refactor

* tint applying fix

* fixes

* migration of accent and themes

* fix updating system theme

* migration changes

* limiting color range

* ios: wallpapers rename enum (#4384)

* ios: wallpapers rename enum2 (#4385)

* ios: wallpapers rename enum2

* change

* colors were commented

* fix build and look

---------

Co-authored-by: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com>
2024-07-03 22:42:13 +01:00
Evgeny Poberezkin 5f96f6c117 core: extending queue information to debug delivery (#4395)
* core: extending queue information to debug delivery

* simplexmq
2024-07-03 19:34:58 +01:00
spaced4ndy e963ef53e7 ios: test ntf server (#4394)
* ios: test ntf server

* reset on change

* fix formatting
2024-07-03 18:31:09 +01:00
spaced4ndy 1180edf80e ios: servers summary (#4321)
* types

* chat list buttons

* update

* wip

* xftp summary

* sub status view

* reconnect server button

* reset stats button

* refactor

* subscription icon in server view

* getAgentSubsSummary api

* view

* subs indicator with timer

* rename

* variableValueAsPercentage

* encodePrettyPrinted

* totals, timer

* reduce interval

* show more/less stats

* refactor

* sort

* rework indicator wip

* change offline icon

* rework indicator

* show percent

* file progress

* fix

* onion

* user servers, icons

* dashes

* Revert "user servers, icons"

This reverts commit 805e7e9bd6.

* remove icon, remove total, remove session %

* exclude sessions

* move starting from

* file stats

* fix

* open server settings

* file in progress

* update

* wpi

* wip

* rework stats

* arrow for sessions

* texts

* reconnect all

* single user

* rework file stats

* update

* dont log terminal items

* center

* update

* _connSubIgnored

* rename

* update

* large titles

* refactor

* update

* text

* upd

* single dash
2024-07-03 13:59:46 +01:00
spaced4ndy 4bd6517d19 core: servers stats improvements, fixes (#4358) 2024-07-03 15:58:37 +04:00
Evgeny Poberezkin 2585f4ecfd ios: ChatView performance improvements (#4353)
* feat: Add synthesized hashable conformance to chat and API types (#4348)

* UIKit ReverseList

* ReverseList - manual layout updates for external state

* Propagate ScrollModel; Disable async media width

* Filter chat items

* Remove UIKit menu wrapper

* Make chat item width calculation synchronous (#4371)

* Fix floating button regression

* Improve filter performance

* Fix page load the merged items exceed full page

* Resolve iOS15 compabibility

* Restore build config

* Add page-up scroll; Fix same item decrementing unread counter multiple times

* Fix: Chat not loading additional pages, if newest items are all merged and exceed page size

* Minor

* Fix item loading regression

* Fix item loading regression 2

* Fix unread regression

* refactor

---------

Co-authored-by: Arturs Krumins <auth@levitatingpineapple.com>
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-07-03 10:24:26 +01:00
242 changed files with 19352 additions and 2298 deletions
+74 -2
View File
@@ -9,6 +9,7 @@
import Foundation
import UIKit
import SimpleXChat
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -17,6 +18,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
prepareForLaunch()
return true
}
@@ -141,6 +143,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
}
private func prepareForLaunch() {
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
}
static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on
}
@@ -148,13 +154,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow?
static var windowStatic: UIWindow?
var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
UITableView.appearance().backgroundColor = .clear
guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene
window = windowScene.keyWindow
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
SceneDelegate.windowStatic = windowScene.keyWindow
migrateAccentColorAndTheme()
ThemeManager.applyTheme(currentThemeDefault.get())
ThemeManager.adjustWindowStyle()
}
private func migrateAccentColorAndTheme() {
let defs = UserDefaults.standard
/// For checking migration
// themeOverridesDefault.set([])
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
let userInterfaceStyle = getUserInterfaceStyleDefault()
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
// No migration needed or already migrated
return
}
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
let accentColor = Color(cgColor: getUIAccentColorDefault())
if accentColor != defaultAccentColor {
let colors = ThemeColors(primary: accentColor.toReadableHex())
var overrides = themeOverridesDefault.get()
var themeIds = currentThemeIdsDefault.get()
switch userInterfaceStyle {
case .light:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
case .dark:
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
case .unspecified:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
@unknown default: ()
}
} else if userInterfaceStyle != .unspecified {
let themeName = switch userInterfaceStyle {
case .light: DefaultTheme.LIGHT.themeName
case .dark: DefaultTheme.DARK.themeName
default: DefaultTheme.SYSTEM_THEME_NAME
}
ThemeManager.applyTheme(themeName)
}
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_cats@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_cats@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_cats@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_flowers@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_flowers@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_flowers@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_hearts@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_hearts@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_hearts@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_kids@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_kids@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_kids@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_school@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_school@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_school@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "wallpaper_travel@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_travel@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_travel@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+25 -2
View File
@@ -14,6 +14,8 @@ struct ContentView: View {
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
var contentAccessAuthenticationExtended: Bool
@@ -51,6 +53,16 @@ struct ContentView: View {
}
var body: some View {
if #available(iOS 16.0, *) {
allViews()
.scrollContentBackground(.hidden)
} else {
// on iOS 15 scroll view background disabled in SceneDelegate
allViews()
}
}
@ViewBuilder func allViews() -> some View {
ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@@ -138,6 +150,17 @@ struct ContentView: View {
break
}
}
.onAppear {
reactOnDarkThemeChanges()
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges()
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
}
}
@ViewBuilder private func contentView() -> some View {
@@ -224,8 +247,8 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
.fill(.background)
)
.fill(theme.colors.background)
)
}
private func mainView() -> some View {
+44 -14
View File
@@ -47,7 +47,11 @@ final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User?
@Published var currentUser: User? {
didSet {
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
@Published var users: [UserInfo] = []
@Published var chatInitialized = false
@Published var chatRunning: Bool?
@@ -69,6 +73,7 @@ final class ChatModel: ObservableObject {
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -176,8 +181,18 @@ final class ChatModel: ObservableObject {
}
}
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
groupMembers.first { $0.groupMemberId == groupMemberId }
if let i = groupMembersIndexes[groupMemberId] {
return groupMembers[i]
}
return nil
}
private func getChatIndex(_ id: String) -> Int? {
@@ -332,12 +347,12 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
withAnimation {
withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
}
return false
} else {
withAnimation(itemAnimation()) {
withConditionalAnimation(itemAnimation()) {
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
@@ -357,7 +372,7 @@ final class ChatModel: ObservableObject {
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withAnimation {
withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
}
} else if let status = status {
@@ -422,6 +437,16 @@ final class ChatModel: ObservableObject {
}
}
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
guard var current = currentUser else { return }
current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i {
users[i].user = current
}
currentUser = current
}
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
@@ -512,11 +537,13 @@ final class ChatModel: ObservableObject {
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update preview
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
markChatItemRead_(i)
if reversedChatItems[i].isRcvNew {
// update current chat
markChatItemRead_(i)
// update preview
decreaseUnreadCounter(cInfo)
}
}
}
@@ -651,14 +678,17 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
if let i = groupMembersIndexes[member.groupMemberId] {
withAnimation(.default) {
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
withAnimation { groupMembers.append(GMember(member)) }
withAnimation {
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
return true
}
} else {
@@ -686,7 +716,7 @@ final class ChatModel: ObservableObject {
}
i += 1
}
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
@@ -723,8 +753,8 @@ struct NTFContactRequest {
var chatId: String
}
struct UnreadChatItemCounts {
var totalBelow: Int
struct UnreadChatItemCounts: Equatable {
var isNearBottom: Bool
var unreadBelow: Int
}
+37
View File
@@ -205,6 +205,43 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
}
}
func saveWallpaperFile(url: URL) -> String? {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
}
func saveWallpaperFile(image: UIImage) -> String? {
let hasAlpha = imageHasAlpha(image)
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
do {
try dataResized!.write(to: destFile)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
}
func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}
+51 -21
View File
@@ -92,18 +92,22 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r
}
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)")
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)")
}
let start = Date.now
let resp = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
: sendSimpleXCmd(cmd, ctrl)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
}
return resp
}
@@ -242,14 +246,8 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
logger.error("apiSuspendChat error: \(String(describing: r))")
}
func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
if case .cmdOk = r { return }
throw r
}
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
if case .cmdOk = r { return }
throw r
}
@@ -309,8 +307,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r
}
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r
}
@@ -541,6 +541,11 @@ func reconnectAllServers() async throws {
try await sendCommandOkResp(.reconnectAllServers)
}
func reconnectServer(smpServer: String) async throws {
let userId = try currentUserId("reconnectServer")
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
}
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
@@ -831,6 +836,21 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
throw r
}
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
if case .cmdOk = r { return true }
logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
return false
}
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
if case .cmdOk = r { return true }
logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
return false
}
func apiCreateUserAddress() async throws -> String {
let userId = try currentUserId("apiCreateUserAddress")
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
@@ -1334,6 +1354,18 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
throw r
}
func resetAgentServersStats() async throws {
try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 {
if let userId = ChatModel.shared.currentUser?.userId {
return userId
@@ -1353,8 +1385,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
if encryptionStartedDefault.get() {
encryptionStartedDefault.set(false)
}
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
@@ -1439,8 +1470,7 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
logger.debug("startChatWithTemporaryDatabase")
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser
}
+1
View File
@@ -39,6 +39,7 @@ struct SimpleXApp: App {
// so that it's computed by the time view renders, and not on event after rendering
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
.environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url
+200
View File
@@ -0,0 +1,200 @@
//
// Theme.swift
// SimpleX (iOS)
//
// Created by Avently on 14.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
class AppTheme: ObservableObject, Equatable {
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
var name: String
var base: DefaultTheme
@ObservedObject var colors: Colors
@ObservedObject var appColors: AppColors
@ObservedObject var wallpaper: AppWallpaper
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
self.name = name
self.base = base
self.colors = colors
self.appColors = appColors
self.wallpaper = wallpaper
}
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
lhs.name == rhs.name &&
lhs.colors == rhs.colors &&
lhs.appColors == rhs.appColors &&
lhs.wallpaper == rhs.wallpaper
}
func updateFromCurrentColors() {
objectWillChange.send()
name = CurrentColors.name
base = CurrentColors.base
colors.updateColorsFrom(CurrentColors.colors)
appColors.updateColorsFrom(CurrentColors.appColors)
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
}
}
struct ThemedBackground: ViewModifier {
@EnvironmentObject var theme: AppTheme
var grouped: Bool = false
func body(content: Content) -> some View {
content
.background(
theme.base == DefaultTheme.SIMPLEX
? LinearGradient(
colors: [
grouped
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.lighter(0.4),
grouped
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.darker(0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
: LinearGradient(
colors: [],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.background(
theme.base == DefaultTheme.SIMPLEX
? Color.clear
: grouped
? theme.colors.background.asGroupedBackground(theme.base.mode)
: theme.colors.background
)
}
}
var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
func reactOnDarkThemeChanges() {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let data = Data(base64Encoded: dropImagePrefix(image)),
let parsed = UIImage(data: data),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
copy.imageFile = filename
return copy
} else {
return ThemeWallpaper()
}
} else {
return self
}
}
func withFilledWallpaperBase64() -> ThemeWallpaper {
let aw = toAppWallpaper()
let type = aw.type
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
scaleType: scaleType,
background: aw.background?.toReadableHex(),
tint: aw.tint?.toReadableHex(),
image: image,
imageFile: nil
)
}
}
extension ThemeModeOverride {
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
let w: ThemeWallpaper
switch wallpaperType {
case let WallpaperType.preset(filename, scale):
let p = PresetWallpaper.from(filename)
w = ThemeWallpaper(
preset: filename,
scale: scale ?? wallpaper?.scale,
scaleType: nil,
background: p?.background[base]?.toReadableHex(),
tint: p?.tint[base]?.toReadableHex(),
image: nil,
imageFile: nil
)
case WallpaperType.image:
w = ThemeWallpaper(
preset: nil,
scale: nil,
scaleType: WallpaperScaleType.fill,
background: Color.clear.toReadableHex(),
tint: Color.clear.toReadableHex(),
image: nil,
imageFile: nil
)
default:
w = ThemeWallpaper()
}
let wallpaper: ThemeWallpaper? = if let wallpaper {
ThemeWallpaper(
preset: wallpaper.preset,
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
background: wallpaper.background != w.background ? wallpaper.background : nil,
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
image: wallpaper.image,
imageFile: wallpaper.imageFile
)
} else {
nil
}
return ThemeModeOverride(
mode: self.mode,
colors: ThemeColors(
primary: colors.primary != tc.primary ? colors.primary : nil,
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
),
wallpaper: wallpaper
)
}
}
+303
View File
@@ -0,0 +1,303 @@
//
// ThemeManager.swift
// SimpleX (iOS)
//
// Created by Avently on 03.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
class ThemeManager {
struct ActiveTheme: Equatable {
let name: String
let base: DefaultTheme
let colors: Colors
let appColors: AppColors
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
func toAppTheme() -> AppTheme {
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
}
}
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
switch systemDarkThemeDefault.get() {
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
}
}
private static func nonSystemThemeName() -> String {
let themeName = currentThemeDefault.get()
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
themeName
} else {
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
}
}
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
let nonSystemThemeName = nonSystemThemeName()
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
return appSettingsTheme.getTheme(defaultThemeId)
}
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
if let perUserTheme {
return perUserTheme
}
let defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
let themeName = currentThemeDefault.get()
let nonSystemThemeName = nonSystemThemeName()
let defaultTheme = defaultActiveTheme(appSettingsTheme)
let baseTheme = switch nonSystemThemeName {
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
}
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
}
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
PresetWallpaper.from(filename)?.colors[baseTheme.base]
} else if let wallpaper = perChatTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else if let wallpaper = perUserTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else {
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
}
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
return ActiveTheme(
name: themeName,
base: baseTheme.base,
colors: colors,
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
)
}
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
let wType = current.wallpaper.type
let wBackground = current.wallpaper.background
let wTint = current.wallpaper.tint
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
nil
} else {
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
}
return ThemeOverrides(
themeId: "",
base: current.base,
colors: ThemeColors.from(current.colors, current.appColors),
wallpaper: w
)
}
static func applyTheme(_ theme: String) {
currentThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
let tint = UIColor(CurrentColors.colors.primary)
if SceneDelegate.windowStatic?.tintColor != tint {
SceneDelegate.windowStatic?.tintColor = tint
}
// applyNavigationBarColors(CurrentColors.toAppTheme())
}
static func adjustWindowStyle() {
let style = switch currentThemeDefault.get() {
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
default: UIUserInterfaceStyle.dark
}
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
}
}
// static func applyNavigationBarColors(_ theme: AppTheme) {
// let baseColors = switch theme.base {
// case DefaultTheme.LIGHT: LightColorPaletteApp
// case DefaultTheme.DARK: DarkColorPaletteApp
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
// case DefaultTheme.BLACK: BlackColorPaletteApp
// }
// let isDefaultColor = baseColors.title == theme.appColors.title
//
// let title = UIColor(theme.appColors.title)
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
// } else {
// UINavigationBar.appearance().titleTextAttributes = nil
// UINavigationBar.appearance().largeTitleTextAttributes = nil
// }
// }
static func changeDarkTheme(_ theme: String) {
systemDarkThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
}
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get())
}
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
}
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let theme = overrides.sameTheme(type, baseTheme.themeName)
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
prevValue.wallpaper = if let type {
if case WallpaperType.empty = type {
nil as ThemeWallpaper?
} else {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
}
} else {
nil
}
pref.set(overrides.replace(prevValue))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(nonSystemThemeName)
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
let overrides = themeOverridesDefault.get()
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
wallpaper
} else {
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
}
guard let sameWallpaper else {
if let type {
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
} else {
// Make an empty wallpaper to override any top level ones
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
}
return true
}
var type = sameWallpaper.toAppWallpaper().type
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
type = WallpaperType.image(filename, scale, scaleType)
} else {
logger.error("Error while copying wallpaper from global overrides to chat overrides")
return false
}
}
var prevValue = pref.wrappedValue
var w = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
prevValue.colors = ThemeColors()
prevValue.wallpaper = w
pref.wrappedValue = prevValue
return true
}
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.wallpaper = if let type {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
} else {
nil
}
pref.wrappedValue = prevValue
}
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let wallpaper = theme.wallpaper?.importFromString()
let nonSystemThemeName = theme.base.themeName
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
if let imageFile = prevValue.wallpaper?.imageFile {
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
}
prevValue.base = theme.base
prevValue.colors = theme.colors
prevValue.wallpaper = wallpaper
pref.set(overrides.replace(prevValue))
currentThemeDefault.set(nonSystemThemeName)
var currentThemeIds = currentThemeIdsDefault.get()
currentThemeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(currentThemeIds)
applyTheme(nonSystemThemeName)
}
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = nonSystemThemeName()
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
var prevValue = overrides.getTheme(themeId)
else { return }
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.set(overrides.replace(prevValue))
applyTheme(currentThemeDefault.get())
}
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.wrappedValue = prevValue
}
static func removeTheme(_ themeId: String?) {
var themes = themeOverridesDefault.get().map { $0 }
themes.removeAll(where: { $0.themeId == themeId })
themeOverridesDefault.set(themes)
}
}
@@ -2,7 +2,7 @@
// MPVolumeView.swift
// SimpleX (iOS)
//
// Created by Stanislav on 24.04.2024.
// Created by Avently on 24.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
@@ -11,6 +11,7 @@ import SimpleXChat
struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var cc = CallController.shared
var body: some View {
@@ -43,7 +44,7 @@ struct IncomingCallView: View {
cc.endCall(invitation: invitation)
}
callButton("Ignore", "multiply", .accentColor) {
callButton("Ignore", "multiply", .primary) {
cc.activeCallInvitation = nil
}
@@ -63,7 +64,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.modifier(ThemedBackground())
.onAppear { dismissAllSheets() }
}
@@ -76,7 +77,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24)
Text(text)
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.frame(minWidth: 44)
})
@@ -9,10 +9,9 @@
import SwiftUI
import SimpleXChat
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var imageSize: CGFloat = 32
@@ -26,9 +25,7 @@ struct ChatInfoToolbar: View {
ChatInfoImage(
chat: chat,
size: imageSize,
color: colorScheme == .dark
? chatImageColorDark
: chatImageColorLight
color: Color(uiColor: .tertiaryLabel)
)
.padding(.trailing, 4)
VStack {
@@ -41,14 +38,14 @@ struct ChatInfoToolbar: View {
}
}
}
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
}
@@ -57,5 +54,6 @@ struct ChatInfoToolbar: View {
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(CurrentColors.toAppTheme())
}
}
+165 -12
View File
@@ -36,14 +36,14 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
}
}
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View {
if servers.count > 0 {
HStack {
Text(title).frame(width: 120, alignment: .leading)
Button(serverHost(servers[0])) {
UIPasteboard.general.string = servers.joined(separator: ";")
}
.foregroundColor(.secondary)
.foregroundColor(secondaryColor)
.lineLimit(1)
}
}
@@ -90,6 +90,7 @@ enum SendReceipts: Identifiable, Hashable {
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@State var contact: Contact
@@ -143,7 +144,7 @@ struct ChatInfoView: View {
.listRowSeparator(.hidden)
if let customUserProfile = customUserProfile {
Section("Incognito") {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Your random profile")
Spacer()
@@ -161,6 +162,11 @@ struct ChatInfoView: View {
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
@@ -183,13 +189,15 @@ struct ChatInfoView: View {
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
if contact.ready && contact.active {
Section("Servers") {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
networkStatusRow()
.onTapGesture {
alert = .networkStatusAlert
@@ -211,8 +219,8 @@ struct ChatInfoView: View {
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
}
@@ -223,7 +231,7 @@ struct ChatInfoView: View {
}
if developerTools {
Section(header: Text("For console")) {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
Button ("Debug delivery") {
@@ -241,6 +249,7 @@ struct ChatInfoView: View {
}
}
}
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -292,7 +301,7 @@ struct ChatInfoView: View {
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ Text(" ")
+ Text(contact.profile.displayName)
@@ -332,7 +341,7 @@ struct ChatInfoView: View {
setContactAlias()
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
private func setContactAlias() {
@@ -370,6 +379,7 @@ struct ChatInfoView: View {
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
.modifier(ThemedBackground(grouped: true))
} label: {
Label(
contact.verified ? "View security code" : "Verify security code",
@@ -386,6 +396,7 @@ struct ChatInfoView: View {
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
)
.navigationBarTitle("Contact preferences")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Contact preferences", systemImage: "switch.2")
@@ -434,11 +445,11 @@ struct ChatInfoView: View {
HStack {
Text("Network status")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
Spacer()
Text(chatModel.contactNetworkStatus(contact).statusString)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
serverImage()
}
}
@@ -446,7 +457,7 @@ struct ChatInfoView: View {
private func serverImage() -> some View {
let status = chatModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
.font(.system(size: 12))
}
@@ -565,6 +576,148 @@ struct ChatInfoView: View {
}
}
struct ChatWallpaperEditorSheet: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var globalThemeUsed: Bool = false
@State var chat: Chat
@State private var themes: ThemeModeOverrides
init(chat: Chat) {
self.chat = chat
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
var body: some View {
let preferred = themes.preferredMode(!theme.colors.isLight)
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
ChatWallpaperEditor(
initialTheme: initialTheme,
themeModeOverride: initialTheme,
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
globalThemeUsed: $globalThemeUsed,
save: { applyToMode, newTheme in
await save(applyToMode, newTheme, $chat)
}
)
.navigationTitle("Chat theme")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
globalThemeUsed = preferred == nil
}
.onChange(of: theme.base.mode) { _ in
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
}
.onChange(of: ChatModel.shared.chatId) { _ in
dismiss()
}
}
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
private static var updateBackendTask: Task = Task {}
private func save(
_ applyToMode: DefaultThemeMode?,
_ newTheme: ThemeModeOverride?,
_ chat: Binding<Chat>
) async {
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
var changedThemes: ThemeModeOverrides? = unchangedThemes
let light: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
let dark: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
if let applyToMode {
switch applyToMode {
case DefaultThemeMode.light:
changedThemes?.light = light
case DefaultThemeMode.dark:
changedThemes?.dark = dark
}
} else {
changedThemes?.light = light
changedThemes?.dark = dark
}
if changedThemes?.light != nil || changedThemes?.dark != nil {
let light = changedThemes?.light
let dark = changedThemes?.dark
let currentMode = CurrentColors.base.mode
// same image file for both modes, copy image to make them as different files
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
let imageFile = if currentMode == DefaultThemeMode.light {
darkImageFile
} else {
lightImageFile
}
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
if currentMode == DefaultThemeMode.light {
dark.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
} else {
light.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
}
}
} else {
changedThemes = nil
}
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
wallpaperFiles.forEach(removeWallpaperFile)
let changedThemesConstant = changedThemes
ChatWallpaperEditorSheet.updateBackendTask.cancel()
ChatWallpaperEditorSheet.updateBackendTask = Task {
do {
try await Task.sleep(nanoseconds: 300_000000)
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
contact.uiThemes = changedThemesConstant
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
themes = themesFromChat(chat.wrappedValue)
}
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
groupInfo.uiThemes = changedThemesConstant
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
themes = themesFromChat(chat.wrappedValue)
}
}
}
} catch {
// canceled task
}
}
}
}
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
Alert(
title: Text("Change receiving address?"),
@@ -11,6 +11,7 @@ import SimpleXChat
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var status: CICallStatus
@@ -22,7 +23,7 @@ struct CICallItemView: View {
switch status {
case .pending:
if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
} else {
acceptCallButton()
}
@@ -35,7 +36,7 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange)
}
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
@@ -52,7 +53,7 @@ struct CICallItemView: View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(.secondary)
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
}
}
@@ -70,7 +71,7 @@ struct CICallItemView: View {
Label("Answer call", systemImage: "phone.arrow.down.left")
}
} else {
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
}
}
}
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature
@@ -66,10 +67,10 @@ struct CIChatFeatureView: View {
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
default: nil
}
}
@@ -81,7 +82,7 @@ struct CIChatFeatureView: View {
if let param = f.param {
HStack {
i
chatEventText(Text(param)).lineLimit(1)
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
}
} else {
i
@@ -93,7 +94,7 @@ struct CIChatFeatureView: View {
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
chatEventText(chatItem)
chatEventText(chatItem, theme.colors.secondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
@@ -104,6 +105,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
}
}
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
if let acceptText {
r = r
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
return r.font(.caption)
}
}
@@ -11,7 +11,7 @@ import SimpleXChat
struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
@@ -30,12 +30,12 @@ struct CIFileView: View {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
@@ -170,7 +170,7 @@ struct CIFileView: View {
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation:
if fileSizeValid(file) {
fileIcon("arrow.down.doc.fill", color: .accentColor)
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
}
@@ -182,7 +182,7 @@ struct CIFileView: View {
progressView()
}
case .rcvAborted:
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
@@ -11,7 +11,7 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
@@ -42,7 +42,7 @@ struct CIGroupInvitationView: View {
.overlay(DetermineWidth())
(
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
@@ -65,12 +65,11 @@ struct CIGroupInvitationView: View {
}
}
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -99,7 +98,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View {
var color: Color
if action && !inProgress {
color = chatIncognito ? .indigo : .accentColor
color = chatIncognito ? .indigo : theme.colors.primary
} else {
color = Color(uiColor: .tertiaryLabel)
}
@@ -11,12 +11,10 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem
let image: String
var preview: UIImage?
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy?
var imgWidth: CGFloat?
@State private var showFullScreenImage = false
var body: some View {
@@ -25,15 +23,14 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
.onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -90,7 +87,6 @@ struct CIImageView: View {
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
@@ -9,6 +9,7 @@
import SwiftUI
struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
var json: String
@State private var showJSON = false
@@ -21,7 +22,6 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) {
@@ -44,6 +44,7 @@ func invalidJSONView(_ json: String) -> some View {
}
.frame(maxHeight: .infinity)
.padding()
.modifier(ThemedBackground())
}
struct CIInvalidJSONView_Previews: PreviewProvider {
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct CILinkView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
var body: some View {
@@ -32,7 +32,7 @@ struct CILinkView: View {
Text(linkPreview.uri.absoluteString)
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
r = r
+ Text(openText)
.fontWeight(.medium)
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
return r.font(.caption)
}
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
if let member = chatItem.memberDisplayName {
return Text(member + " " + chatItem.content.text + " ")
.fontWeight(.light)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
} else {
return Text(chatItem.content.text + " ")
.fontWeight(.light)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
}
@@ -11,8 +11,9 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var metaColor = Color.secondary
var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true
var showEdited = true
@@ -63,6 +64,7 @@ func ciMetaText(
chatTTL: Int?,
encrypted: Bool?,
color: Color = .clear,
primaryColor: Color = .accentColor,
transparent: Bool = false,
sent: SentCheckmark? = nil,
showStatus: Bool = true,
@@ -85,7 +87,7 @@ func ciMetaText(
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
}
if showStatus {
if let (icon, statusColor) = meta.statusIcon(color) {
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
let t = Text(Image(systemName: icon)).font(.caption2)
let gap = Text(" ").kerning(-1.25)
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
@@ -112,15 +114,16 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
}
struct CIMetaView_Previews: PreviewProvider {
static let metaColor = Color.secondary
static var previews: some View {
Group {
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
}
.previewLayout(.fixed(width: 360, height: 100))
}
@@ -13,6 +13,7 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
@@ -114,24 +115,23 @@ struct CIRcvDecryptionError: View {
}
(
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? .accentColor : .secondary)
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
+ Text(" ")
+ Text("Fix connection")
.foregroundColor(syncSupported ? .accentColor : .secondary)
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -145,13 +145,12 @@ struct CIRcvDecryptionError: View {
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -13,16 +13,13 @@ import Combine
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem
private let image: String
private let preview: UIImage?
@State private var duration: Int
@State private var progress: Int = 0
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
@Binding private var videoWidth: CGFloat?
@State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
private var videoWidth: CGFloat?
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@@ -33,13 +30,12 @@ struct CIVideoView: View {
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
self.chatItem = chatItem
self.image = image
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self._videoWidth = videoWidth
self.scrollProxy = scrollProxy
self.videoWidth = videoWidth
if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
@@ -49,10 +45,6 @@ struct CIVideoView: View {
}
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
self._preview = State(initialValue: uiImage)
}
}
var body: some View {
@@ -63,9 +55,8 @@ struct CIVideoView: View {
videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -152,7 +143,6 @@ struct CIVideoView: View {
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
@@ -252,7 +242,6 @@ struct CIVideoView: View {
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIVoiceView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@@ -72,7 +73,7 @@ struct CIVoiceView: View {
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
private func playbackSlider() -> some View {
@@ -89,10 +90,11 @@ struct CIVoiceView: View {
allowMenu = true
}
}
.tint(theme.colors.primary)
}
private func metaView() -> some View {
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
}
}
@@ -118,7 +120,7 @@ struct VoiceMessagePlayerTime: View {
struct VoiceMessagePlayer: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var recordingFile: CIFile?
var recordingTime: TimeInterval
@@ -216,26 +218,26 @@ struct VoiceMessagePlayer: View {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill")
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
playPauseIcon("play.fill", theme.colors.primary)
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
ZStack {
Image(systemName: image)
.resizable()
@@ -244,7 +246,7 @@ struct VoiceMessagePlayer: View {
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
@@ -261,11 +263,12 @@ struct VoiceMessagePlayer: View {
}
}
} label: {
playPauseIcon(icon)
playPauseIcon(icon, theme.colors.primary)
}
}
private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -273,7 +276,7 @@ struct VoiceMessagePlayer: View {
Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke(
Color.accentColor,
theme.colors.primary,
style: StrokeStyle(lineWidth: 3)
)
.rotationEffect(.degrees(-90))
@@ -288,7 +291,7 @@ struct VoiceMessagePlayer: View {
.frame(width: size, height: size)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
@@ -296,7 +299,7 @@ struct VoiceMessagePlayer: View {
ProgressView()
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
@@ -10,22 +10,21 @@ import SwiftUI
import SimpleXChat
struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Text(chatItem.content.text)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.italic()
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
}
@@ -11,6 +11,7 @@ import SimpleXChat
struct EmojiItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@@ -18,7 +19,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 6)
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
@@ -12,6 +12,7 @@ import SwiftUI
import SimpleXChat
struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@@ -42,7 +43,7 @@ struct FramedCIVoiceView: View {
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
@@ -9,25 +9,19 @@
import SwiftUI
import SimpleXChat
let notesChatColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.21)
let notesChatColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.19)
let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@ObservedObject var chat: Chat
var chatItem: ChatItem
var preview: UIImage?
@Binding var revealed: Bool
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
@State var imgWidth: CGFloat? = nil
@State var videoWidth: CGFloat? = nil
@State var metaColor = Color.secondary
var imgWidth: CGFloat? = nil
var videoWidth: CGFloat? = nil
@State private var useWhiteMetaColor: Bool = false
@State var showFullScreenImage = false
@Binding var allowMenu: Bool
@State private var showSecrets = false
@@ -58,10 +52,9 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
.onTapGesture {
if let proxy = scrollProxy,
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
proxy.scrollTo(ci.viewId, anchor: .bottom)
scrollModel.scrollToItem(id: ci.id)
}
}
}
@@ -73,18 +66,16 @@ struct FramedItemView: View {
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
.onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 }
if chatItem.content.msgContent != nil {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.bottom, 6)
.overlay(DetermineWidth())
.accessibilityLabel("")
}
}
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
.cornerRadius(18)
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
@@ -114,29 +105,33 @@ struct FramedItemView: View {
.padding(.bottom, 2)
} else {
switch (chatItem.content.msgContent) {
case let .image(text, image):
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
case let .image(text, _):
CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
.preference(
key: MetaColorPreferenceKey.self,
value: .white
)
.onAppear {
useWhiteMetaColor = true
}
.onDisappear {
useWhiteMetaColor = false
}
} else {
ciMsgContentView(chatItem)
}
case let .video(text, image, duration):
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
case let .video(text, _, duration):
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
.preference(
key: MetaColorPreferenceKey.self,
value: .white
)
.onAppear {
useWhiteMetaColor = true
}
.onDisappear {
useWhiteMetaColor = false
}
} else {
ciMsgContentView(chatItem)
}
@@ -175,13 +170,13 @@ struct FramedItemView: View {
.font(.caption)
.lineLimit(1)
}
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
.background(chatItemFrameContextColor(chatItem, theme))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@@ -233,7 +228,7 @@ struct FramedItemView: View {
// if enable this always, size of the framed voice message item will be incorrect after end of playback
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
.background(chatItemFrameContextColor(chatItem, theme))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
@@ -248,7 +243,7 @@ struct FramedItemView: View {
VStack(alignment: .leading, spacing: 2) {
Text(sender)
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
ciQuotedMsgTextView(qi, lines: 2)
}
@@ -347,13 +342,6 @@ func isRightToLeft(_ s: String) -> Bool {
return false
}
private struct MetaColorPreferenceKey: PreferenceKey {
static var defaultValue = Color.secondary
static func reduce(value: inout Color, nextValue: () -> Color) {
value = nextValue()
}
}
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
if case let .image(text, _) = ci.content.msgContent {
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
@@ -363,22 +351,22 @@ func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
return false
}
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ theme: AppTheme) -> Color {
onlyImageOrVideo(ci)
? Color.clear
: chatItemFrameColor(ci, colorScheme)
: chatItemFrameColor(ci, theme)
}
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
func chatItemFrameColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
ci.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
? theme.appColors.sentMessage
: theme.appColors.receivedMessage
}
func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
ci.chatDir.sent
? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
: Color(uiColor: .quaternarySystemFill)
? theme.appColors.sentQuote
: theme.appColors.receivedQuote
}
struct FramedItemView_Previews: PreviewProvider {
@@ -13,12 +13,12 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@State var chatItem: ChatItem
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false
@State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer?
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
if let proxy = scrollProxy {
proxy.scrollTo(chatItem.viewId)
}
scrollModel.scrollToItem(id: chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
@@ -11,6 +11,7 @@ import SimpleXChat
struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var msgError: MsgErrorType
var chatItem: ChatItem
@@ -54,6 +55,7 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var onTap: () -> Void
@@ -62,13 +64,12 @@ struct CIMsgError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
CIMetaView(chat: chat, chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture(perform: onTap)
}
@@ -11,7 +11,7 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
@@ -19,11 +19,10 @@ struct MarkedDeletedItemView: View {
var body: some View {
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
var sender: String? = nil
@@ -65,7 +66,7 @@ struct MsgContentView: View {
}
private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
if let mt = meta {
if mt.isLive {
v = v + typingIndicator(mt.recent)
@@ -79,7 +80,7 @@ struct MsgContentView: View {
return (recent ? typingIndicators[typingIdx] : noTyping)
.font(.body.monospaced())
.kerning(-2)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
@@ -87,7 +88,7 @@ struct MsgContentView: View {
}
}
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text {
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
let s = text
var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
@@ -102,7 +103,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
}
if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
}
if let s = sender {
@@ -11,6 +11,7 @@ import SimpleXChat
struct ChatItemForwardingView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
var ci: ChatItem
@@ -38,6 +39,7 @@ struct ChatItemForwardingView: View {
}
}
}
.modifier(ThemedBackground())
.alert(item: $alert) { $0.alert }
}
@@ -45,7 +47,7 @@ struct ChatItemForwardingView: View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
searchFieldView(text: $searchText, focussed: $searchFocused)
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
@@ -54,8 +56,13 @@ struct ChatItemForwardingView: View {
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
.modifier(ThemedBackground(grouped: true))
} else {
emptyList()
ZStack {
emptyList()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(ThemedBackground())
}
}
}
@@ -106,10 +113,10 @@ struct ChatItemForwardingView: View {
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = prohibitedByPref(chat)
Button {
@@ -139,7 +146,7 @@ struct ChatItemForwardingView: View {
ChatInfoImage(chat: chat, size: 30)
.padding(.trailing, 2)
Text(chat.chatInfo.chatViewName)
.foregroundColor(prohibited ? .secondary : .primary)
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
if chat.chatInfo.incognito {
Spacer()
@@ -147,7 +154,7 @@ struct ChatItemForwardingView: View {
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -181,5 +188,5 @@ private func canForwardToChat(_ chat: Chat) -> Bool {
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
)
).environmentObject(CurrentColors.toAppTheme())
}
@@ -12,7 +12,7 @@ import SimpleXChat
struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
@@ -101,12 +101,14 @@ struct ChatItemInfoView: View {
Label("History", systemImage: "clock")
}
.tag(CIInfoTab.history)
.modifier(ThemedBackground())
if let qi = ci.quotedItem {
quoteTab(qi)
.tabItem {
Label("In reply to", systemImage: "arrowshape.turn.up.left")
}
.tag(CIInfoTab.quote)
.modifier(ThemedBackground())
}
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
forwardedFromTab(forwardedFromItem)
@@ -114,6 +116,7 @@ struct ChatItemInfoView: View {
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
}
.tag(CIInfoTab.forwarded)
.modifier(ThemedBackground())
}
}
.onAppear {
@@ -123,6 +126,7 @@ struct ChatItemInfoView: View {
}
} else {
historyTab()
.modifier(ThemedBackground())
}
}
@@ -212,7 +216,7 @@ struct ChatItemInfoView: View {
}
else {
Text("No history")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
}
@@ -227,8 +231,8 @@ struct ChatItemInfoView: View {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(ci, colorScheme))
.cornerRadius(18)
.background(chatItemFrameColor(ci, theme))
.modifier(ChatItemClipped())
.contextMenu {
if itemVersion.msgContent.text != "" {
Button {
@@ -258,18 +262,19 @@ struct ChatItemInfoView: View {
} else {
Text("no text")
.italic()
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
private struct TextBubble: View {
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
@State private var showSecrets = false
var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
}
}
@@ -296,8 +301,8 @@ struct ChatItemInfoView: View {
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, colorScheme))
.cornerRadius(18)
.background(quotedMsgFrameColor(qi, theme))
.modifier(ChatItemClipped())
.contextMenu {
if qi.text != "" {
Button {
@@ -320,10 +325,10 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
(qi.chatDir?.sent ?? false)
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
? theme.appColors.sentMessage
: theme.appColors.receivedMessage
}
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
@@ -358,7 +363,7 @@ struct ChatItemInfoView: View {
Divider().padding(.top, 32)
Text("Recipient(s) can't see who this message is from.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
}
@@ -372,23 +377,23 @@ struct ChatItemInfoView: View {
VStack(alignment: .leading) {
Text("you")
.italic()
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
VStack(alignment: .leading) {
Text(groupMember.chatViewName)
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else {
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
}
}
@@ -410,7 +415,7 @@ struct ChatItemInfoView: View {
}
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
VStack(alignment: .leading, spacing: 12) {
LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
@@ -418,12 +423,12 @@ struct ChatItemInfoView: View {
}
} else {
Text("No delivery information")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
}
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
@@ -433,7 +438,7 @@ struct ChatItemInfoView: View {
})
}
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2)
@@ -442,26 +447,22 @@ struct ChatItemInfoView: View {
Spacer()
if sentViaProxy == true {
Image(systemName: "arrow.forward")
.foregroundColor(.secondary).opacity(0.67)
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
switch status {
case .sndRcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
switch status {
case .rcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
} else {
Image(systemName: "ellipsis")
.foregroundColor(Color.secondary)
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
}
+52 -23
View File
@@ -11,9 +11,9 @@ import SimpleXChat
struct ChatItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@@ -24,7 +24,6 @@ struct ChatItemView: View {
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
scrollProxy: ScrollViewProxy? = nil,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
@@ -34,7 +33,6 @@ struct ChatItemView: View {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
@@ -62,12 +60,43 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
let preview = chatItem.content.msgContent
.flatMap {
switch $0 {
case let .image(_, image): image
case let .video(_, image, _): image
default: nil
}
}
.map { dropImagePrefix($0) }
.flatMap { Data(base64Encoded: $0) }
.flatMap { UIImage(data: $0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
} else {
maxWidth
}
}()
return FramedItemView(
chat: chat,
chatItem: chatItem,
preview: preview,
revealed: $revealed,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
}
}
struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
@@ -97,14 +126,14 @@ struct ChatItemContentView<Content: View>: View {
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case .sndModerated: deletedItemView()
@@ -131,20 +160,20 @@ struct ChatItemContentView<Content: View>: View {
}
private func eventItemView() -> some View {
return CIEventView(eventText: eventItemViewText())
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
}
private func eventItemViewText() -> Text {
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
return chatEventText(t + Text(" ") + chatItem.timestampText)
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(secondaryColor)
.fontWeight(.light)
+ chatEventText(chatItem)
+ chatEventText(chatItem, secondaryColor)
} else {
return chatEventText(chatItem)
return chatEventText(chatItem, secondaryColor)
}
}
@@ -179,7 +208,7 @@ struct ChatItemContentView<Content: View>: View {
info.pqEnabled
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
: e2eeInfoNoPQText()
}
@@ -187,24 +216,24 @@ struct ChatItemContentView<Content: View>: View {
private func e2eeInfoNoPQText() -> Text {
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
}
}
func chatEventText(_ text: Text) -> Text {
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
text
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(secondaryColor)
.fontWeight(.light)
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts)
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
}
func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText)
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
}
struct ChatItemView_Previews: PreviewProvider {
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
import SwiftUI
struct ComposeFileView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let fileName: String
let cancelFile: (() -> Void)
let cancelEnabled: Bool
@@ -33,7 +33,7 @@ struct ComposeFileView: View {
.padding(.vertical, 1)
.padding(.trailing, 12)
.frame(height: 50)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let images: [String]
let cancelImage: (() -> Void)
let cancelEnabled: Bool
@@ -48,7 +48,7 @@ struct ComposeImageView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
@@ -40,7 +40,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
}
struct ComposeLinkView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview?
var cancelPreview: (() -> Void)? = nil
let cancelEnabled: Bool
@@ -62,7 +62,7 @@ struct ComposeLinkView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
@@ -82,7 +82,7 @@ struct ComposeLinkView: View {
Text(linkPreview.uri.absoluteString)
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
@@ -254,6 +254,7 @@ enum UploadContent: Equatable {
struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool
@@ -314,6 +315,7 @@ struct ComposeView: View {
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo,
!g.fullGroupPreferences.files.on(for: g.membership) {
b.disabled(true).onTapGesture {
@@ -354,16 +356,16 @@ struct ComposeView: View {
keyboardVisible: $keyboardVisible,
sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: .accentColor
: theme.colors.primary
)
.padding(.trailing, 12)
.background(.background)
.background(theme.colors.background)
.disabled(!chat.userCanSend)
if chat.userIsObserver {
Text("you are observer")
.italic()
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
@@ -655,7 +657,7 @@ struct ComposeView: View {
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
HStack {
Image(systemName: icon).foregroundColor(.secondary)
Image(systemName: icon).foregroundColor(theme.colors.secondary)
Text(reason).italic()
}
.padding(12)
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var recordingFileName: String
@Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState
@@ -50,7 +50,7 @@ struct ComposeVoiceView: View {
}
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
Button {
startPlayback()
} label: {
playPauseIcon("play.fill")
playPauseIcon("play.fill", theme.colors.primary)
}
Text(voiceMessageTime_(recordingTime))
case .playing:
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
playPauseIcon("pause.fill", theme.colors.primary)
}
Text(voiceMessageTime_(playbackTime))
case .paused:
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
playPauseIcon("play.fill", theme.colors.primary)
}
Text(voiceMessageTime_(playbackTime))
}
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
private func playPauseIcon(_ image: String, _ color: Color) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -147,9 +147,11 @@ struct ComposeVoiceView: View {
} label: {
Image(systemName: "multiply")
}
.tint(theme.colors.primary)
}
struct SliderBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void
@@ -158,10 +160,12 @@ struct ComposeVoiceView: View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity)
.frame(height: 4)
.tint(theme.colors.primary)
}
}
private struct ProgressBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -169,7 +173,7 @@ struct ComposeVoiceView: View {
GeometryReader { geometry in
ZStack {
Rectangle()
.fill(Color.accentColor)
.fill(theme.colors.primary)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress)
}
@@ -9,18 +9,18 @@
import SwiftUI
struct ContextInvitingContactMemberView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var body: some View {
HStack {
Image(systemName: "message")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
Text("Send direct message to connect")
}
.padding(12)
.frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.background(theme.appColors.sentMessage)
.padding(.top, 8)
}
}
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
let contextItem: ChatItem
let contextIcon: String
@@ -23,10 +23,10 @@ struct ContextItemView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
if showSender, let sender = contextItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(.secondary)
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
msgContentView(lines: 2)
}
} else {
@@ -40,11 +40,12 @@ struct ContextItemView: View {
} label: {
Image(systemName: "multiply")
}
.tint(theme.colors.primary)
}
.padding(12)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, colorScheme))
.background(chatItemFrameColor(contextItem, theme))
.padding(.top, 8)
}
@@ -55,7 +56,7 @@ struct ContextItemView: View {
}
private func contextMsgPreview() -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false)
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
func attachment() -> Text {
switch contextItem.content.msgContent {
@@ -28,6 +28,7 @@ struct NativeTextEditor: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField(height: _height)
field.backgroundColor = .clear
field.text = text
field.textAlignment = alignment(text)
field.autocapitalizationType = .sentences
@@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
@Binding var composeState: ComposeState
@EnvironmentObject var theme: AppTheme
var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
@@ -49,7 +50,7 @@ struct SendMessageView: View {
Text("Voice message…")
.font(teFont.italic())
.multilineTextAlignment(.leading)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
@@ -247,6 +248,7 @@ struct SendMessageView: View {
}
private struct RecordVoiceMessageButton: View {
@EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@Binding var holdingVMR: Bool
@@ -256,7 +258,7 @@ struct SendMessageView: View {
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
}
.disabled(disabled)
.frame(width: 29, height: 29)
@@ -309,7 +311,7 @@ struct SendMessageView: View {
}
} label: {
Image(systemName: "mic")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
@@ -323,7 +325,7 @@ struct SendMessageView: View {
Image(systemName: "multiply")
.resizable()
.scaledToFit()
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.frame(width: 15, height: 15)
}
.frame(width: 29, height: 29)
@@ -340,7 +342,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)
@@ -383,7 +385,7 @@ struct SendMessageView: View {
}
Task {
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
while composeState.liveMessage != nil {
while await composeState.liveMessage != nil {
await update()
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
}
@@ -394,7 +396,7 @@ struct SendMessageView: View {
private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
}
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
@@ -12,6 +12,7 @@ import SimpleXChat
struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
@@ -66,8 +67,8 @@ struct ContactPreferencesView: View {
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
}
header: { featureHeader(feature, enabled) }
footer: { featureFooter(feature, enabled) }
header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) }
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) }
}
private func timedMessagesFeatureSection() -> some View {
@@ -102,8 +103,8 @@ struct ContactPreferencesView: View {
infoRow("Delete after", timeText(pref.contactPreference.ttl))
}
}
header: { featureHeader(.timedMessages, enabled) }
footer: { featureFooter(.timedMessages, enabled) }
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
}
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
@@ -21,6 +21,7 @@ struct AddGroupMembersView: View {
struct AddGroupMembersViewCommon: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var chat: Chat
@State var groupInfo: GroupInfo
var creatingGroup: Bool = false
@@ -70,7 +71,7 @@ struct AddGroupMembersViewCommon: View {
if (membersToAdd.isEmpty) {
Text("No contacts to add")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
@@ -90,16 +91,18 @@ struct AddGroupMembersViewCommon: View {
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
Spacer()
Text("\(count) contact(s) selected")
.foregroundColor(theme.colors.secondary)
}
} else {
Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing)
.foregroundColor(theme.colors.secondary)
}
}
}
Section {
searchFieldView(text: $searchText, focussed: $searchFocussed)
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary)
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
@@ -125,6 +128,7 @@ struct AddGroupMembersViewCommon: View {
.onChange(of: selectedContacts) { _ in
searchFocussed = false
}
.modifier(ThemedBackground(grouped: true))
}
private func inviteMembersButton() -> some View {
@@ -172,14 +176,14 @@ struct AddGroupMembersViewCommon: View {
var iconColor: Color
if prohibitedToInviteIncognito {
icon = "theatermasks.circle.fill"
iconColor = Color(uiColor: .tertiaryLabel)
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
} else {
if checked {
icon = "checkmark.circle.fill"
iconColor = .accentColor
iconColor = theme.colors.primary
} else {
icon = "circle"
iconColor = Color(uiColor: .tertiaryLabel)
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
}
}
return Button {
@@ -197,7 +201,7 @@ struct AddGroupMembersViewCommon: View {
ProfileImage(imageStr: contact.image, size: 30)
.padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
Spacer()
Image(systemName: icon)
@@ -207,7 +211,7 @@ struct AddGroupMembersViewCommon: View {
}
}
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View {
HStack {
Image(systemName: "magnifyingglass")
.resizable()
@@ -216,7 +220,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
.padding(.trailing, 10)
TextField("Search", text: text)
.focused(focussed)
.foregroundColor(.primary)
.foregroundColor(onBackgroundColor)
.frame(maxWidth: .infinity)
Image(systemName: "xmark.circle.fill")
.resizable()
@@ -228,7 +232,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
focussed.wrappedValue = false
}
}
.foregroundColor(.secondary)
.foregroundColor(secondaryColor)
.frame(height: 36)
}
@@ -13,6 +13,7 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
@@ -81,13 +82,19 @@ struct GroupChatInfoView: View {
} else {
sendReceiptsOptionDisabled()
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
} header: {
Text("")
} footer: {
Text("Only group owners can change group preferences.")
.foregroundColor(theme.colors.secondary)
}
Section("\(members.count + 1) members") {
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
groupLinkButton()
if (chat.chatInfo.incognito) {
@@ -99,7 +106,7 @@ struct GroupChatInfoView: View {
}
}
if members.count > 8 {
searchFieldView(text: $searchText, focussed: $searchFocussed)
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
}
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
@@ -129,12 +136,13 @@ struct GroupChatInfoView: View {
}
if developerTools {
Section(header: Text("For console")) {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
}
}
}
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -199,6 +207,7 @@ struct GroupChatInfoView: View {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
@@ -210,6 +219,7 @@ struct GroupChatInfoView: View {
private struct MemberRowView: View {
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
@@ -220,14 +230,13 @@ struct GroupChatInfoView: View {
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
Spacer()
memberInfo(member)
@@ -257,15 +266,25 @@ struct GroupChatInfoView: View {
}
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else {
return member.memberStatus.shortText
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
if [.owner, .admin, .observer].contains(role) {
Text(member.memberRole.text)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
}
@@ -276,13 +295,13 @@ struct GroupChatInfoView: View {
Button {
alert = .blockMemberAlert(mem: member)
} label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
} else {
Button {
alert = .unblockMemberAlert(mem: member)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
}
}
@@ -294,13 +313,13 @@ struct GroupChatInfoView: View {
Button {
alert = .unblockForAllAlert(mem: member)
} label: {
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
} else {
Button {
alert = .blockForAllAlert(mem: member)
} label: {
Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
}
}
@@ -316,6 +335,14 @@ struct GroupChatInfoView: View {
}
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(theme.colors.secondary)
}
}
private func memberInfoView(_ groupMember: GMember) -> some View {
@@ -333,6 +360,7 @@ struct GroupChatInfoView: View {
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
if groupLink == nil {
@@ -350,6 +378,7 @@ struct GroupChatInfoView: View {
groupProfile: groupInfo.groupProfile
)
.navigationBarTitle("Group profile")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
@@ -364,6 +393,7 @@ struct GroupChatInfoView: View {
welcomeText: groupInfo.groupProfile.description ?? ""
)
.navigationTitle("Welcome message")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
groupInfo.groupProfile.description == nil
@@ -518,6 +548,7 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
creatingGroup: creatingGroup
)
.navigationBarTitle("Group preferences")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
if creatingGroup {
@@ -528,14 +559,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
@@ -133,6 +133,7 @@ struct GroupLinkView: View {
shouldCreate = false
}
}
.modifier(ThemedBackground(grouped: true))
}
private func createGroupLink() {
@@ -11,6 +11,7 @@ import SimpleXChat
struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@@ -120,12 +121,14 @@ struct GroupMemberInfoView: View {
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section("Member") {
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
@@ -138,16 +141,10 @@ struct GroupMemberInfoView: View {
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
if let connStats = connectionStats {
Section("Servers") {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
@@ -165,8 +162,8 @@ struct GroupMemberInfoView: View {
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
@@ -177,9 +174,13 @@ struct GroupMemberInfoView: View {
}
if developerTools {
Section("For console") {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
@@ -247,6 +248,7 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2)
}
}
.modifier(ThemedBackground(grouped: true))
}
func connectViaAddressButton(_ contactLink: String) -> some View {
@@ -326,7 +328,7 @@ struct GroupMemberInfoView: View {
if mem.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ Text(" ")
+ Text(mem.displayName)
@@ -374,6 +376,7 @@ struct GroupMemberInfoView: View {
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
.modifier(ThemedBackground())
} label: {
Label(
member.verified ? "View security code" : "Verify security code",
@@ -423,7 +426,7 @@ struct GroupMemberInfoView: View {
Section {
if mem.blockedByAdmin {
Label("Blocked by admin", systemImage: "hand.raised")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
} else if mem.memberSettings.showMessages {
blockMemberButton(mem)
} else {
@@ -18,6 +18,7 @@ private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] =
struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
@@ -73,7 +74,7 @@ struct GroupPreferencesView: View {
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
if groupInfo.canEdit {
@@ -111,18 +112,19 @@ struct GroupPreferencesView: View {
}
if enableFeature.wrappedValue == .on, let enableForRole {
HStack {
Text("Enabled for").foregroundColor(.secondary)
Text("Enabled for").foregroundColor(theme.colors.secondary)
Spacer()
Text(
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
?? "all members"
)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
}
}
} footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.foregroundColor(theme.colors.secondary)
}
.onChange(of: enableFeature.wrappedValue) { enabled in
if case .off = enabled {
@@ -11,6 +11,7 @@ import SimpleXChat
struct GroupWelcomeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
@State var welcomeText: String
@@ -57,7 +58,7 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -70,7 +71,7 @@ struct GroupWelcomeView: View {
Group {
if welcomeText.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.disabled(true)
}
TextEditor(text: $welcomeText)
@@ -0,0 +1,273 @@
//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
let items: Array<Item>
@Binding var scrollState: ReverseListScrollModel<Item>.State
/// Closure, that returns user interface for a given item
let content: (Item) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: .zero, position: .top)
}
} else {
controller.update(items: items)
}
}
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var itemCount: Int = .zero
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: cellReuseId
)
} else {
tableView.register(
HostingCell<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, .zero)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.representer.content(item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
cell.backgroundColor = .clear
return cell
}
// 4. External state changes will require manual layout updates
NotificationCenter.default
.addObserver(
self,
selector: #selector(updateLayout),
name: notificationName,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
CGPoint(
x: tableView.contentOffset.x,
y: tableView.contentOffset.y + tableView.bounds.height
),
animated: true
)
Task { representer.scrollState = .atDestination }
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
if let index {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
tableView.scrollToRow(
at: IndexPath(row: index, section: .zero),
at: position,
animated: animated
)
Task { representer.scrollState = .atDestination }
}
}
func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
)
itemCount = items.count
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<Hosted: View>: UITableViewCell {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !contentView.subviews.contains(hostingController.view) {
contentView.addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: contentView.leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: contentView.trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: contentView.topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: contentView.bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
override func prepareForReuse() {
super.prepareForReuse()
hostingController.rootView = nil
}
}
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(Item.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: Item.ID) {
state = .scrollingTo(.item(id))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
@@ -10,6 +10,7 @@ import SwiftUI
struct VerifyCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
var displayName: String
@State var connectionCode: String?
@State var connectionVerified: Bool
@@ -30,7 +31,7 @@ struct VerifyCodeView: View {
HStack {
if connectionVerified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
Text("\(displayName) is verified")
} else {
Text("\(displayName) is not verified")
@@ -66,6 +67,7 @@ struct VerifyCodeView: View {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.large)
.navigationTitle("Scan code")
.modifier(ThemedBackground())
} label: {
Label("Scan code", systemImage: "qrcode")
}
@@ -122,5 +124,6 @@ struct VerifyCodeView: View {
struct VerifyCodeView_Previews: PreviewProvider {
static var previews: some View {
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
.environmentObject(CurrentColors.toAppTheme())
}
}
@@ -26,6 +26,7 @@ private let rowHeights: [DynamicTypeSize: CGFloat] = [
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject var chat: Chat
@State private var showContactRequestDialog = false
@@ -224,7 +225,7 @@ struct ChatListNavLink: View {
} label: {
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
}
.tint(chat.chatInfo.incognito ? .indigo : .accentColor)
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
}
@ViewBuilder private func markReadButton() -> some View {
@@ -234,14 +235,14 @@ struct ChatListNavLink: View {
} label: {
Label("Read", systemImage: "checkmark")
}
.tint(Color.accentColor)
.tint(theme.colors.primary)
} else {
Button {
Task { await markChatUnread(chat) }
} label: {
Label("Unread", systemImage: "circlebadge.fill")
}
.tint(Color.accentColor)
.tint(theme.colors.primary)
}
}
@@ -306,7 +307,7 @@ struct ChatListNavLink: View {
Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(.accentColor)
.tint(theme.colors.primary)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
@@ -346,7 +347,7 @@ struct ChatListNavLink: View {
} label: {
Label("Name", systemImage: "pencil")
}
.tint(.accentColor)
.tint(theme.colors.primary)
}
.frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) {
@@ -354,6 +355,7 @@ struct ChatListNavLink: View {
if case let .contactConnection(contactConnection) = chat.chatInfo {
ContactConnectionInfo(contactConnection: contactConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.modifier(ThemedBackground(grouped: true))
}
}
}
+102 -48
View File
@@ -11,6 +11,7 @@ import SimpleXChat
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool
@State private var searchMode = false
@FocusState private var searchFocussed
@@ -86,6 +87,7 @@ struct ChatListView: View {
))
}
.listStyle(.plain)
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode)
.toolbar {
@@ -115,9 +117,7 @@ struct ChatListView: View {
HStack(spacing: 4) {
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
toggleFilterButton()
}
SubsStatusIndicator()
}
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -131,15 +131,6 @@ struct ChatListView: View {
}
}
private func toggleFilterButton() -> some View {
Button {
showUnreadAndFavorites = !showUnreadAndFavorites
} label: {
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
.foregroundColor(.accentColor)
}
}
@ViewBuilder private var chatList: some View {
let cs = filteredChats()
ZStack {
@@ -154,12 +145,14 @@ struct ChatListView: View {
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
}
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
.listRowBackground(Color.clear)
}
.offset(x: -8)
}
@@ -171,7 +164,7 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(.secondary)
Text("No filtered chats").foregroundColor(theme.colors.secondary)
}
}
}
@@ -179,7 +172,7 @@ struct ChatListView: View {
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
}
private func onboardingButtons() -> some View {
@@ -190,7 +183,7 @@ struct ChatListView: View {
p.addLine(to: CGPoint(x: 0, y: 10))
p.addLine(to: CGPoint(x: 8, y: 0))
}
.fill(Color.accentColor)
.fill(theme.colors.primary)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
@@ -200,7 +193,7 @@ struct ChatListView: View {
Spacer()
Text("You have no chats")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
.padding(.trailing, 6)
@@ -213,16 +206,14 @@ struct ChatListView: View {
.padding(.vertical, 10)
.padding(.horizontal, 20)
}
.background(Color.accentColor)
.background(theme.colors.primary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
ChatView(chat: chat)
}
}
@@ -274,17 +265,87 @@ struct ChatListView: View {
}
}
struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var sess: ServerSessions = ServerSessions.newServerSessions
@State private var timer: Timer? = nil
@State private var timerCounter = 0
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
// Constants for the intervals
let initialInterval: TimeInterval = 1.0
let regularInterval: TimeInterval = 3.0
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
var body: some View {
Button {
showServersSummary = true
} label: {
HStack(spacing: 4) {
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: sess)
}
}
}
.onAppear {
startInitialTimer()
}
.onDisappear {
stopTimer()
}
.sheet(isPresented: $showServersSummary) {
ServersSummaryView()
}
}
private func startInitialTimer() {
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
getServersSummary()
timerCounter += 1
// Switch to the regular timer after the initial phase
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
switchToRegularTimer()
}
}
}
func switchToRegularTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
getServersSummary()
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func getServersSummary() {
do {
let summ = try getAgentServersSummary()
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
@State private var showScanCodeSheet = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
@@ -292,7 +353,7 @@ struct ChatListSearchBar: View {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search or paste SimpleX link", text: $searchText)
.foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
.foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground)
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
@@ -301,48 +362,26 @@ struct ChatListSearchBar: View {
.onTapGesture {
searchText = ""
}
} else if !searchFocussed {
HStack(spacing: 24) {
if m.pasteboardHasStrings {
Image(systemName: "doc")
.onTapGesture {
if let str = UIPasteboard.general.string {
searchText = str
}
}
}
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.onTapGesture {
showScanCodeSheet = true
}
}
.padding(.trailing, 2)
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.onTapGesture {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
Divider()
}
.sheet(isPresented: $showScanCodeSheet) {
NewChatView(selection: .connect, showQRCodeScanner: true)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
@@ -376,6 +415,21 @@ struct ChatListSearchBar: View {
}
}
private func toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -11,10 +11,10 @@ import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@State var deleting: Bool = false
@Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@@ -36,7 +36,7 @@ struct ChatPreviewView: View {
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.top, 4)
}
.padding(.bottom, 4)
@@ -94,9 +94,9 @@ struct ChatPreviewView: View {
case let .group(groupInfo):
let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) {
case .memInvited: v.foregroundColor(deleting ? .secondary : chat.chatInfo.incognito ? .indigo : .accentColor)
case .memAccepted: v.foregroundColor(.secondary)
default: if deleting { v.foregroundColor(.secondary) } else { v }
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
case .memAccepted: v.foregroundColor(theme.colors.secondary)
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
}
default: previewTitle(t)
}
@@ -108,7 +108,7 @@ struct ChatPreviewView: View {
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
}
@@ -133,11 +133,11 @@ struct ChatPreviewView: View {
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? Color.accentColor : Color.secondary)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
@@ -151,9 +151,9 @@ struct ChatPreviewView: View {
private func messageDraft(_ draft: ComposeState) -> Text {
let msg = draft.message
return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ attachment()
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false)
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
@@ -172,7 +172,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
@@ -206,7 +206,7 @@ struct ChatPreviewView: View {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
} else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message")
@@ -257,38 +257,38 @@ struct ChatPreviewView: View {
case let .direct(contact):
if contact.active && contact.activeConn != nil {
switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito)
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
case .group:
if progressByTimeout {
ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
default:
incognitoIcon(chat.chatInfo.incognito)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
}
}
@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
if incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
.foregroundColor(secondaryColor)
} else {
EmptyView()
}
@@ -11,6 +11,7 @@ import SimpleXChat
struct ContactConnectionInfo: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection
@State private var alert: CCInfoAlert?
@@ -48,7 +49,7 @@ struct ContactConnectionInfo: View {
Section {
if contactConnection.groupLinkId == nil {
settingsRow("pencil") {
settingsRow("pencil", color: theme.colors.secondary) {
TextField("Set contact name…", text: $localAlias)
.autocapitalization(.none)
.autocorrectionDisabled(true)
@@ -63,14 +64,15 @@ struct ContactConnectionInfo: View {
let connReqInv = contactConnection.connReqInv {
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
incognitoEnabled()
shareLinkButton(connReqInv)
oneTimeLinkLearnMoreButton()
shareLinkButton(connReqInv, theme.colors.secondary)
oneTimeLinkLearnMoreButton(theme.colors.secondary)
} else {
incognitoEnabled()
oneTimeLinkLearnMoreButton()
oneTimeLinkLearnMoreButton(theme.colors.secondary)
}
} footer: {
sharedProfileInfo(contactConnection.incognito)
.foregroundColor(theme.colors.secondary)
}
Section {
@@ -82,6 +84,7 @@ struct ContactConnectionInfo: View {
}
}
}
.modifier(ThemedBackground(grouped: true))
if #available(iOS 16, *) {
v
} else {
@@ -149,7 +152,7 @@ struct ContactConnectionInfo: View {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
}
.onTapGesture {
@@ -164,23 +167,24 @@ struct ContactConnectionInfo: View {
}
}
private func shareLinkButton(_ connReqInvitation: String) -> some View {
private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: {
settingsRow("square.and.arrow.up") {
settingsRow("square.and.arrow.up", color: secondaryColor) {
Text("Share 1-time link")
}
}
}
private func oneTimeLinkLearnMoreButton() -> some View {
private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
NavigationLink {
AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("info.circle") {
settingsRow("info.circle", color: secondaryColor) {
Text("Learn more")
}
}
@@ -12,6 +12,7 @@ import SimpleXChat
struct ContactConnectionView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
@@ -29,7 +30,7 @@ struct ContactConnectionView: View {
.resizable()
.scaledToFill()
.frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .secondarySystemBackground))
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
.onTapGesture { showContactConnectionInfo = true }
}
.frame(width: 63, height: 63)
@@ -41,7 +42,7 @@ struct ContactConnectionView: View {
.font(.title3)
.bold()
.allowsTightening(false)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 8)
.padding(.top, 1)
.padding(.bottom, 0.5)
@@ -54,14 +55,14 @@ struct ContactConnectionView: View {
.padding(.trailing, 8)
.padding(.vertical, 4)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.padding(.bottom, 2)
ZStack(alignment: .topTrailing) {
Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito)
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
@@ -11,6 +11,7 @@ import SimpleXChat
struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var contactRequest: UserContactRequest
@ObservedObject var chat: Chat
@@ -23,7 +24,7 @@ struct ContactRequestView: View {
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.accentColor)
.foregroundColor(theme.colors.primary)
.padding(.leading, 8)
.frame(alignment: .topLeading)
Spacer()
@@ -32,7 +33,7 @@ struct ContactRequestView: View {
.padding(.trailing, 8)
.padding(.top, 4)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.padding(.bottom, 2)
@@ -0,0 +1,738 @@
//
// ServersSummaryView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 25.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ServersSummaryView: View {
@EnvironmentObject var m: ChatModel
@State private var serversSummary: PresentedServersSummary? = nil
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
@State private var selectedServerType: PresentedServerType = .smp
@State private var selectedSMPServer: String? = nil
@State private var selectedXFTPServer: String? = nil
@State private var timer: Timer? = nil
@State private var alert: SomeAlert?
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
enum PresentedUserCategory {
case currentUser
case allUsers
}
enum PresentedServerType {
case smp
case xftp
}
var body: some View {
NavigationView {
viewBody()
.navigationTitle("Servers info")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
shareButton()
}
}
}
.onAppear {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 {
selectedUserCategory = .currentUser
}
getServersSummary()
startTimer()
}
.onDisappear {
stopTimer()
}
.alert(item: $alert) { $0.alert }
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
getServersSummary()
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func shareButton() -> some View {
Button {
if let serversSummary = serversSummary {
showShareSheet(items: [encodePrettyPrinted(serversSummary)])
}
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(serversSummary == nil)
}
public func encodePrettyPrinted<T: Encodable>(_ value: T) -> String {
let encoder = jsonEncoder
encoder.outputFormatting = .prettyPrinted
let data = try! encoder.encode(value)
return String(decoding: data, as: UTF8.self)
}
@ViewBuilder private func viewBody() -> some View {
if let summ = serversSummary {
List {
Group {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
Picker("User selection", selection: $selectedUserCategory) {
Text("All users").tag(PresentedUserCategory.allUsers)
Text("Current user").tag(PresentedUserCategory.currentUser)
}
.pickerStyle(.segmented)
}
Picker("Server type", selection: $selectedServerType) {
Text("Messages").tag(PresentedServerType.smp)
Text("Files").tag(PresentedServerType.xftp)
}
.pickerStyle(.segmented)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
switch (selectedUserCategory, selectedServerType) {
case (.allUsers, .smp):
let smpSumm = summ.allUsersSMP
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
smpSubsSection(totals)
if curr.count > 0 {
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
}
if prev.count > 0 {
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
}
if prox.count > 0 {
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
}
ServerSessionsView(sess: totals.sessions)
case (.currentUser, .smp):
let smpSumm = summ.currentUserSMP
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
smpSubsSection(totals)
if curr.count > 0 {
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
}
if prev.count > 0 {
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
}
if prox.count > 0 {
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
}
ServerSessionsView(sess: totals.sessions)
case (.allUsers, .xftp):
let xftpSumm = summ.allUsersXFTP
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
if curr.count > 0 {
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
}
if prev.count > 0 {
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
}
ServerSessionsView(sess: totals.sessions)
case (.currentUser, .xftp):
let xftpSumm = summ.currentUserXFTP
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
if curr.count > 0 {
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
}
if prev.count > 0 {
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
}
ServerSessionsView(sess: totals.sessions)
}
Section {
reconnectAllButton()
resetStatsButton()
}
}
} else {
Text("No info, try to reload")
}
}
private func smpSubsSection(_ totals: SMPTotals) -> some View {
Section {
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total))
} header: {
HStack {
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
}
}
}
}
private func reconnectAllButton() -> some View {
Button {
alert = SomeAlert(
alert: Alert(
title: Text("Reconnect all servers?"),
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
primaryButton: .default(Text("Ok")) {
Task {
do {
try await reconnectAllServers()
} catch let error {
alert = SomeAlert(
alert: mkAlert(
title: "Error reconnecting servers",
message: "\(responseError(error))"
),
id: "error reconnecting servers"
)
}
}
},
secondaryButton: .cancel()
),
id: "reconnect servers question"
)
} label: {
Text("Reconnect all servers")
}
}
@ViewBuilder private func smpServersListView(
_ servers: [SMPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil
) -> some View {
let sortedServers = servers.sorted {
$0.hasSubs == $1.hasSubs
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
: $0.hasSubs && !$1.hasSubs
}
Section {
ForEach(sortedServers) { server in
smpServerView(server, statsStartedAt)
}
} header: {
if let header = header {
Text(header)
}
} footer: {
if let footer = footer {
Text(footer)
}
}
}
private func smpServerView(_ srvSumm: SMPServerSummary, _ statsStartedAt: Date) -> some View {
NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) {
SMPServerSummaryView(
summary: srvSumm,
statsStartedAt: statsStartedAt
)
.navigationBarTitle("SMP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.smpServer))
.lineLimit(1)
if let subs = srvSumm.subs {
Spacer()
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
}
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
} else if let sess = srvSumm.sessions {
Spacer()
Image(systemName: "arrow.up.circle")
.symbolRenderingMode(.palette)
.foregroundStyle(sessIconColor(sess), Color.clear)
}
}
}
}
private func serverAddress(_ server: String) -> String {
parseServerAddress(server)?.hostnames.first ?? server
}
private func sessIconColor(_ sess: ServerSessions) -> Color {
let online = m.networkInfo.online
return (
online && sess.ssConnected > 0
? sessionActiveColor
: Color(uiColor: .tertiaryLabel)
)
}
private var sessionActiveColor: Color {
let onionHosts = networkUseOnionHostsGroupDefault.get()
return onionHosts == .require ? .indigo : .accentColor
}
@ViewBuilder private func xftpServersListView(
_ servers: [XFTPServerSummary],
_ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil
) -> some View {
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
Section {
ForEach(sortedServers) { server in
xftpServerView(server, statsStartedAt)
}
} header: {
if let header = header {
Text(header)
}
} footer: {
if let footer = footer {
Text(footer)
}
}
}
private func xftpServerView(_ srvSumm: XFTPServerSummary, _ statsStartedAt: Date) -> some View {
NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) {
XFTPServerSummaryView(
summary: srvSumm,
statsStartedAt: statsStartedAt
)
.navigationBarTitle("XFTP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.xftpServer))
.lineLimit(1)
if let inProgressIcon = inProgressIcon(srvSumm) {
Spacer()
Image(systemName: inProgressIcon)
.symbolRenderingMode(.palette)
.foregroundStyle(sessionActiveColor, Color.clear)
}
}
}
}
private func inProgressIcon(_ srvSumm: XFTPServerSummary) -> String? {
switch (srvSumm.rcvInProgress, srvSumm.sndInProgress, srvSumm.delInProgress) {
case (false, false, false): nil
case (true, false, false): "arrow.down.circle"
case (false, true, false): "arrow.up.circle"
case (false, false, true): "trash.circle"
default: "arrow.up.arrow.down.circle"
}
}
private func resetStatsButton() -> some View {
Button {
alert = SomeAlert(
alert: Alert(
title: Text("Reset all statistics?"),
message: Text("Servers statistics will be reset - this cannot be undone!"),
primaryButton: .destructive(Text("Reset")) {
Task {
do {
try await resetAgentServersStats()
getServersSummary()
} catch let error {
alert = SomeAlert(
alert: mkAlert(
title: "Error resetting statistics",
message: "\(responseError(error))"
),
id: "error resetting statistics"
)
}
}
},
secondaryButton: .cancel()
),
id: "reset statistics question"
)
} label: {
Text("Reset all statistics")
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
} else {
Image(systemName: "dot.radiowaves.up.forward")
.foregroundColor(color.opacity(opacity))
}
}
}
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
: (n * 4).rounded() / 4
}
let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return online && subs.total > 0
? (
subs.ssActive == 0
? (
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
: ( // ssActive > 0
sess.ssConnected == 0
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
)
: noConnColorAndPercent
}
struct SMPServerSummaryView: View {
var summary: SMPServerSummary
var statsStartedAt: Date
@State private var alert: SomeAlert?
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
var body: some View {
List {
Section("Server address") {
Text(summary.smpServer)
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
}
if let stats = summary.stats {
SMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
}
if let subs = summary.subs {
smpSubsSection(subs)
}
if let sess = summary.sessions {
ServerSessionsView(sess: sess)
}
}
.alert(item: $alert) { $0.alert }
}
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
Section {
infoRow("Connections subscribed", numOrDash(subs.ssActive))
infoRow("Pending", numOrDash(subs.ssPending))
infoRow("Total", numOrDash(subs.total))
reconnectButton()
} header: {
HStack {
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
}
}
}
}
private func reconnectButton() -> some View {
Button {
alert = SomeAlert(
alert: Alert(
title: Text("Reconnect server?"),
message: Text("Reconnect server to force message delivery. It uses additional traffic."),
primaryButton: .default(Text("Ok")) {
Task {
do {
try await reconnectServer(smpServer: summary.smpServer)
} catch let error {
alert = SomeAlert(
alert: mkAlert(
title: "Error reconnecting server",
message: "\(responseError(error))"
),
id: "error reconnecting server"
)
}
}
},
secondaryButton: .cancel()
),
id: "reconnect server question"
)
} label: {
Text("Reconnect")
}
}
}
struct ServerSessionsView: View {
var sess: ServerSessions
var body: some View {
Section("Transport sessions") {
infoRow("Connected", numOrDash(sess.ssConnected))
infoRow("Errors", numOrDash(sess.ssErrors))
infoRow("Connecting", numOrDash(sess.ssConnecting))
}
}
}
struct SMPStatsView: View {
var stats: AgentSMPServerStatsData
var statsStartedAt: Date
var body: some View {
Section {
infoRow("Messages sent", numOrDash(stats._sentDirect + stats._sentViaProxy))
infoRow("Messages received", numOrDash(stats._recvMsgs))
NavigationLink {
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
} header: {
Text("Statistics")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
}
}
}
private func numOrDash(_ n: Int) -> String {
n == 0 ? "-" : "\(n)"
}
struct DetailedSMPStatsView: View {
var stats: AgentSMPServerStatsData
var statsStartedAt: Date
var body: some View {
List {
Section("Sent messages") {
infoRow("Sent total", numOrDash(stats._sentDirect + stats._sentViaProxy))
infoRowTwoValues("Sent directly", "attempts", stats._sentDirect, stats._sentDirectAttempts)
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
Text("Send errors")
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
}
Section("Received messages") {
infoRow("Received total", numOrDash(stats._recvMsgs))
Text("Receive errors")
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
Text("Acknowledgement errors")
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
}
Section {
infoRow("Created", numOrDash(stats._connCreated))
infoRow("Secured", numOrDash(stats._connCreated))
infoRow("Completed", numOrDash(stats._connCompleted))
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
} header: {
Text("Connections")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).")
}
}
}
}
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
HStack {
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
Spacer()
Group {
if value == 0 && value2 == 0 {
Text(verbatim: "-")
} else {
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
}
}
.foregroundStyle(.secondary)
}
}
struct XFTPServerSummaryView: View {
var summary: XFTPServerSummary
var statsStartedAt: Date
var body: some View {
List {
Section("Server address") {
Text(summary.xftpServer)
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
}
if let stats = summary.stats {
XFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
}
if let sess = summary.sessions {
ServerSessionsView(sess: sess)
}
}
}
}
struct XFTPStatsView: View {
var stats: AgentXFTPServerStatsData
var statsStartedAt: Date
@State private var expanded = false
var body: some View {
Section {
infoRow("Uploaded", prettySize(stats._uploadsSize))
infoRow("Downloaded", prettySize(stats._downloadsSize))
NavigationLink {
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
} header: {
Text("Statistics")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
}
}
}
private func prettySize(_ sizeInKB: Int64) -> String {
let kb: Int64 = 1024
return sizeInKB == 0 ? "-" : ByteCountFormatter.string(fromByteCount: sizeInKB * kb, countStyle: .binary)
}
struct DetailedXFTPStatsView: View {
var stats: AgentXFTPServerStatsData
var statsStartedAt: Date
var body: some View {
List {
Section("Uploaded files") {
infoRow("Size", prettySize(stats._uploadsSize))
infoRowTwoValues("Chunks uploaded", "attempts", stats._uploads, stats._uploadAttempts)
infoRow("Upload errors", numOrDash(stats._uploadErrs))
infoRowTwoValues("Chunks deleted", "attempts", stats._deletions, stats._deleteAttempts)
infoRow("Deletion errors", numOrDash(stats._deleteErrs))
}
Section {
infoRow("Size", prettySize(stats._downloadsSize))
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
Text("Download errors")
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
} header: {
Text("Downloaded files")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).")
}
}
}
}
#Preview {
ServersSummaryView()
}
@@ -6,13 +6,10 @@
import SwiftUI
import SimpleXChat
private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: 0.11, alpha: 255))
private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255))
struct UserPicker: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
@EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool
@Binding var showConnectDesktop: Bool
@Binding var userPickerVisible: Bool
@@ -21,9 +18,6 @@ struct UserPicker: View {
private let menuButtonHeight: CGFloat = 68
@State var chatViewNameWidth: CGFloat = 0
var fillColor: Color {
colorScheme == .dark ? fillColorDark : fillColorLight
}
var body: some View {
VStack {
@@ -82,7 +76,7 @@ struct UserPicker: View {
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(
Rectangle()
.fill(fillColor)
.fill(theme.colors.surface)
.cornerRadius(16)
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
)
@@ -131,13 +125,13 @@ struct UserPicker: View {
.padding(.trailing, 12)
Text(user.chatViewName)
.fontWeight(user.activeUser ? .medium : .regular)
.foregroundColor(.primary)
.foregroundColor(theme.colors.onBackground)
.overlay(DetermineWidth())
Spacer()
if user.activeUser {
Image(systemName: "checkmark")
} else if u.unreadCount > 0 {
unreadCounter(u.unreadCount, color: user.showNtfs ? .accentColor : .secondary)
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash")
}
@@ -145,7 +139,7 @@ struct UserPicker: View {
.padding(.trailing)
.padding([.leading, .vertical], 12)
})
.buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill)))
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
@@ -156,13 +150,13 @@ struct UserPicker: View {
Spacer()
Image(systemName: icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
}
.padding(.horizontal)
.padding(.vertical, 22)
.frame(height: menuButtonHeight)
}
.buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill)))
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}
}
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct ChatArchiveView: View {
@EnvironmentObject var theme: AppTheme
var archiveName: String
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
@@ -20,14 +21,14 @@ struct ChatArchiveView: View {
let fileTs = chatArchiveTimeDefault.get()
List {
Section {
settingsRow("square.and.arrow.up") {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button {
showShareSheet(items: [fileUrl])
} label: {
Text("Save archive")
}
}
settingsRow("trash") {
settingsRow("trash", color: theme.colors.secondary) {
Button {
showDeleteAlert = true
} label: {
@@ -36,8 +37,10 @@ struct ChatArchiveView: View {
}
} header: {
Text("Chat archive")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Created on \(fileTs)")
.foregroundColor(theme.colors.secondary)
}
}
.alert(isPresented: $showDeleteAlert) {
@@ -35,6 +35,7 @@ enum DatabaseEncryptionAlert: Identifiable {
struct DatabaseEncryptionView: View {
@EnvironmentObject private var m: ChatModel
@EnvironmentObject private var theme: AppTheme
@Binding var useKeychain: Bool
var migration: Bool
@State private var alert: DatabaseEncryptionAlert? = nil
@@ -63,7 +64,7 @@ struct DatabaseEncryptionView: View {
private func databaseEncryptionView() -> some View {
Section {
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : theme.colors.secondary) {
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
.onChange(of: useKeychainToggle) { _ in
if useKeychainToggle {
@@ -85,7 +86,7 @@ struct DatabaseEncryptionView: View {
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
settingsRow("lock.rotation") {
settingsRow("lock.rotation", color: theme.colors.secondary) {
Button(migration ? "Set passphrase" : "Update database passphrase") {
alert = currentKey == ""
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
@@ -102,6 +103,7 @@ struct DatabaseEncryptionView: View {
)
} header: {
Text(migration ? "Database passphrase" : "")
.foregroundColor(theme.colors.secondary)
} footer: {
VStack(alignment: .leading, spacing: 16) {
if m.chatDbEncrypted == false {
@@ -125,6 +127,7 @@ struct DatabaseEncryptionView: View {
}
}
}
.foregroundColor(theme.colors.secondary)
.padding(.top, 1)
.font(.callout)
}
@@ -277,6 +280,7 @@ struct DatabaseEncryptionView: View {
struct PassphraseField: View {
@EnvironmentObject var theme: AppTheme
@Binding var key: String
var placeholder: LocalizedStringKey
var valid: Bool
@@ -287,7 +291,7 @@ struct PassphraseField: View {
var body: some View {
ZStack(alignment: .leading) {
let iconColor = valid
? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : .secondary)
? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : theme.colors.secondary)
: .red
Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle")
.resizable()
@@ -41,6 +41,7 @@ enum DatabaseAlert: Identifiable {
struct DatabaseView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool
@State private var runChat = false
@State private var alert: DatabaseAlert? = nil
@@ -82,8 +83,10 @@ struct DatabaseView: View {
.disabled(stopped || progressIndicator)
} header: {
Text("Messages")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
}
Section {
@@ -105,24 +108,27 @@ struct DatabaseView: View {
}
} header: {
Text("Run chat")
.foregroundColor(theme.colors.secondary)
} footer: {
if case .documents = dbContainer {
Text("Database will be migrated when the app restarts")
.foregroundColor(theme.colors.secondary)
}
}
Section {
let unencrypted = m.chatDbEncrypted == false
let color: Color = unencrypted ? .orange : .secondary
let color: Color = unencrypted ? .orange : theme.colors.secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink {
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
.navigationTitle("Database passphrase")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Database passphrase")
}
}
settingsRow("square.and.arrow.up") {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
alert = .exportProhibited
@@ -131,7 +137,7 @@ struct DatabaseView: View {
}
}
}
settingsRow("square.and.arrow.down") {
settingsRow("square.and.arrow.down", color: theme.colors.secondary) {
Button("Import database", role: .destructive) {
showFileImporter = true
}
@@ -140,34 +146,37 @@ struct DatabaseView: View {
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
? "Old database archive"
: "New database archive"
settingsRow("archivebox") {
settingsRow("archivebox", color: theme.colors.secondary) {
NavigationLink {
ChatArchiveView(archiveName: archiveName)
.navigationTitle(title)
.modifier(ThemedBackground(grouped: true))
} label: {
Text(title)
}
}
}
settingsRow("trash.slash") {
settingsRow("trash.slash", color: theme.colors.secondary) {
Button("Delete database", role: .destructive) {
alert = .deleteChat
}
}
} header: {
Text("Chat database")
.foregroundColor(theme.colors.secondary)
} footer: {
Text(
stopped
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
: "Stop chat to enable database actions"
)
.foregroundColor(theme.colors.secondary)
}
.disabled(!stopped)
if case .group = dbContainer, legacyDatabase {
Section("Old database") {
settingsRow("trash") {
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
settingsRow("trash", color: theme.colors.secondary) {
Button("Delete old database") {
alert = .deleteLegacyDatabase
}
@@ -182,12 +191,15 @@ struct DatabaseView: View {
.disabled(!stopped || appFilesCountAndSize?.0 == 0)
} header: {
Text("Files & media")
.foregroundColor(theme.colors.secondary)
} footer: {
if let (fileCount, size) = appFilesCountAndSize {
if fileCount == 0 {
Text("No received or sent files")
.foregroundColor(theme.colors.secondary)
} else {
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
.foregroundColor(theme.colors.secondary)
}
}
}
@@ -355,6 +367,7 @@ struct DatabaseView: View {
Task {
do {
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
do {
let config = ArchiveConfig(archivePath: archivePath.path)
let archiveErrors = try await apiImportArchive(config: config)
@@ -189,6 +189,7 @@ struct MigrateToAppGroupView: View {
Task {
do {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
try await apiExportArchive(config: config)
await MainActor.run { setV3DBMigration(.exported) }
} catch let error {
@@ -231,6 +232,7 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
if !ChatModel.shared.chatDbChanged {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
}
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
try await apiExportArchive(config: config)
if storagePath == nil {
deleteOldArchive()
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct ChatInfoImage: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var size: CGFloat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
@@ -24,8 +24,7 @@ struct ChatInfoImage: View {
case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill"
}
let notesColor = colorScheme == .light ? notesChatColorLight : notesChatColorDark
let iconColor = if case .local = chat.chatInfo { notesColor } else { color }
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: iconName,
@@ -0,0 +1,68 @@
//
// ChatItemClipShape.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 04/07/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
/// Modifier, which provides clipping mask for ``ChatItemWithMenu`` view
/// and it's previews: (drag interaction, context menu, etc.)
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
init() {
clipShape = ClipShape(
maxCornerRadius: 18
)
}
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View {
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
.clipShape(clipShape)
}
}
@@ -0,0 +1,102 @@
//
// ChatWallpaper.swift
// SimpleX (iOS)
//
// Created by Avently on 14.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
struct ChatViewBackground: ViewModifier {
@EnvironmentObject var theme: AppTheme
var image: Image
var imageType: WallpaperType
var background: Color
var tint: Color
func body(content: Content) -> some View {
content.background(
Canvas { context, size in
var image = context.resolve(image)
let rect = CGRectMake(0, 0, size.width, size.height)
func repeatDraw(_ imageScale: CGFloat) {
image.shading = .color(tint)
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
for h in 0 ... Int(size.height / image.size.height / scale) {
for w in 0 ... Int(size.width / image.size.width / scale) {
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
context.draw(image, in: rect, style: FillStyle())
}
}
}
context.fill(Path(rect), with: .color(background))
switch imageType {
case let WallpaperType.preset(filename, scale):
repeatDraw(CGFloat((scale ?? 1) * (PresetWallpaper.from(filename)?.scale ?? 1)))
case let WallpaperType.image(_, scale, scaleType):
let scaleType = scaleType ?? WallpaperScaleType.fill
switch scaleType {
case WallpaperScaleType.repeat: repeatDraw(CGFloat(scale ?? 1))
case WallpaperScaleType.fill: fallthrough
case WallpaperScaleType.fit:
let scale = scaleType.computeScaleFactor(image.size, size)
let scaledWidth = (image.size.width * scale.0)
let scaledHeight = (image.size.height * scale.1)
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
if case WallpaperScaleType.fit = scaleType {
if scaledWidth < size.width {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while x > 0 {
context.draw(image, in: CGRectMake((x - scaledWidth), ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while x < size.width {
context.draw(image, in: CGRectMake(x, ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while y > 0 {
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), (y - scaledHeight), scaledWidth, scaledHeight), style: FillStyle())
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while y < size.height {
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), y, scaledWidth, scaledHeight), style: FillStyle())
y += scaledHeight
}
}
}
context.fill(Path(rect), with: .color(tint))
}
case WallpaperType.empty: ()
}
}
)
}
}
extension PresetWallpaper {
public func toType(_ base: DefaultTheme, _ scale: Float? = nil) -> WallpaperType {
let scale = if let scale {
scale
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
type.scale
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
scale
} else {
Float(1.0)
}
return WallpaperType.preset(
filename,
scale
)
}
}
@@ -1,112 +0,0 @@
//
// ContextMenu2.swift
// SimpleX (iOS)
//
// Created by Evgeny on 09/08/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UIKit
import SwiftUI
extension View {
func uiKitContextMenu(hasImageOrVideo: Bool, maxWidth: CGFloat, itemWidth: Binding<CGFloat>, menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
Group {
if allowMenu.wrappedValue {
if hasImageOrVideo {
InteractionView(content:
self.environmentObject(ChatModel.shared)
.overlay(DetermineWidthImageVideoItem())
.onPreferenceChange(DetermineWidthImageVideoItem.Key.self) { itemWidth.wrappedValue = $0 == 0 ? maxWidth : $0 }
, maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
.frame(maxWidth: itemWidth.wrappedValue)
} else {
InteractionView(content: self.environmentObject(ChatModel.shared), maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
.fixedSize(horizontal: true, vertical: false)
}
} else {
self
}
}
}
}
private class HostingViewHolder: UIView {
var contentSize: CGSize = CGSizeMake(0, 0)
override var intrinsicContentSize: CGSize { get { contentSize } }
}
struct InteractionView<Content: View>: UIViewRepresentable {
let content: Content
var maxWidth: CGFloat
var itemWidth: Binding<CGFloat>
@Binding var menu: UIMenu
func makeUIView(context: Context) -> UIView {
let view = HostingViewHolder()
view.backgroundColor = .clear
let hostView = UIHostingController(rootView: content)
view.contentSize = hostView.view.intrinsicContentSize
hostView.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
]
view.addSubview(hostView.view)
view.addConstraints(constraints)
view.layer.cornerRadius = 18
hostView.view.layer.cornerRadius = 18
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(menuInteraction)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
let was = (uiView as! HostingViewHolder).contentSize
(uiView as! HostingViewHolder).contentSize = uiView.subviews[0].sizeThatFits(CGSizeMake(itemWidth.wrappedValue, .infinity))
if was != (uiView as! HostingViewHolder).contentSize {
uiView.invalidateIntrinsicContentSize()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
let parent: InteractionView<Content>
init(_ parent: InteractionView<Content>) {
self.parent = parent
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil,
actionProvider: { [weak self] _ in
guard let self = self else { return nil }
return self.parent.menu
}
)
}
// func contextMenuInteraction(
// _ interaction: UIContextMenuInteraction,
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
// animator: UIContextMenuInteractionCommitAnimating
// ) {
// animator.addCompletion {
// print("user tapped")
// }
// }
}
}
@@ -33,6 +33,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
var addMedia: (_ content: UploadContent) async -> Void
var selectionLimit: Int
var filter: PHPickerFilter = .any(of: [.images, .videos])
var finishedPreprocessing: () -> Void = {}
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
@@ -148,7 +149,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .any(of: [.images, .videos])
config.filter = filter
config.selectionLimit = selectionLimit
config.selection = .ordered
config.preferredAssetRepresentationMode = .current
@@ -12,10 +12,12 @@ import SimpleXChat
let defaultProfileImageCorner = 22.5
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
var iconName: String = "person.crop.circle.fill"
var size: CGFloat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var backgroundColor: Color? = nil
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
@@ -24,10 +26,12 @@ struct ProfileImage: View {
let uiImage = UIImage(data: data) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
Image(systemName: iconName)
.resizable()
.foregroundColor(color)
.foregroundColor(c)
.frame(width: size, height: size)
.background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear))
}
}
}
@@ -51,6 +55,29 @@ private let radiusFactor = (1 - squareToCircleRatio) / 50
}
}
extension Color {
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
return self
}
func asAnotherColorFromSecondaryVariant(_ theme: AppTheme) -> Color {
let s = theme.colors.secondaryVariant
let l = theme.colors.isLight
return switch self {
case Color(uiColor: .tertiaryLabel): // ChatView title
l ? s.darker(0.05) : s.lighter(0.2)
case Color(uiColor: .tertiarySystemFill): // SettingsView, ChatInfoView
l ? s.darker(0.065) : s.lighter(0.085)
case Color(uiColor: .quaternaryLabel): // ChatListView user picker
l ? s.darker(0.1) : s.lighter(0.1)
case Color(uiColor: .tertiarySystemGroupedBackground): // ChatListView items, forward view
s.asGroupedBackground(theme.base.mode)
default: self
}
}
}
struct ProfileImage_Previews: PreviewProvider {
static var previews: some View {
ProfileImage(imageStr: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC", size: 63)
@@ -0,0 +1,445 @@
//
// ThemeModeEditor.swift
// SimpleX (iOS)
//
// Created by Avently on 20.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
struct UserWallpaperEditor: View {
@EnvironmentObject var theme: AppTheme
var initialTheme: ThemeModeOverride
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@State private var showImageImporter: Bool = false
var body: some View {
List {
let wallpaperType = theme.wallpaper.type
WallpaperPresetSelector(
selectedWallpaper: wallpaperType,
currentColors: { type in
// If applying for :
// - all themes: no overrides needed
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
let perUserOverride: ThemeModeOverrides? = wallpaperType.sameType(type) ? ChatModel.shared.currentUser?.uiThemes : nil
return ThemeManager.currentColors(type, nil, perUserOverride, themeOverridesDefault.get())
},
onChooseType: onChooseType
)
.padding(.bottom, 10)
.listRowInsets(.init())
.listRowBackground(Color.clear)
.modifier(WallpaperImporter(showImageImporter: $showImageImporter, onChooseImage: { image in
if let filename = saveWallpaperFile(image: image) {
_ = onTypeCopyFromSameTheme(WallpaperType.image(filename, 1, WallpaperScaleType.fill))
}
}))
WallpaperSetupView(
wallpaperType: themeModeOverride.type,
base: theme.base,
initialWallpaper: theme.wallpaper,
editColor: { name in editColor(name, theme) },
onTypeChange: onTypeChange
)
Section {
if !globalThemeUsed {
ResetToGlobalThemeButton(true, theme.colors.primary) {
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
globalThemeUsed = true
Task {
await save(applyToMode, nil)
await MainActor.run {
// Change accent color globally
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
}
}
SetDefaultThemeButton(theme.colors.primary) {
globalThemeUsed = false
let lightBase = DefaultTheme.LIGHT
let darkBase = if theme.base != DefaultTheme.LIGHT { theme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
let mode = themeModeOverride.mode
Task {
// Saving for both modes in one place by changing mode once per save
if applyToMode == nil {
let oppositeMode = mode == DefaultThemeMode.light ? DefaultThemeMode.dark : DefaultThemeMode.light
await save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, oppositeMode == DefaultThemeMode.light ? lightBase : darkBase))
}
await MainActor.run {
themeModeOverride = ThemeModeOverride.withFilledAppDefaults(mode, mode == DefaultThemeMode.light ? lightBase : darkBase)
}
await save(themeModeOverride.mode, themeModeOverride)
await MainActor.run {
// Change accent color globally
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
}.onChange(of: initialTheme.mode) { mode in
themeModeOverride = initialTheme
if applyToMode != nil {
applyToMode = mode
}
}
.onChange(of: theme) { _ in
// Applies updated global theme if current one tracks global theme
if globalThemeUsed {
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
globalThemeUsed = true
}
}
}
if showMore {
let values = [
(nil, "All modes"),
(DefaultThemeMode.light, "Light mode"),
(DefaultThemeMode.dark, "Dark mode")
]
Picker("Apply to", selection: $applyToMode) {
ForEach(values, id: \.0) { (_, text) in
Text(text)
}
}
.frame(height: 36)
.onChange(of: applyToMode) { mode in
if let mode, mode != theme.base.mode {
let lightBase = DefaultTheme.LIGHT
let darkBase = if theme.base != DefaultTheme.LIGHT { theme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
ThemeManager.applyTheme(mode == DefaultThemeMode.light ? lightBase.themeName : darkBase.themeName)
}
}
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
_ = ThemeManager.copyFromSameThemeOverrides(type, nil, $themeModeOverride)
Task {
await save(applyToMode, themeModeOverride)
}
globalThemeUsed = false
return true
}
private func preApplyGlobalIfNeeded(_ type: WallpaperType?) {
if globalThemeUsed {
_ = onTypeCopyFromSameTheme(type)
}
}
private func onTypeChange(_ type: WallpaperType?) {
if globalThemeUsed {
preApplyGlobalIfNeeded(type)
// Saves copied static image instead of original from global theme
ThemeManager.applyWallpaper(themeModeOverride.type, $themeModeOverride)
} else {
ThemeManager.applyWallpaper(type, $themeModeOverride)
}
Task {
await save(applyToMode, themeModeOverride)
}
}
private func currentColors(_ type: WallpaperType?) -> ThemeManager.ActiveTheme {
// If applying for :
// - all themes: no overrides needed
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
let perUserOverride: ThemeModeOverrides? = theme.wallpaper.type.sameType(type) ? ChatModel.shared.currentUser?.uiThemes : nil
return ThemeManager.currentColors(type, nil, perUserOverride, themeOverridesDefault.get())
}
private func onChooseType(_ type: WallpaperType?) {
if let type, case WallpaperType.image = type {
if theme.wallpaper.type.isImage || currentColors(type).wallpaper.type.image == nil {
showImageImporter = true
} else {
_ = onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)
}
} else if themeModeOverride.type != type || theme.wallpaper.type != type {
_ = onTypeCopyFromSameTheme(type)
} else {
onTypeChange(type)
}
}
private func editColor(_ name: ThemeColor, _ currentTheme: AppTheme) -> Binding<Color> {
editColorBinding(
name: name,
wallpaperType: theme.wallpaper.type,
wallpaperImage: theme.wallpaper.type.image,
theme: currentTheme,
onColorChange: { color in
preApplyGlobalIfNeeded(themeModeOverride.type)
ThemeManager.applyThemeColor(name: name, color: color, pref: $themeModeOverride)
Task { await save(applyToMode, themeModeOverride) }
})
}
}
struct ChatWallpaperEditor: View {
@EnvironmentObject var theme: AppTheme
@State private var currentTheme: ThemeManager.ActiveTheme
var initialTheme: ThemeModeOverride
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@State private var showImageImporter: Bool = false
init(initialTheme: ThemeModeOverride, themeModeOverride: ThemeModeOverride, applyToMode: DefaultThemeMode? = nil, globalThemeUsed: Binding<Bool>, save: @escaping (DefaultThemeMode?, ThemeModeOverride?) async -> Void) {
let cur = ThemeManager.currentColors(nil, globalThemeUsed.wrappedValue ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
self.currentTheme = cur
self.initialTheme = initialTheme
self.themeModeOverride = themeModeOverride
self.applyToMode = applyToMode
self._globalThemeUsed = globalThemeUsed
self.save = save
}
var body: some View {
List {
WallpaperPresetSelector(
selectedWallpaper: currentTheme.wallpaper.type,
activeBackgroundColor: currentTheme.wallpaper.background,
activeTintColor: currentTheme.wallpaper.tint,
currentColors: currentColors,
onChooseType: onChooseType
)
.padding(.bottom, 10)
.listRowInsets(.init())
.listRowBackground(Color.clear)
.modifier(WallpaperImporter(showImageImporter: $showImageImporter, onChooseImage: { image in
if let filename = saveWallpaperFile(image: image) {
_ = onTypeCopyFromSameTheme(WallpaperType.image(filename, 1, WallpaperScaleType.fill))
}
}))
WallpaperSetupView(
wallpaperType: themeModeOverride.type,
base: currentTheme.base,
initialWallpaper: currentTheme.wallpaper,
editColor: editColor,
onTypeChange: onTypeChange
)
Section {
if !globalThemeUsed {
ResetToGlobalThemeButton(ChatModel.shared.currentUser?.uiThemes?.preferredMode(isInDarkTheme()) == nil, theme.colors.primary) {
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
globalThemeUsed = true
Task {
await save(applyToMode, nil)
}
}
}
SetDefaultThemeButton(theme.colors.primary) {
globalThemeUsed = false
let lightBase = DefaultTheme.LIGHT
let darkBase = if currentTheme.base != DefaultTheme.LIGHT { currentTheme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
let mode = themeModeOverride.mode
Task {
// Saving for both modes in one place by changing mode once per save
if applyToMode == nil {
let oppositeMode = mode == DefaultThemeMode.light ? DefaultThemeMode.dark : DefaultThemeMode.light
await save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, oppositeMode == DefaultThemeMode.light ? lightBase : darkBase))
}
await MainActor.run {
themeModeOverride = ThemeModeOverride.withFilledAppDefaults(mode, mode == DefaultThemeMode.light ? lightBase : darkBase)
}
await save(themeModeOverride.mode, themeModeOverride)
}
}
.onChange(of: initialTheme) { initial in
if initial.mode != themeModeOverride.mode {
themeModeOverride = initial
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
if applyToMode != nil {
applyToMode = initial.mode
}
}
}
.onChange(of: currentTheme) { _ in
// Applies updated global theme if current one tracks global theme
if globalThemeUsed {
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
globalThemeUsed = true
}
}
.onChange(of: themeModeOverride) { override in
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : override, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
}
}
if showMore {
let values = [
(nil, "All modes"),
(DefaultThemeMode.light, "Light mode"),
(DefaultThemeMode.dark, "Dark mode")
]
Picker("Apply to", selection: $applyToMode) {
ForEach(values, id: \.0) { (_, text) in
Text(text)
}
}
.frame(height: 36)
.onChange(of: applyToMode) { mode in
if let mode, mode != currentTheme.base.mode {
let lightBase = DefaultTheme.LIGHT
let darkBase = if currentTheme.base != DefaultTheme.LIGHT { currentTheme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
ThemeManager.applyTheme(mode == DefaultThemeMode.light ? lightBase.themeName : darkBase.themeName)
}
}
CustomizeThemeColorsSection(editColor: editColor)
ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
let success = ThemeManager.copyFromSameThemeOverrides(type, ChatModel.shared.currentUser?.uiThemes?.preferredMode(!currentTheme.colors.isLight), $themeModeOverride)
if success {
Task {
await save(applyToMode, themeModeOverride)
}
globalThemeUsed = false
}
return success
}
private func preApplyGlobalIfNeeded(_ type: WallpaperType?) {
if globalThemeUsed {
_ = onTypeCopyFromSameTheme(type)
}
}
private func onTypeChange(_ type: WallpaperType?) {
if globalThemeUsed {
preApplyGlobalIfNeeded(type)
// Saves copied static image instead of original from global theme
ThemeManager.applyWallpaper(themeModeOverride.type, $themeModeOverride)
} else {
ThemeManager.applyWallpaper(type, $themeModeOverride)
}
Task {
await save(applyToMode, themeModeOverride)
}
}
private func currentColors(_ type: WallpaperType?) -> ThemeManager.ActiveTheme {
// If applying for :
// - all themes: no overrides needed
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
let perChatOverride: ThemeModeOverride? = type?.sameType(themeModeOverride.type) == true ? themeModeOverride : nil
return ThemeManager.currentColors(type, perChatOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
}
private func onChooseType(_ type: WallpaperType?) {
if let type, case WallpaperType.image = type {
if (themeModeOverride.type?.isImage == true && !globalThemeUsed) || currentColors(type).wallpaper.type.image == nil {
showImageImporter = true
} else if !onTypeCopyFromSameTheme(currentColors(type).wallpaper.type) {
showImageImporter = true
}
} else if globalThemeUsed || themeModeOverride.type != type || themeModeOverride.type != type {
_ = onTypeCopyFromSameTheme(type)
} else {
onTypeChange(type)
}
}
private func editColor(_ name: ThemeColor) -> Binding<Color> {
editColorBinding(
name: name,
wallpaperType: themeModeOverride.type,
wallpaperImage: themeModeOverride.type?.image,
theme: currentTheme.toAppTheme(),
onColorChange: { color in
preApplyGlobalIfNeeded(themeModeOverride.type)
ThemeManager.applyThemeColor(name: name, color: color, pref: $themeModeOverride)
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
Task { await save(applyToMode, themeModeOverride) }
})
}
}
private func ResetToGlobalThemeButton(_ app: Bool, _ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
Button {
onClick()
} label: {
Text(app ? "Reset to app theme" : "Reset to user theme")
.foregroundColor(primaryColor)
}
}
private func SetDefaultThemeButton(_ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
Button {
onClick()
} label: {
Text("Set default theme")
.foregroundColor(primaryColor)
}
}
private func AdvancedSettingsButton(_ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
Button {
onClick()
} label: {
HStack {
Image(systemName: "chevron.down")
Text("Advanced settings")
}.foregroundColor(primaryColor)
}
}
@@ -0,0 +1,19 @@
//
// ViewModifiers.swift
// SimpleX (iOS)
//
// Created by Avently on 12.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
if condition() {
transform(self)
} else {
self
}
}
}
@@ -10,6 +10,7 @@ import SwiftUI
struct PasscodeEntry: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var width: CGFloat
var height: CGFloat
@Binding var password: String
@@ -140,11 +141,11 @@ struct PasscodeEntry: View {
ZStack {
Circle()
.frame(width: h, height: h)
.foregroundColor(Color(uiColor: .systemBackground))
.foregroundColor(AppTheme.shared.colors.background)
label()
}
}
.foregroundColor(.secondary)
.foregroundColor(theme.colors.secondary)
.frame(width: size, height: h)
}
}

Some files were not shown because too many files have changed in this diff Show More