Let the label the account syncs be one the user can set

This commit is contained in:
Arkadiusz Fal
2026-09-09 23:52:54 +02:00
parent 2e6a464575
commit 2e1f411cc8
6 changed files with 299 additions and 4 deletions

View File

@@ -319,6 +319,37 @@
}
}
},
"accounts.rename" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rename"
}
}
}
},
"accounts.rename.message %@" : {
"comment" : "Rename alert body, naming what an empty field goes back to",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This name is shown on all your devices. Leave it empty to go back to \"%@\"."
}
}
}
},
"accounts.rename.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rename Account"
}
}
}
},
"accounts.signIn.identityChanged" : {
"localizations" : {
"en" : {

View File

@@ -204,13 +204,20 @@ final class DataContextValidator {
}
}
/// User-set label, else the stored login identifier, else the host.
///
/// The username lives in the Keychain beside the credential rather than in
/// synced metadata, so it is only available on a device that is signed in.
/// User-set label, else whatever the account falls back to.
func displayName(for account: Account) -> String {
if let name = account.displayName, !name.isEmpty { return name }
return defaultDisplayName(for: account)
}
/// What the account is called with no user-set label: the stored login
/// identifier, else the source's name, else its host.
///
/// Device-local by nature. The username lives in the Keychain beside the
/// credential rather than in synced metadata, so it is only available on a
/// device that is signed in — two devices holding the very same account can
/// legitimately fall back to different names for it.
func defaultDisplayName(for account: Account) -> String {
if let username = appEnvironment?
.accountCredentialsManager(for: account)?
.username(for: account),
@@ -228,4 +235,18 @@ final class DataContextValidator {
return String(localized: "accounts.status.instanceMissing")
}
/// What a rename field's contents mean once the user is done typing.
///
/// `nil` clears the user-set label and hands the account back to
/// `defaultDisplayName`. An empty field says that outright — and so does
/// typing exactly the name the account already falls back to. The field
/// starts prefilled with the label the row is showing, so saving it
/// untouched must not turn a *device-local* fallback into a synced label
/// that every other device is then made to display.
static func renamedDisplayName(_ input: String, fallingBackTo defaultName: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != defaultName else { return nil }
return trimmed
}
}

View File

@@ -0,0 +1,79 @@
//
// AccountRenameAlert.swift
// Yattee
//
// The one place an account's synced label is set.
//
import SwiftUI
/// An account the user asked to rename, and what its field starts on.
///
/// Carries the strings rather than the account so the surfaces that name an
/// account differently — an orphan resolves through no source — can each say
/// what the field should offer, while the alert itself stays the same
/// everywhere.
struct AccountRenameRequest: Identifiable {
let id: UUID
/// The label the account shows with no user-set name. Doubles as the
/// placeholder and as the value that means "no custom name at all".
let defaultName: String
/// What the field is prefilled with: the label the row is showing now.
let initialText: String
}
/// The rename alert, shared by every screen that lists accounts.
///
/// A text field in an alert rather than a sheet: the whole edit is one short
/// string, and it is the app's existing way of asking for one on tvOS, where
/// there is no sheet-sized form for a single field.
private struct AccountRenameAlertModifier: ViewModifier {
@Binding var request: AccountRenameRequest?
let onCommit: (UUID, String?) -> Void
@State private var text = ""
func body(content: Content) -> some View {
content
// Keyed on the ID rather than on the request: the prefill has to be
// in place before the alert's field is built, and re-entering the
// same row is the only time the text should be reset.
.onChange(of: request?.id) { _, _ in
text = request?.initialText ?? ""
}
.alert(
String(localized: "accounts.rename.title"),
isPresented: Binding(
get: { request != nil },
set: { if !$0 { request = nil } }
),
presenting: request
) { pending in
TextField(pending.defaultName, text: $text)
Button(String(localized: "common.save")) {
onCommit(
pending.id,
DataContextValidator.renamedDisplayName(text, fallingBackTo: pending.defaultName)
)
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { pending in
Text(String(localized: "accounts.rename.message \(pending.defaultName)"))
}
}
}
extension View {
/// Presents the rename alert for `request`, calling `onCommit` with the
/// label to store — `nil` when the user cleared it.
func accountRenameAlert(
_ request: Binding<AccountRenameRequest?>,
onCommit: @escaping (UUID, String?) -> Void
) -> some View {
modifier(AccountRenameAlertModifier(request: request, onCommit: onCommit))
}
}

View File

@@ -36,6 +36,7 @@ struct AccountsListView: View {
@State private var login: AccountsListLogin?
@State private var pendingRemoval: PendingAccountRemoval?
@State private var renameRequest: AccountRenameRequest?
/// Bumped whenever a credential is replaced, purely to re-read the login
/// identifier the rows display.
@@ -107,6 +108,9 @@ struct AccountsListView: View {
#if !os(tvOS)
.presentationCompactAdaptation(.sheet)
#endif
.accountRenameAlert($renameRequest) { accountID, name in
appEnvironment?.accountsManager.updateDisplayName(name, for: accountID)
}
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
credentialRevision &+= 1
}
@@ -276,6 +280,17 @@ struct AccountsListView: View {
#endif
}
// Offered on orphans too: with no source to fall back to, a label of
// its own is the only thing that tells one abandoned login from another.
Button {
renameRequest = makeRenameRequest(for: account, instance: instance)
} label: {
Label(String(localized: "accounts.rename"), systemImage: "pencil")
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
Button(role: .destructive) {
pendingRemoval = PendingAccountRemoval(
id: account.id,
@@ -326,6 +341,28 @@ struct AccountsListView: View {
?? String(localized: "accounts.orphan.unnamed")
}
/// What the rename field should start on, and what clearing it goes back to.
///
/// An orphan is the exception for the same reason `title(for:instance:)`
/// is: with no source to resolve through, the validator's last resort is
/// the words "Source missing", which is a diagnosis and not a name to
/// prefill a field with.
private func makeRenameRequest(for account: Account, instance: Instance?) -> AccountRenameRequest {
guard instance != nil, let validator = appEnvironment?.dataContextValidator else {
return AccountRenameRequest(
id: account.id,
defaultName: String(localized: "accounts.orphan.unnamed"),
initialText: account.displayName ?? ""
)
}
return AccountRenameRequest(
id: account.id,
defaultName: validator.defaultDisplayName(for: account),
initialText: validator.displayName(for: account)
)
}
private func status(for account: Account) -> AccountStatus {
accountsManager?.status(for: account.id) ?? .checking
}

View File

@@ -45,6 +45,10 @@ struct InstanceAccountsSection: View {
/// sharing the Keychain, which is not something to do on one tap.
@State private var pendingSignOut: Account?
/// The account a rename was asked for. Like the sign-out above it, held on
/// the section rather than the row, and matched back to a row by ID.
@State private var renameRequest: AccountRenameRequest?
private var accounts: [Account] {
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
}
@@ -120,6 +124,12 @@ struct InstanceAccountsSection: View {
Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key")
}
Button {
renameRequest = makeRenameRequest(for: account)
} label: {
Label(String(localized: "accounts.rename"), systemImage: "pencil")
}
Button(role: .destructive) {
pendingSignOut = account
} label: {
@@ -151,6 +161,16 @@ struct InstanceAccountsSection: View {
#if !os(tvOS)
.presentationCompactAdaptation(.sheet)
#endif
// Attached to the row for the same reason the dialog above it is, and
// narrowed to the row that raised it so only one alert is ever live.
.accountRenameAlert(
Binding(
get: { renameRequest?.id == account.id ? renameRequest : nil },
set: { if $0 == nil { renameRequest = nil } }
)
) { accountID, name in
appEnvironment?.accountsManager.updateDisplayName(name, for: accountID)
}
#if os(tvOS)
// tvOS never syncs app Keychain items, so an account that arrived from
@@ -174,6 +194,16 @@ struct InstanceAccountsSection: View {
appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName
}
/// What the rename field starts on, and what clearing it goes back to.
private func makeRenameRequest(for account: Account) -> AccountRenameRequest {
AccountRenameRequest(
id: account.id,
defaultName: appEnvironment?.dataContextValidator.defaultDisplayName(for: account)
?? instance.displayName,
initialText: displayName(for: account)
)
}
private func status(for account: Account) -> AccountStatus {
appEnvironment?.accountsManager.status(for: account.id) ?? .checking
}