Files
TREK/client/ios/App/TREKWidgets/CollabContent.swift
T

208 lines
10 KiB
Swift

import SwiftUI
/// The travel group: who comes along, the latest messages in the trip chat
/// as bubbles, and the open poll with its votes as bars.
struct CollabContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
@Environment(\.colorScheme) private var scheme
var body: some View {
let collab = snapshot?.collab
let labels = snapshot?.labels
if let collab, let labels {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
Text(labels.group ?? "").trekEyebrow(scheme)
AvatarStack(members: collab.members, size: 30, scheme: scheme).padding(.top, 8)
Spacer(minLength: 0)
if let poll = collab.poll {
Label(labels.poll ?? "", systemImage: "chart.bar.fill")
.font(.system(size: 10.5, weight: .heavy))
.foregroundStyle(Trek.info)
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
} else if let last = collab.messages.last {
Text(last.author).font(.system(size: 10.5, weight: .heavy)).foregroundStyle(Trek.muted(scheme))
Text(last.text).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(3)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
}
case .medium:
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(snapshot?.trip?.title ?? labels.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 22, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(2)), labels)
Spacer(minLength: 0)
}
case .large:
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
Text(labels.group ?? "").trekEyebrow(scheme)
if let trip = snapshot?.trip {
Text(trip.title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
}
}
Spacer(minLength: 4)
AvatarStack(members: collab.members, size: 28, scheme: scheme)
}
bubbles(Array(collab.messages.suffix(3)), labels)
Spacer(minLength: 0)
if let poll = collab.poll { PollCard(poll: poll, labels: labels, scheme: scheme) }
}
default:
accessory(collab, labels)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.group ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
Image(systemName: "person.3.fill").font(.system(size: 28)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
Text(labels?.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func bubbles(_ messages: [WidgetSnapshot.Collab.Message], _ labels: WidgetSnapshot.Labels) -> some View {
if messages.isEmpty {
Text(labels.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
} else {
VStack(spacing: 6) {
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
ChatBubble(message: message, scheme: scheme)
}
}
}
}
@ViewBuilder
private func accessory(_ collab: WidgetSnapshot.Collab, _ labels: WidgetSnapshot.Labels) -> some View {
if let last = collab.messages.last {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "bubble.left.and.bubble.right.fill").font(.system(size: 11, weight: .bold))
Text(last.author).font(.system(size: 12, weight: .bold)).lineLimit(1)
}
Text(last.text).font(.system(size: 13, weight: .semibold)).lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(labels.noMessages ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
}
}
}
extension Trek {
/// A colour of a person's own, from their name.
static func personColor(_ name: String) -> Color {
let palette: [UInt32] = [0x4A7DDB, 0x2FA37A, 0xE8A13A, 0x8E6CD9, 0xD6408B, 0x2BA6A6, 0xD6673B, 0x5A6CDE]
return Color(hex: palette[Int(seed(name) % UInt64(palette.count))])
}
}
/// Overlapping initials in each person's colour, with a count for the rest.
struct AvatarStack: View {
let members: [WidgetSnapshot.Collab.Member]
let size: CGFloat
let scheme: ColorScheme
var body: some View {
HStack(spacing: -size * 0.18) {
ForEach(Array(members.prefix(4).enumerated()), id: \.offset) { _, member in
Circle()
.fill(LinearGradient(colors: [Trek.personColor(member.name), Trek.personColor(member.name).opacity(0.75)], startPoint: .top, endPoint: .bottom))
.frame(width: size, height: size)
.overlay(Text(member.initials).font(.system(size: size * 0.34, weight: .heavy)).minimumScaleFactor(0.7).lineLimit(1).foregroundStyle(.white).padding(.horizontal, size * 0.1))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
if members.count > 4 {
Circle()
.fill(Trek.card(scheme))
.frame(width: size, height: size)
.overlay(Text("+\(members.count - 4)").font(.system(size: size * 0.34, weight: .heavy)).foregroundStyle(Trek.muted(scheme)))
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
}
}
}
}
/// A chat message: the others on the left with their initials, mine on the right in ink.
struct ChatBubble: View {
let message: WidgetSnapshot.Collab.Message
let scheme: ColorScheme
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if message.mine { Spacer(minLength: 36) }
if !message.mine {
Circle()
.fill(Trek.personColor(message.author))
.frame(width: 20, height: 20)
.overlay(Text(message.initials).font(.system(size: 8, weight: .heavy)).foregroundStyle(.white))
}
Text(message.text)
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(message.mine ? (scheme == .dark ? Color(hex: 0x101013) : .white) : Trek.ink(scheme))
.lineLimit(2)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(message.mine ? Trek.ink(scheme) : Trek.card(scheme))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(message.mine ? Color.clear : Trek.cardBorder(scheme), lineWidth: 0.7)
)
if !message.mine { Spacer(minLength: 36) }
}
}
}
/// The open poll: the question and each option as a bar of its votes.
struct PollCard: View {
let poll: WidgetSnapshot.Collab.Poll
let labels: WidgetSnapshot.Labels
let scheme: ColorScheme
var body: some View {
let most = max(poll.options.map(\.votes).max() ?? 1, 1)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 5) {
Image(systemName: "chart.bar.fill").font(.system(size: 10, weight: .bold)).foregroundStyle(Trek.info)
Text(labels.poll ?? "").font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(Trek.info)
Spacer(minLength: 0)
Text("\(poll.voters) \(labels.votes ?? "")").font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
ForEach(Array(poll.options.prefix(3).enumerated()), id: \.offset) { _, option in
let leading = option.votes == most && option.votes > 0
ZStack(alignment: .leading) {
GeometryReader { geo in
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(leading ? Trek.info.opacity(scheme == .dark ? 0.35 : 0.22) : Trek.track(scheme))
.frame(width: max(8, geo.size.width * CGFloat(option.votes) / CGFloat(most)))
}
HStack {
Text(option.label).font(.system(size: 12, weight: leading ? .heavy : .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 4)
Text("\(option.votes)").font(Trek.number(12, .heavy)).foregroundStyle(Trek.muted(scheme))
}
.padding(.horizontal, 9)
}
.frame(height: 24)
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 15, style: .continuous).fill(Trek.card(scheme)))
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
}
}