diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index d6b61f1..e261b0e 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -251,6 +251,8 @@ struct PrivateChatView: View { private func messageBubble(for message: MessageItem, isCurrentUser: Bool) -> some View { let timeText = timestamp(for: message) + let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground) + return VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 4) { Text(contentText(for: message)) .font(.body) @@ -265,8 +267,10 @@ struct PrivateChatView: View { } .padding(.vertical, 10) .padding(.horizontal, 12) - .background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .background( + MessageBubbleShape() + .fill(bubbleColor) + ) .frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading) .fixedSize(horizontal: false, vertical: true) } @@ -765,6 +769,109 @@ private var headerPlaceholderAvatar: some View { } +/// Decorative bubble with two horns on top and two legs on bottom to mimic cartoon-style speech clouds. +private struct MessageBubbleShape: Shape { + var cornerRadius: CGFloat = 18 + var hornHeight: CGFloat = 10 + var hornWidth: CGFloat = 16 + var hornSpacing: CGFloat = 12 + var legHeight: CGFloat = 6 + var legWidth: CGFloat = 18 + var legSpacing: CGFloat = 14 + + func path(in rect: CGRect) -> Path { + var path = Path() + guard rect.width > 0, rect.height > 0 else { return path } + + let radius = min(cornerRadius, min(rect.width, rect.height) / 2) + let innerTop = rect.minY + hornHeight + let innerBottom = rect.maxY - legHeight + let left = rect.minX + let right = rect.maxX + + let horizontalSpan = max(0, rect.width - 2 * radius) + + let hornsEnabled = horizontalSpan > (2 * hornWidth + 2 * hornSpacing) + let legsEnabled = horizontalSpan > (2 * legWidth + 2 * legSpacing) + + let effectiveHornWidth = hornsEnabled ? min(hornWidth, horizontalSpan / 4) : 0 + let effectiveLegWidth = legsEnabled ? min(legWidth, horizontalSpan / 4) : 0 + + let effectiveHornSpacing = hornsEnabled ? min(hornSpacing, (horizontalSpan - effectiveHornWidth * 2) / 2) : 0 + let effectiveLegSpacing = legsEnabled ? min(legSpacing, (horizontalSpan - effectiveLegWidth * 2) / 2) : 0 + + let leftHornStart = left + radius + effectiveHornSpacing + let rightHornStart = right - radius - effectiveHornSpacing - effectiveHornWidth + + let leftLegStart = left + radius + effectiveLegSpacing + let rightLegStart = right - radius - effectiveLegSpacing - effectiveLegWidth + + path.move(to: CGPoint(x: left + radius, y: innerTop)) + path.addQuadCurve(to: CGPoint(x: left, y: innerTop + radius), control: CGPoint(x: left, y: innerTop)) + path.addLine(to: CGPoint(x: left, y: innerBottom - radius)) + path.addQuadCurve(to: CGPoint(x: left + radius, y: innerBottom), control: CGPoint(x: left, y: innerBottom)) + + if legsEnabled { + path.addLine(to: CGPoint(x: leftLegStart, y: innerBottom)) + path.addQuadCurve( + to: CGPoint(x: leftLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight), + control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight) + ) + path.addQuadCurve( + to: CGPoint(x: leftLegStart + effectiveLegWidth, y: innerBottom), + control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight) + ) + + path.addLine(to: CGPoint(x: rightLegStart, y: innerBottom)) + path.addQuadCurve( + to: CGPoint(x: rightLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight), + control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight) + ) + path.addQuadCurve( + to: CGPoint(x: rightLegStart + effectiveLegWidth, y: innerBottom), + control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight) + ) + } + + path.addLine(to: CGPoint(x: right - radius, y: innerBottom)) + path.addQuadCurve(to: CGPoint(x: right, y: innerBottom - radius), control: CGPoint(x: right, y: innerBottom)) + path.addLine(to: CGPoint(x: right, y: innerTop + radius)) + path.addQuadCurve(to: CGPoint(x: right - radius, y: innerTop), control: CGPoint(x: right, y: innerTop)) + + if hornsEnabled { + let hornOutset = effectiveHornWidth * 0.45 + let hornTipY = rect.minY - hornHeight * 0.35 + + // Right horn leans outward to the right + path.addLine(to: CGPoint(x: rightHornStart + effectiveHornWidth, y: innerTop)) + path.addQuadCurve( + to: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset, y: hornTipY), + control: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset * 0.65, y: innerTop - hornHeight * 0.35) + ) + path.addQuadCurve( + to: CGPoint(x: rightHornStart, y: innerTop), + control: CGPoint(x: rightHornStart + hornOutset * 0.25, y: innerTop - hornHeight) + ) + + // Move across the top between horns and draw the left horn leaning outward + path.addLine(to: CGPoint(x: leftHornStart + effectiveHornWidth, y: innerTop)) + path.addQuadCurve( + to: CGPoint(x: leftHornStart - hornOutset, y: hornTipY), + control: CGPoint(x: leftHornStart + effectiveHornWidth - hornOutset * 0.65, y: innerTop - hornHeight * 0.35) + ) + path.addQuadCurve( + to: CGPoint(x: leftHornStart, y: innerTop), + control: CGPoint(x: leftHornStart - hornOutset * 0.25, y: innerTop - hornHeight) + ) + } + + path.addLine(to: CGPoint(x: left + radius, y: innerTop)) + path.closeSubpath() + return path + } +} + + #if canImport(UIKit) private struct LegacyMultilineTextView: UIViewRepresentable { @Binding var text: String