commit 722eb163cd45325b3f6fd69c215558c15a0356a1
parent 849ed234799823b43c7de4d34ac3fabc2cd501c0
Author: Michael Camilleri <[email protected]>
Date: Sat, 16 May 2026 22:53:00 +0900
Preview friend invites in the share sheet
This commit reshapes the Share Game invite entry point from a single drill-down
row into a horizontal list of prior crossmates. Up to four friends are shown
inline with deterministic avatar symbols and player colours, plus an 'All'
affordance for the full invite picker. Empty states now say 'No Prior
Crossmates', and the share sheet Done button moves to the trailing confirmation
position. The full invite picker now uses the same avatars, trims stored
display names before showing them, and places the explanatory copy above the
list.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 182 insertions(+), 22 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -35,6 +35,7 @@
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
+ 4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */; };
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
@@ -216,6 +217,7 @@
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; };
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; };
+ F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; };
F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; };
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; };
@@ -354,6 +356,7 @@
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
+ F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */,
EE3412F437AABD2988B6976D /* FriendPickerView.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
5ABB557BA10CBE9909056882 /* GameShareItem.swift */,
@@ -573,6 +576,7 @@
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */,
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
+ 4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */,
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */,
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */,
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */,
diff --git a/Crossmate/Views/FriendAvatarView.swift b/Crossmate/Views/FriendAvatarView.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+struct FriendAvatarView: View {
+ let authorID: String
+ var size: CGFloat = 34
+
+ private var avatar: FriendAvatar {
+ FriendAvatar.avatar(for: authorID)
+ }
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(avatar.background)
+
+ Image(systemName: avatar.symbolName)
+ .font(.system(size: size * 0.46, weight: .semibold))
+ .foregroundStyle(.white)
+ }
+ .frame(width: size, height: size)
+ .accessibilityHidden(true)
+ }
+}
+
+private struct FriendAvatar {
+ let symbolName: String
+ let background: Color
+
+ static func avatar(for authorID: String) -> FriendAvatar {
+ let symbol = symbols[stableIndex(for: "symbol-\(authorID)", count: symbols.count)]
+ let color = PlayerColor.palette[stableIndex(for: "color-\(authorID)", count: PlayerColor.palette.count)]
+ return FriendAvatar(symbolName: symbol, background: color.tint)
+ }
+
+ private static let symbols: [String] = [
+ "star.fill",
+ "sparkles",
+ "bolt.fill",
+ "moon.fill",
+ "heart.fill",
+ "crown.fill",
+ "leaf.fill",
+ "flame.fill"
+ ]
+
+ private static func stableIndex(for value: String, count: Int) -> Int {
+ guard count > 0 else { return 0 }
+ let hash = value.utf8.reduce(UInt32(2_166_136_261)) { partial, byte in
+ (partial ^ UInt32(byte)) &* 16_777_619
+ }
+ return Int(hash % UInt32(count))
+ }
+}
diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift
@@ -23,20 +23,23 @@ struct FriendPickerView: View {
var body: some View {
List {
- if friends.isEmpty {
- Section {
- Text("You haven't played with anyone yet. Share a game via a link first — once someone joins, they'll appear here for one-tap invites next time.")
- .font(.footnote)
+ Section {
+ if friends.isEmpty {
+ Text("No Prior Crossmates")
+ .font(.body.weight(.medium))
.foregroundStyle(.secondary)
- }
- } else {
- Section {
+ .frame(maxWidth: .infinity, minHeight: 72, alignment: .center)
+ } else {
ForEach(friends, id: \.authorID) { friend in
- row(for: friend)
+ friendRow(for: friend)
}
- } footer: {
- Text("The friend is added to this game in iCloud and notified. They choose whether to accept from their Invited list.")
}
+ } header: {
+ Text("Tap a player to add and notify. The player chooses whether to accept from their Invited list.")
+ .font(.footnote)
+ .foregroundStyle(Color(.secondaryLabel))
+ .textCase(nil)
+ .padding(.bottom, 8)
}
if let errorMessage {
@@ -48,19 +51,21 @@ struct FriendPickerView: View {
}
}
}
- .navigationTitle("Invite a Friend")
+ .navigationTitle("Invite a Crossmate")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
- private func row(for friend: FriendEntity) -> some View {
+ private func friendRow(for friend: FriendEntity) -> some View {
let authorID = friend.authorID ?? ""
let invited = invitedAuthorIDs.contains(authorID)
Button {
Task { await invite(authorID) }
} label: {
HStack {
- Label(displayName(for: friend), systemImage: "person.crop.circle")
+ FriendAvatarView(authorID: authorID)
+ .padding(.trailing, 8)
+ Text(displayName(for: friend))
Spacer()
if invitingAuthorID == authorID {
ProgressView()
@@ -74,7 +79,10 @@ struct FriendPickerView: View {
}
private func displayName(for friend: FriendEntity) -> String {
- if let name = friend.displayName, !name.isEmpty { return name }
+ if let name = friend.displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
return "Player"
}
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -6,13 +6,27 @@ struct GameShareSheet: View {
let title: String
let shareController: ShareController
+ @Environment(\.inviteFriend) private var inviteFriend
@Environment(\.dismiss) private var dismiss
+ @FetchRequest(
+ sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)],
+ predicate: NSPredicate(format: "isBlocked == NO"),
+ animation: .default
+ )
+ private var friends: FetchedResults<FriendEntity>
+
@State private var shareURL: URL?
@State private var errorMessage: String?
@State private var isLoadingExistingLink = true
@State private var isCreating = false
@State private var didLoadExistingLink = false
@State private var didCopy = false
+ @State private var invitingAuthorID: String?
+ @State private var invitedAuthorIDs: Set<String> = []
+
+ private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> {
+ Array(friends.prefix(4))
+ }
var body: some View {
NavigationStack {
@@ -36,7 +50,7 @@ struct GameShareSheet: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
- Text("Crossmate syncs games using iCloud. Please be aware that your player name is shared with other players. Players can work on the same puzzle simultaneously or at different times.")
+ Text("Crossmate syncs games using iCloud. Your player name is shared with other players. Players can work on the same puzzle simultaneously or at different times.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -84,13 +98,38 @@ struct GameShareSheet: View {
}
Section {
- NavigationLink {
- FriendPickerView(gameID: gameID)
- } label: {
- Label("Invite a Friend", systemImage: "person.badge.plus")
+ if visibleFriends.isEmpty {
+ Text("No Prior Crossmates")
+ .font(.body.weight(.medium))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, minHeight: 72, alignment: .center)
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ ForEach(visibleFriends, id: \.authorID) { friend in
+ friendInviteButton(for: friend)
+ }
+
+ NavigationLink {
+ FriendPickerView(gameID: gameID)
+ } label: {
+ VStack(spacing: 6) {
+ Image(systemName: "ellipsis")
+ .font(.system(size: 40, weight: .regular))
+ .frame(width: 40, height: 40)
+ Text("All")
+ .font(.callout.weight(.medium))
+ .lineLimit(1)
+ }
+ .frame(width: 96, height: 88)
+ }
+ .buttonStyle(.plain)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 2)
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
- } footer: {
- Text("Re-invite someone you've played with before — no link to send.")
}
if let errorMessage {
@@ -110,7 +149,7 @@ struct GameShareSheet: View {
.navigationTitle("Share Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .cancellationAction) {
+ ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
@@ -120,6 +159,62 @@ struct GameShareSheet: View {
}
}
+ @ViewBuilder
+ private func friendInviteButton(for friend: FriendEntity) -> some View {
+ let authorID = friend.authorID ?? ""
+ let isInviting = invitingAuthorID == authorID
+ let wasInvited = invitedAuthorIDs.contains(authorID)
+
+ Button {
+ Task { await invite(authorID) }
+ } label: {
+ VStack(spacing: 8) {
+ ZStack {
+ FriendAvatarView(authorID: authorID, size: 40)
+ if isInviting {
+ ProgressView()
+ .controlSize(.small)
+ .background(.background, in: Circle())
+ } else if wasInvited {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .background(.background, in: Circle())
+ .offset(x: 12, y: -10)
+ }
+ }
+ Text(displayName(for: friend))
+ .font(.callout.weight(.medium))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
+ .frame(width: 108, height: 88)
+ }
+ .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited)
+ }
+
+ private func displayName(for friend: FriendEntity) -> String {
+ if let name = friend.displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
+ return "Player"
+ }
+
+ private func invite(_ authorID: String) async {
+ guard !authorID.isEmpty, let inviteFriend else { return }
+ invitingAuthorID = authorID
+ errorMessage = nil
+ defer { invitingAuthorID = nil }
+
+ do {
+ try await inviteFriend(gameID, authorID)
+ invitedAuthorIDs.insert(authorID)
+ } catch {
+ errorMessage = describe(error)
+ }
+ }
+
private func loadExistingLink() async {
guard !didLoadExistingLink else { return }
didLoadExistingLink = true