mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Let the label the account syncs be one the user can set
This commit is contained in:
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
79
Yattee/Views/Settings/AccountRenameAlert.swift
Normal file
79
Yattee/Views/Settings/AccountRenameAlert.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user