Тулбар ChatDetail по Figma: capsule back-кнопка, аватар 44×44, padding и размеры
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -280,7 +280,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.6;
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -302,7 +302,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -318,7 +318,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.6;
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "FDCB18AB-B5D7-42BF-97B9-38257BAC9228"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.SymbolicBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "E8F68F98-CDDA-4214-B970-261FCB84A214"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
symbolName = ""
|
||||
moduleName = "">
|
||||
<Locations>
|
||||
</Locations>
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
16
Rosetta/Assets.xcassets/toolbar-add-chat.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/toolbar-add-chat.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "toolbar-add-chat.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
3
Rosetta/Assets.xcassets/toolbar-add-chat.imageset/toolbar-add-chat.svg
vendored
Normal file
3
Rosetta/Assets.xcassets/toolbar-add-chat.imageset/toolbar-add-chat.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
16
Rosetta/Assets.xcassets/toolbar-compose.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/toolbar-compose.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "toolbar-compose.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
3
Rosetta/Assets.xcassets/toolbar-compose.imageset/toolbar-compose.svg
vendored
Normal file
3
Rosetta/Assets.xcassets/toolbar-compose.imageset/toolbar-compose.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.61816 12.2793C8.52051 12.377 8.30892 12.5072 7.9834 12.6699C7.65788 12.8327 7.22656 13.028 6.68945 13.2559C6.67318 13.2559 6.6569 13.2559 6.64062 13.2559C6.62435 13.2721 6.60807 13.2803 6.5918 13.2803C6.57552 13.2803 6.55924 13.2721 6.54297 13.2559C6.52669 13.2559 6.51042 13.2559 6.49414 13.2559C6.46159 13.2396 6.42904 13.207 6.39648 13.1582C6.36393 13.1257 6.34766 13.085 6.34766 13.0361C6.34766 13.0199 6.35579 13.0036 6.37207 12.9873C6.37207 12.971 6.37207 12.9548 6.37207 12.9385C6.59993 12.4014 6.79525 11.9701 6.95801 11.6445C7.12077 11.319 7.25098 11.1074 7.34863 11.0098L16.7725 1.58594C16.7887 1.56966 16.8132 1.55339 16.8457 1.53711C16.8783 1.53711 16.9027 1.53711 16.9189 1.53711C16.9352 1.53711 16.9596 1.53711 16.9922 1.53711C17.0247 1.55339 17.0492 1.56966 17.0654 1.58594L18.0176 2.53809C18.0339 2.55436 18.0501 2.57878 18.0664 2.61133C18.0664 2.64388 18.0664 2.67643 18.0664 2.70898C18.0664 2.72526 18.0664 2.74967 18.0664 2.78223C18.0501 2.81478 18.0339 2.83919 18.0176 2.85547L8.61816 12.2793ZM18.5791 1.97656L17.627 1.04883C17.6107 1.01628 17.5944 0.983724 17.5781 0.951172C17.5618 0.91862 17.5537 0.894206 17.5537 0.87793C17.5537 0.861654 17.5618 0.83724 17.5781 0.804688C17.5944 0.772135 17.6107 0.747721 17.627 0.731445L18.0908 0.243164C18.1885 0.161784 18.2943 0.0966797 18.4082 0.0478516C18.5059 0.0152995 18.6117 -0.000976562 18.7256 -0.000976562C18.8558 -0.000976562 18.9697 0.0152995 19.0674 0.0478516C19.1813 0.0966797 19.2871 0.161784 19.3848 0.243164C19.4661 0.34082 19.5312 0.446615 19.5801 0.560547C19.6126 0.658203 19.6289 0.772135 19.6289 0.902344C19.6289 1.01628 19.6126 1.12207 19.5801 1.21973C19.5312 1.33366 19.4661 1.43945 19.3848 1.53711L18.8965 2.00098C18.8802 2.01725 18.8558 2.03353 18.8232 2.0498C18.7907 2.0498 18.7581 2.0498 18.7256 2.0498C18.7093 2.0498 18.6849 2.0498 18.6523 2.0498C18.6198 2.03353 18.5954 2.00911 18.5791 1.97656ZM16.748 6.88379C16.748 6.65592 16.8213 6.46875 16.9678 6.32227C17.1305 6.15951 17.3177 6.07812 17.5293 6.07812C17.7572 6.07812 17.9443 6.15951 18.0908 6.32227C18.2536 6.46875 18.335 6.65592 18.335 6.88379V16.4541C18.335 17.0238 18.1885 17.5609 17.8955 18.0654C17.6188 18.5374 17.2363 18.9118 16.748 19.1885C16.2598 19.4814 15.7227 19.6279 15.1367 19.6279H3.17383C2.60417 19.6279 2.06706 19.4814 1.5625 19.1885C1.09049 18.9118 0.716146 18.5374 0.439453 18.0654C0.146484 17.5609 0 17.0238 0 16.4541V4.49121C0 3.90527 0.146484 3.36816 0.439453 2.87988C0.716146 2.3916 1.09049 2.00911 1.5625 1.73242C2.06706 1.43945 2.60417 1.29297 3.17383 1.29297H12.7441C12.972 1.29297 13.1592 1.37435 13.3057 1.53711C13.4684 1.68359 13.5498 1.87077 13.5498 2.09863C13.5498 2.31022 13.4684 2.4974 13.3057 2.66016C13.1592 2.80664 12.972 2.87988 12.7441 2.87988H3.17383C2.88086 2.87988 2.6123 2.95312 2.36816 3.09961C2.12402 3.24609 1.93685 3.44141 1.80664 3.68555C1.66016 3.92969 1.58691 4.19824 1.58691 4.49121V16.4541C1.58691 16.7471 1.66016 17.0156 1.80664 17.2598C1.93685 17.5039 2.12402 17.6911 2.36816 17.8213C2.6123 17.9678 2.88086 18.041 3.17383 18.041H15.1367C15.4297 18.041 15.6982 17.9678 15.9424 17.8213C16.1865 17.6911 16.3818 17.5039 16.5283 17.2598C16.6748 17.0156 16.748 16.7471 16.748 16.4541V6.88379Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
16
Rosetta/Assets.xcassets/toolbar-xmark.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/toolbar-xmark.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "toolbar-xmark.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
3
Rosetta/Assets.xcassets/toolbar-xmark.imageset/toolbar-xmark.svg
vendored
Normal file
3
Rosetta/Assets.xcassets/toolbar-xmark.imageset/toolbar-xmark.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.7734 0.317383C17.8711 0.203451 17.9932 0.12207 18.1396 0.0732422C18.2699 0.0244141 18.4082 0 18.5547 0C18.6849 0 18.8151 0.0244141 18.9453 0.0732422C19.0918 0.12207 19.2139 0.203451 19.3115 0.317383C19.4255 0.415039 19.5068 0.537109 19.5557 0.683594C19.6045 0.813802 19.6289 0.94401 19.6289 1.07422C19.6289 1.2207 19.6045 1.35905 19.5557 1.48926C19.5068 1.63574 19.4255 1.75781 19.3115 1.85547L11.3525 9.81445L19.3115 17.7734C19.4255 17.8711 19.5068 17.9932 19.5557 18.1396C19.6045 18.2699 19.6289 18.4082 19.6289 18.5547C19.6289 18.6849 19.6045 18.8151 19.5557 18.9453C19.5068 19.0918 19.4255 19.2139 19.3115 19.3115C19.2139 19.4255 19.0918 19.5068 18.9453 19.5557C18.8151 19.6045 18.6849 19.6289 18.5547 19.6289C18.4082 19.6289 18.2699 19.6045 18.1396 19.5557C17.9932 19.5068 17.8711 19.4255 17.7734 19.3115L9.81445 11.3525L1.85547 19.3115C1.75781 19.4255 1.63574 19.5068 1.48926 19.5557C1.35905 19.6045 1.2207 19.6289 1.07422 19.6289C0.94401 19.6289 0.813802 19.6045 0.683594 19.5557C0.537109 19.5068 0.415039 19.4255 0.317383 19.3115C0.203451 19.2139 0.12207 19.0918 0.0732422 18.9453C0.0244141 18.8151 0 18.6849 0 18.5547C0 18.4082 0.0244141 18.2699 0.0732422 18.1396C0.12207 17.9932 0.203451 17.8711 0.317383 17.7734L8.27637 9.81445L0.317383 1.85547C0.203451 1.75781 0.12207 1.63574 0.0732422 1.48926C0.0244141 1.35905 0 1.2207 0 1.07422C0 0.94401 0.0244141 0.813802 0.0732422 0.683594C0.12207 0.537109 0.203451 0.415039 0.317383 0.317383C0.415039 0.203451 0.537109 0.12207 0.683594 0.0732422C0.813802 0.0244141 0.94401 0 1.07422 0C1.2207 0 1.35905 0.0244141 1.48926 0.0732422C1.63574 0.12207 1.75781 0.203451 1.85547 0.317383L9.81445 8.27637L17.7734 0.317383Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -52,4 +52,21 @@ extension View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glass circle — convenience for circular buttons.
|
||||
@ViewBuilder
|
||||
func glassCircle() -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
background {
|
||||
Circle().fill(.clear)
|
||||
.glassEffect(.regular, in: .circle)
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
Circle().fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,23 +138,22 @@ private extension ChatDetailView {
|
||||
@ToolbarContentBuilder
|
||||
var chatDetailToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { dismiss() } label: { backCircleButtonLabel }
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
Button { dismiss() } label: { backCapsuleButtonLabel }
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button { dismiss() } label: {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Text(titleText)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if !route.isSavedMessages && effectiveVerified > 0 {
|
||||
VerifiedBadge(verified: effectiveVerified, size: 12)
|
||||
VerifiedBadge(verified: effectiveVerified, size: 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,39 +166,43 @@ private extension ChatDetailView {
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
AvatarView(
|
||||
initials: avatarInitials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 35,
|
||||
size: 38,
|
||||
isOnline: false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(width: 44, height: 44)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var backCircleButtonLabel: some View {
|
||||
ZStack {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.backChevron,
|
||||
viewBox: CGSize(width: 11, height: 20),
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
private var backCapsuleButtonLabel: some View {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.backChevron,
|
||||
viewBox: CGSize(width: 11, height: 20),
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 4)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
.frame(width: 36, height: 36) // iOS hit-area
|
||||
}
|
||||
|
||||
// MARK: - Existing helpers / UI
|
||||
|
||||
@@ -96,11 +96,14 @@ private extension ChatListSearchContent {
|
||||
private extension ChatListSearchContent {
|
||||
@ViewBuilder
|
||||
var recentSearchesSection: some View {
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
searchPlaceholder
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Horizontal scrollable favorite contacts row
|
||||
FavoriteContactsRowSearch(onOpenDialog: onOpenDialog)
|
||||
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
searchPlaceholderInline
|
||||
} else {
|
||||
HStack {
|
||||
Text("RECENT")
|
||||
.font(.system(size: 13))
|
||||
@@ -120,30 +123,33 @@ private extension ChatListSearchContent {
|
||||
recentRow(recent)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: 120)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
var searchPlaceholder: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
var searchPlaceholderInline: some View {
|
||||
VStack(spacing: 16) {
|
||||
LottieView(
|
||||
animationName: "search",
|
||||
animationSpeed: 1.0
|
||||
)
|
||||
.frame(width: 120, height: 120)
|
||||
.padding(.top, 60)
|
||||
|
||||
Text("Search for users")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Text("Find people by username or public key")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
Text("Find people by username or public key")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
func recentRow(_ user: RecentSearch) -> some View {
|
||||
@@ -157,22 +163,10 @@ private extension ChatListSearchContent {
|
||||
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 42, isSavedMessages: isSelf
|
||||
)
|
||||
Button {
|
||||
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Circle().fill(RosettaColors.figmaBlue))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 42, isSavedMessages: isSelf
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(isSelf ? "Saved Messages" : (
|
||||
@@ -254,3 +248,48 @@ private extension ChatListSearchContent {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorite Contacts Row (observation-isolated)
|
||||
|
||||
/// Horizontal scrollable avatar row for active search state.
|
||||
/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
|
||||
/// does NOT propagate to `ChatListSearchContent`'s parent.
|
||||
private struct FavoriteContactsRowSearch: View {
|
||||
var onOpenDialog: (ChatRoute) -> Void
|
||||
|
||||
var body: some View {
|
||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||
if !dialogs.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(dialogs), id: \.id) { dialog in
|
||||
Button {
|
||||
onOpenDialog(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
)
|
||||
|
||||
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.06)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
.frame(width: 78)
|
||||
}
|
||||
.frame(width: 78)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,19 @@ struct ChatListView: View {
|
||||
@StateObject private var viewModel = ChatListViewModel()
|
||||
@StateObject private var navigationState = ChatListNavigationState()
|
||||
@State private var searchText = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
|
||||
NavigationStack(path: $navigationState.path) {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
// Custom search bar
|
||||
customSearchBar
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if isSearchActive {
|
||||
ChatListSearchContent(
|
||||
@@ -45,25 +49,26 @@ struct ChatListView: View {
|
||||
viewModel: viewModel,
|
||||
onSelectRecent: { searchText = $0 },
|
||||
onOpenDialog: { route in
|
||||
isSearchActive = false
|
||||
searchText = ""
|
||||
navigationState.path.append(route)
|
||||
// Delay search dismissal so NavigationStack processes
|
||||
// the push before the search overlay is removed.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
isSearchActive = false
|
||||
isSearchFocused = false
|
||||
searchText = ""
|
||||
viewModel.setSearchQuery("")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
normalContent
|
||||
}
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.applyGlassNavBar()
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
isPresented: $isSearchActive,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search"
|
||||
)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
viewModel.setSearchQuery(newValue)
|
||||
}
|
||||
@@ -84,6 +89,124 @@ struct ChatListView: View {
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
// MARK: - Cancel Search
|
||||
|
||||
private func cancelSearch() {
|
||||
isSearchActive = false
|
||||
isSearchFocused = false
|
||||
searchText = ""
|
||||
viewModel.setSearchQuery("")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Search Bar
|
||||
|
||||
private extension ChatListView {
|
||||
var customSearchBar: some View {
|
||||
HStack(spacing: 10) {
|
||||
// Search bar capsule
|
||||
ZStack {
|
||||
// Centered placeholder: magnifier + "Search"
|
||||
if searchText.isEmpty && !isSearchActive {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Color.gray)
|
||||
Text("Search")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// Active: left-aligned magnifier + TextField
|
||||
if isSearchActive {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Color.gray)
|
||||
|
||||
TextField("Search", text: $searchText)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isSearchFocused)
|
||||
.submitLabel(.search)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.frame(height: 42)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if !isSearchActive {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isSearchActive = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
isSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.background {
|
||||
if isSearchActive {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: isSearchFocused) { _, focused in
|
||||
if focused && !isSearchActive {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isSearchActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circular X button (visible only when search is active)
|
||||
if isSearchActive {
|
||||
Button {
|
||||
cancelSearch()
|
||||
} label: {
|
||||
Image("toolbar-xmark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 19, height: 19)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.5)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Normal Content
|
||||
@@ -111,40 +234,56 @@ private extension ChatListView {
|
||||
private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
if !isSearchActive {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCapsule()
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 0) {
|
||||
Button { } label: {
|
||||
Image("toolbar-add-chat")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
|
||||
Button { } label: {
|
||||
Image("toolbar-compose")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.glassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,13 +364,13 @@ private struct ChatListDialogContent: View {
|
||||
}
|
||||
} else {
|
||||
if !viewModel.pinnedDialogs.isEmpty {
|
||||
ForEach(viewModel.pinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
chatRow(dialog, isFirst: index == 0)
|
||||
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.unpinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
chatRow(dialog, isFirst: index == 0 && viewModel.pinnedDialogs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +384,7 @@ private struct ChatListDialogContent: View {
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
private func chatRow(_ dialog: Dialog) -> some View {
|
||||
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
||||
Button {
|
||||
navigationState.path.append(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
@@ -253,7 +392,8 @@ private struct ChatListDialogContent: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
|
||||
.listRowSeparator(.visible, edges: .bottom)
|
||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
@@ -368,4 +508,12 @@ private struct DeviceApprovalBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }
|
||||
#Preview("Chat List") {
|
||||
ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false))
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Search Active") {
|
||||
ChatListView(isSearchActive: .constant(true), isDetailPresented: .constant(false))
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
@@ -255,25 +255,12 @@ private struct RecentSection: View {
|
||||
navigationPath.append(ChatRoute(recent: user))
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: colorIdx,
|
||||
size: 42,
|
||||
isSavedMessages: isSelf
|
||||
)
|
||||
|
||||
Button {
|
||||
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Circle().fill(RosettaColors.figmaBlue))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: colorIdx,
|
||||
size: 42,
|
||||
isSavedMessages: isSelf
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
|
||||
|
||||
92
docs/blur-materials.md
Normal file
92
docs/blur-materials.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# iOS Blur Materials Reference (from Figma)
|
||||
|
||||
All materials use `backdrop-blur: 50px` (equivalent to `UIBlurEffect` system radius).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode Materials
|
||||
|
||||
### Ultrathin — Dark
|
||||
Most transparent. Use for subtle overlays, nav bar pills.
|
||||
|
||||
| Layer | Property |
|
||||
|-------|----------|
|
||||
| Base fill | `rgba(255, 255, 255, 0.07)` |
|
||||
| Blur layer | `backdrop-blur: 50px`, `rgba(255, 255, 255, 0.03)`, blend: `color-dodge` |
|
||||
|
||||
SwiftUI equivalent: `.ultraThinMaterial` (note: adds slight grey chromatic tint on pure black)
|
||||
|
||||
### Thin — Dark
|
||||
Slight blur. Use for search bars, secondary panels.
|
||||
|
||||
| Layer | Property |
|
||||
|-------|----------|
|
||||
| Base fill | `rgba(255, 255, 255, 0.05)` |
|
||||
| Blur layer | `backdrop-blur: 50px`, `rgba(255, 255, 255, 0.40)`, blend: `color-dodge` |
|
||||
|
||||
SwiftUI equivalent: `.thinMaterial`
|
||||
|
||||
### Regular (Default) — Dark
|
||||
Medium blur. Use for tab bars, cards, input bars.
|
||||
|
||||
| Layer | Property |
|
||||
|-------|----------|
|
||||
| Base fill | `rgba(255, 255, 255, 0.25)`, blend: `plus-lighter` |
|
||||
| Blur layer | `backdrop-blur: 50px`, `rgba(255, 255, 255, 0.60)`, blend: `color-dodge` |
|
||||
|
||||
SwiftUI equivalent: `.regularMaterial`
|
||||
|
||||
### Thick — Dark
|
||||
Heavy, opaque blur. Use for modal overlays, sheets.
|
||||
|
||||
| Layer | Property |
|
||||
|-------|----------|
|
||||
| Fill | `rgba(0, 0, 0, 0.60)` |
|
||||
| Blur | `backdrop-blur: 50px` |
|
||||
|
||||
SwiftUI equivalent: `.thickMaterial`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode Vibrant Colors (on material backgrounds)
|
||||
|
||||
### Labels (text over material)
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Vibrant Primary | `#FFFFFF` | Main text |
|
||||
| Vibrant Secondary | `#999999` | Subtitle, caption |
|
||||
| Vibrant Tertiary | `#404040` | Hint, placeholder |
|
||||
| Vibrant Quaternary | `#262626` | Disabled text |
|
||||
|
||||
### Fills (elements over material)
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Vibrant Primary | `#333333` | Main fill |
|
||||
| Vibrant Secondary | `#121212` | Secondary fill |
|
||||
| Vibrant Tertiary | `#121212` | Tertiary fill |
|
||||
|
||||
### Separator
|
||||
| Token | Hex |
|
||||
|-------|-----|
|
||||
| Separator | `#1A1A1A` |
|
||||
|
||||
---
|
||||
|
||||
## Dark Theme — Practical Gotchas
|
||||
|
||||
1. **`.thinMaterial` / `.ultraThinMaterial` look grey on dark backgrounds** — they add a light chromatic tint. For elements on pure black, use `Color.white.opacity(0.08)` or the Figma "Ultrathin" recipe: `rgba(255,255,255,0.07)` base fill.
|
||||
|
||||
2. **Telegram-style dark fades** are `LinearGradient` with `Color.black` opacity stops, NOT material blurs.
|
||||
|
||||
3. **`UIVisualEffectView` cannot blur SwiftUI content** — different rendering layers.
|
||||
|
||||
---
|
||||
|
||||
## iOS Version Mapping
|
||||
|
||||
| Figma Material | iOS < 26 (SwiftUI) | iOS 26+ |
|
||||
|----------------|---------------------|---------|
|
||||
| Ultrathin | `.ultraThinMaterial` or `Color.white.opacity(0.07)` | `.glassEffect(.ultraThin)` |
|
||||
| Thin | `.thinMaterial` or `Color.white.opacity(0.05)` | `.glassEffect(.thin)` |
|
||||
| Regular | `.regularMaterial` | `.glassEffect(.regular)` |
|
||||
| Thick | `.thickMaterial` or `Color.black.opacity(0.6)` | `.glassEffect(.thick)` |
|
||||
@@ -65,16 +65,16 @@ platform :ios do
|
||||
xcargs: "SWIFT_OPTIMIZATION_LEVEL='-Onone'"
|
||||
)
|
||||
|
||||
# Инкремент только после успешной сборки
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
|
||||
# Инкремент только после успешной сборки И загрузки
|
||||
new_version = bump_patch(current_marketing_version)
|
||||
set_marketing_version(new_version)
|
||||
|
||||
new_build = current_build_number + 1
|
||||
set_build_number(new_build)
|
||||
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
|
||||
# ─── Release в App Store ───
|
||||
@@ -89,16 +89,16 @@ platform :ios do
|
||||
xcargs: "SWIFT_OPTIMIZATION_LEVEL='-Onone'"
|
||||
)
|
||||
|
||||
upload_to_app_store(
|
||||
force: true,
|
||||
skip_screenshots: true
|
||||
)
|
||||
|
||||
new_version = bump_patch(current_marketing_version)
|
||||
set_marketing_version(new_version)
|
||||
|
||||
new_build = current_build_number + 1
|
||||
set_build_number(new_build)
|
||||
|
||||
upload_to_app_store(
|
||||
force: true,
|
||||
skip_screenshots: true
|
||||
)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user