Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 134 additions & 76 deletions App/Features/Lists/AddWatcherSheetView.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
// AddWatcherSheetView
//
// Sheet for inviting a user to watch a list by handle (NW-1). Calls
// WatchersViewModel.lookupAndAdd to resolve the handle, then
// WatchersViewModel.addWatcher on confirm.
// Sheet for granting someone access to a list (work-consolidation.md G23,
// issue #48). Searches candidates through `GET /api/lists/{id}/watchers/users`
// — the route built for this, which auto-excludes people who already have
// access — and grants the chosen person access through the real
// `POST /api/lists/{id}/watchers`.
//
// Granting access to a named user is subscriber-gated server-side, so a free
// owner's 403 arrives as `ListsError.subscriberRequired` and is rendered as an
// upsell rather than an error banner (same treatment as Share Links and
// Invite by Email).
//
// Per Decision 0003 the view imports only InterlinedDomain.

Expand All @@ -14,101 +21,152 @@ struct AddWatcherSheetView: View {
@Bindable var viewModel: WatchersViewModel
@Environment(\.dismiss) private var dismiss

@State private var handleInput: String = ""
@State private var searchText: String = ""
@State private var selectedRole: WatcherRole = .viewer

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add Watcher")
Text("Share This List")
.font(.ilTitle(18))
.padding(.top, 4)

VStack(alignment: .leading, spacing: 8) {
Text("Handle")
.font(.ilMono(10))
.foregroundStyle(.secondary)
HStack {
TextField("@username", text: $handleInput)
.textFieldStyle(.roundedBorder)
Button("Search") {
Task { await viewModel.lookupAndAdd(handle: handleInput) }
}
.buttonStyle(.bordered)
.disabled(handleInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| viewModel.isLookingUp)
}
if viewModel.isLookingUp {
ProgressView()
.controlSize(.small)
}
}
searchField

if let found = viewModel.foundUser {
Divider()
VStack(alignment: .leading, spacing: 6) {
Text("Found user")
.font(.ilMono(10))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.font(.ilBody())
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text(found.displayName ?? found.username)
.font(.ilBody())
.fontWeight(.medium)
Text("@\(found.username)")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
Spacer()
}
if viewModel.showSubscriberUpsell {
subscriberUpsell
}

HStack {
Text("Role")
.font(.ilMono(10))
.foregroundStyle(.secondary)
Picker("Role", selection: $selectedRole) {
Text("Viewer").tag(WatcherRole.viewer)
Text("Editor").tag(WatcherRole.editor)
Text("Owner").tag(WatcherRole.owner)
}
.pickerStyle(.menu)
.frame(width: 120)
Spacer()
}
}
rolePicker

HStack {
Spacer()
Button("Add") {
Task {
await viewModel.addWatcher(userId: found.id, role: selectedRole)
if viewModel.error == nil {
dismiss()
}
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.pendingOperations.contains(found.id))
}
}
candidateList

if let error = viewModel.error {
Text(error.localizedDescription)
.font(.ilMono(10))
.foregroundStyle(Color.accentColor)
}

Spacer()

HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Done") { dismiss() }
.keyboardShortcut(.cancelAction)
}
}
.padding(20)
.frame(minWidth: 380, minHeight: 260)
.frame(minWidth: 420, minHeight: 380)
.task {
// Open on the unfiltered first page so the sheet is useful before
// the user types anything.
if !viewModel.hasSearchedOnce {
await viewModel.searchCandidates(query: "")
}
}
}

// MARK: - Sections

private var searchField: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Find someone")
.font(.ilMono(10))
.foregroundStyle(.secondary)
HStack {
TextField("Name or username", text: $searchText)
.textFieldStyle(.roundedBorder)
.onSubmit {
Task { await viewModel.searchCandidates(query: searchText) }
}
Button("Search") {
Task { await viewModel.searchCandidates(query: searchText) }
}
.buttonStyle(.bordered)
.disabled(viewModel.isSearching)
}
if viewModel.isSearching {
ProgressView()
.controlSize(.small)
}
}
}

private var rolePicker: some View {
HStack {
Text("Access")
.font(.ilMono(10))
.foregroundStyle(.secondary)
Picker("Access", selection: $selectedRole) {
ForEach(WatcherRole.allCases, id: \.self) { role in
Text(role.label).tag(role)
}
}
.pickerStyle(.menu)
.frame(width: 140)
Spacer()
}
}

@ViewBuilder
private var candidateList: some View {
if viewModel.candidates.isEmpty {
Text(viewModel.hasSearchedOnce
? "Nobody left to add — everyone matching already has access."
: "Search for someone to give access to.")
.font(.ilMono(11))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
List(viewModel.candidates) { candidate in
candidateRow(candidate)
}
.frame(maxHeight: .infinity)
}
}

private func candidateRow(_ candidate: CollaboratorCandidate) -> some View {
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.font(.ilBody())
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(candidate.displayLabel)
.font(.ilBody())
.fontWeight(.medium)
if let username = candidate.username, !username.isEmpty {
Text("@\(username)")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
Spacer()
Button("Add") {
Task { await viewModel.addWatcher(candidate: candidate, role: selectedRole) }
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.pendingOperations.contains(candidate.id))
.accessibilityLabel("Give \(candidate.displayLabel) \(selectedRole.label) access")
}
.padding(.vertical, 2)
}

private var subscriberUpsell: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "star.circle.fill")
.foregroundStyle(Color.accentColor)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("Sharing a list with someone is a subscriber feature")
.font(.ilBody().weight(.semibold))
Text("Your subscription does not cover granting access. Removing access always works.")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
Spacer()
Button("Dismiss") { viewModel.dismissSubscriberUpsell() }
.buttonStyle(.bordered)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.08))
}
}
152 changes: 152 additions & 0 deletions App/Features/Lists/ContributorsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// ContributorsView
//
// The ranked contributor panel for a list (work-consolidation.md G23 /
// issue #48), presented as a sheet from the Lists toolbar. Shows who added
// and edited rows, in the server's ranking order.
//
// Per Decision 0003 the view imports only InterlinedDomain.

import SwiftUI
import InterlinedDomain

struct ContributorsView: View {

let listId: String
let environment: AppEnvironment

@Environment(\.dismiss) private var dismiss
@State private var viewModel: ContributorsViewModel?

var body: some View {
Group {
if let viewModel {
content(viewModel: viewModel)
} else {
ProgressView()
.accessibilityLabel("Loading contributors")
.padding()
}
}
.frame(minWidth: 440, minHeight: 340)
.task {
if viewModel == nil {
let model = ContributorsViewModel(lists: environment.lists, listId: listId)
viewModel = model
await model.load()
}
}
}

@ViewBuilder
private func content(viewModel: ContributorsViewModel) -> some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()

if viewModel.isLoading, viewModel.contributors.isEmpty {
ProgressView()
.accessibilityLabel("Loading contributors")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.error, viewModel.contributors.isEmpty {
errorState(error: error, viewModel: viewModel)
} else if viewModel.contributors.isEmpty {
Text("No contributions yet — nobody has added or edited a row.")
.font(.ilMono(11))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
List {
ForEach(Array(viewModel.contributors.enumerated()), id: \.element.id) { index, contributor in
row(rank: index + 1, contributor: contributor, total: viewModel.totalScore)
}
}
}

Divider()
footer
}
}

private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Contributors")
.font(.ilTitle(20))
Text("Everyone who has added or edited rows on this list, most active first.")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}

private func row(rank: Int, contributor: ListContributor, total: Int) -> some View {
HStack(spacing: 10) {
Text("\(rank)")
.font(.ilMono(11))
.foregroundStyle(.secondary)
.frame(width: 20, alignment: .trailing)
.accessibilityHidden(true)

Image(systemName: "person.crop.circle")
.font(.ilMono(24))
.foregroundStyle(.secondary)
.accessibilityHidden(true)

VStack(alignment: .leading, spacing: 2) {
Text(contributor.displayLabel)
.font(.ilBody())
if let username = contributor.username, !username.isEmpty {
Text("@\(username)")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}

Spacer()

VStack(alignment: .trailing, spacing: 2) {
Text("\(contributor.addedCount) added · \(contributor.editedCount) edited")
.font(.ilMono(10))
.foregroundStyle(.secondary)
// Guarded: `total` is 0 only when the list has no
// contributors, in which case this row does not render.
if total > 0 {
Text("\(Int((Double(contributor.score) / Double(total) * 100).rounded()))% of edits")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
.accessibilityLabel(
"\(contributor.displayLabel), rank \(rank), \(contributor.addedCount) rows added, \(contributor.editedCount) rows edited"
)
}

private func errorState(error: Error, viewModel: ContributorsViewModel) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label("Couldn't load contributors", systemImage: "exclamationmark.triangle")
.font(.ilBody().weight(.semibold))
Text(error.localizedDescription)
.font(.ilMono(11))
.foregroundStyle(.secondary)
Button("Try Again") {
Task { await viewModel.load() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(16)
}

private var footer: some View {
HStack {
Spacer()
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
.padding(12)
}
}
Loading