Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class BetaFlags {
/// You-tab long press share this gate, so the two never disagree.
var canSwitchAccounts: Bool { accessGranted }

/// Whether Settings shows the Account Info row.
var canViewAccountInfo: Bool { accessGranted }

/// Enables or disables a beta flag and persists the change to disk.
func set(_ option: Option, enabled: Bool) {
if enabled {
Expand Down
15 changes: 13 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ extension AppRouter {
case settings
/// The account's phone, email, user ID and public key, opened from Settings.
case accountInfo
/// The signed-in user's profile fields in one list, opened from the You tab's Edit Profile button.
case editProfile
/// The bio on its own, pushed from Edit Profile.
case editBio
/// The cover banner on its own, pushed from Edit Profile.
case changeCoverPicture
/// The display name on its own, edited from Settings. The full
/// profile-setup flow starts on the same screen but carries on to the
/// tip card; this one returns to the settings list.
Expand Down Expand Up @@ -143,7 +149,8 @@ extension AppRouter {
.buyCurrency, .convertCurrency,
.withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress:
return .balance
case .settings, .accountInfo, .changeDisplayName, .changeProfilePicture, .username,
case .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture,
.changeDisplayName, .changeProfilePicture, .username,
.setMinimumTip,
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw:
Expand Down Expand Up @@ -180,6 +187,9 @@ extension AppRouter {
case .usdcDepositAddress: "usdcDepositAddress"
case .settings: "settings"
case .accountInfo: "accountInfo"
case .editProfile: "editProfile"
case .editBio: "editBio"
case .changeCoverPicture: "changeCoverPicture"
case .changeDisplayName: "changeDisplayName"
case .changeProfilePicture: "changeProfilePicture"
case .username: "username"
Expand Down Expand Up @@ -245,7 +255,8 @@ extension AppRouter {
case .activity,
.discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard,
.usdcDepositEducation, .usdcDepositAddress,
.settings, .accountInfo, .changeDisplayName, .changeProfilePicture,
.settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture,
.changeDisplayName, .changeProfilePicture,
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw,
.profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
Expand Down
16 changes: 16 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ struct DestinationView: View {
case .accountInfo:
AccountInfoScreen()

case .editProfile:
EditProfileScreen()

case .editBio:
// Seeded here so the field opens on the bio it is about to replace.
let session = sessionContainer.session
let flipClient = container.flipClient
EditBioScreen(model: EditBioModel(
bio: session.profile?.bio ?? "",
saving: { try await flipClient.setBio($0, owner: session.ownerKeyPair) },
refresh: { try await session.updateProfile() }
))

case .changeCoverPicture:
ChangeCoverPictureScreen()

case .changeDisplayName:
ChangeDisplayNameScreen(currentName: sessionContainer.session.profile?.displayName ?? "")

Expand Down
5 changes: 4 additions & 1 deletion Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ struct HomeTabView: View {
selection.hidesTabBar(
isWalletCardExpanded: cardExpansion.isExpanded,
isShowingBill: sessionContainer.session.isShowingBill,
isShowingProfileCard: router.isShowingProfileCard,
hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false
)
}
Expand Down Expand Up @@ -354,12 +355,14 @@ extension HomeTab {
func hidesTabBar(
isWalletCardExpanded: Bool,
isShowingBill: Bool,
isShowingProfileCard: Bool,
hasPushedScreen: Bool
) -> Bool {
if isShowingBill || hasPushedScreen { return true }
switch self {
case .wallet: return isWalletCardExpanded
case .tipCard, .scan, .chat: return false
case .tipCard: return isShowingProfileCard
case .scan, .chat: return false
}
}
}
Expand Down
170 changes: 170 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//
// ChangeCoverPictureScreen.swift
// Flipcash
//

import SwiftUI
import UniformTypeIdentifiers
import FlipcashCore
import FlipcashUI

private let logger = Logger(label: "flipcash.profile-cover")

/// Changing the cover banner on its own, pushed from Edit Profile. Owns its upload state so a
/// cover can never resume an avatar blob.
struct ChangeCoverPictureScreen: View {

@Environment(Container.self) private var container
@Environment(SessionContainer.self) private var sessionContainer
@Environment(AppRouter.self) private var router

@State private var state = ProfileCreationState()
@State private var isShowingPhotoPicker = false
@State private var isShowingFilePicker = false
@State private var dialog: DialogItem?
@State private var buttonState: ButtonState = .normal

private var session: Session { sessionContainer.session }
private var profile: Profile? { session.profile }

var body: some View {
Background(color: .backgroundMain) {
VStack(spacing: 0) {
Menu {
Button("Photo Library", systemImage: "photo.on.rectangle") { isShowingPhotoPicker = true }
Button("Choose File", systemImage: "folder") { isShowingFilePicker = true }
} label: {
ProfileCoverBanner(
userID: session.userID,
coverPicture: profile?.coverPicture,
preview: state.selectedImage
)
.clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius))
.overlay {
if state.selectedImage == nil && profile?.coverPicture == nil {
Image(systemName: "plus")
.font(.appDisplayMedium)
.foregroundStyle(Color.textSecondary)
}
}
}
.menuIndicator(.hidden)
.disabled(state.isUploading)
.accessibilityIdentifier("cover-picker")
.padding(.top, 20)

Spacer()

Button(action: submit) {
ButtonStateLabel("Save", state: buttonState)
}
.buttonStyle(.filled)
.disabled(!state.canSubmitPhoto || !buttonState.isNormal)
.accessibilityIdentifier("cover-save")
.padding(.bottom, 20)
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.navigationTitle("Cover")
.navigationBarTitleDisplayMode(.inline)
.dialog(item: $dialog)
.fullScreenCover(isPresented: $isShowingPhotoPicker) {
ImagePickerWithEditor(
onImagePicked: state.select,
onDismiss: { isShowingPhotoPicker = false }
)
.ignoresSafeArea()
}
.fileImporter(
isPresented: $isShowingFilePicker,
allowedContentTypes: [.image],
allowsMultipleSelection: false,
onCompletion: handleFileImport
)
.task(id: state.uploadAttemptID) {
guard state.hasPendingUpload else { return }
await upload()
}
}

private func submit() {
state.beginUpload()
}

private func upload() async {
buttonState = .loading
do {
try await state.uploadPhoto(
with: SessionProfilePictureUploader(
session: session,
flipClient: container.flipClient,
slot: .cover
)
)

buttonState = .success
try? await Task.delay(milliseconds: 500)
guard !Task.isCancelled else { return }

state.releaseSelectedImage()
router.popTopmost()

} catch let error as ErrorBlob {
buttonState = .normal
guard !Task.isCancelled else { return }
logger.info("Cover upload failed", metadata: ["error": "\(error)"])
ErrorReporting.captureError(error, reason: "Cover upload failed", userFacing: true)
dialog = .profilePictureFailed(error)

} catch let error as ImageEncoderError {
buttonState = .normal
logger.error("Failed to encode the cover", metadata: ["error": "\(error)"])
ErrorReporting.captureError(error, reason: "Failed to encode the cover")
dialog = .imageProcessingFailed

} catch {
buttonState = .normal
guard !Task.isCancelled else { return }
logger.error("Failed to set cover picture", metadata: ["error": "\(error)"])
ErrorReporting.captureError(error, reason: "Failed to set cover picture")
dialog = .error(
title: "Couldn't Upload Your Cover",
subtitle: "Try again"
)
}
}

private func handleFileImport(_ result: Result<[URL], Error>) {
switch result {
case .failure(let error):
logger.info("Cover file import failed", metadata: ["error": "\(error)"])

case .success(let urls):
guard let url = urls.first else { return }
importImage(at: url)
}
}

private func importImage(at url: URL) {
Task {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }

let image = await Task.detached(priority: .userInitiated) { () -> UIImage? in
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}.value

guard let image else {
dialog = .error(
title: "Couldn't Open That File",
subtitle: "Try a different image"
)
return
}

state.select(image)
}
}
}
127 changes: 127 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/EditBioModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// EditBioModel.swift
// Flipcash
//

import SwiftUI
import FlipcashCore
import FlipcashUI

private let logger = Logger(label: "flipcash.edit-bio")

/// The bio editor's state and its one save.
///
/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test
/// has nothing to fake.
@MainActor
@Observable
final class EditBioModel {

/// What the server refused the bio for, shown under the field. Cleared by the next edit.
enum FieldError: Equatable {
case moderated
case invalid

var message: String {
switch self {
case .moderated: "This bio isn't allowed. Try different wording"
case .invalid: "This bio isn't valid"
}
}
}

/// Where the save is. `.saved` is held by the screen for its checkmark before it pops.
enum SaveState: Equatable {
case normal
case saving
case saved
}

static let maxLength = 160

/// The bio as typed. Editing it clears ``fieldError``.
var text: String {
didSet {
guard text != oldValue else { return }
fieldError = nil
}
}

private(set) var state: SaveState = .normal
private(set) var fieldError: FieldError?

/// A failure with no field to blame, shown as a dialog. Set to nil once shown.
var failure: Error?

@ObservationIgnored private let initialText: String
@ObservationIgnored private let validator = LengthValidator(maxLength: maxLength)
@ObservationIgnored private let saving: (String) async throws -> Void
@ObservationIgnored private let refresh: () async throws -> Void

/// - Parameters:
/// - bio: the bio the profile carries now.
/// - saving: submits the trimmed bio.
/// - refresh: re-reads the profile so the rest of the app sees the new bio.
init(
bio: String,
saving: @escaping (String) async throws -> Void,
refresh: @escaping () async throws -> Void
) {
self.text = bio
self.initialText = bio
self.saving = saving
self.refresh = refresh
}

/// How many more characters fit, negative once over the limit.
var remaining: Int {
Self.maxLength - text.count
}

/// Whether Save is enabled. Clearing the bio to empty is a change like any other.
var canSave: Bool {
state == .normal && submission != nil && submission != Self.trim(initialText)
}

/// The bio as it would be submitted, or nil while it is over the limit. Empty is valid, unlike
/// for `LengthValidator`, which rejects blank input: clearing the bio is an edit.
private var submission: String? {
let trimmed = Self.trim(text)
return trimmed.isEmpty ? trimmed : validator.validate(trimmed)
}

private static func trim(_ string: String) -> String {
string.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// Submits the bio, then re-reads the profile. Failures land in ``fieldError`` or ``failure``.
func save() async {
guard canSave, let submission else { return }

state = .saving
fieldError = nil

do {
try await saving(submission)
try await refresh()
state = .saved

} catch ErrorProfile.moderated(let category) {
state = .normal
logger.info("Bio moderation denied", metadata: ["category": "\(category)"])
fieldError = .moderated

} catch ErrorProfile.invalidBio {
state = .normal
logger.info("Bio rejected as invalid")
fieldError = .invalid

} catch {
state = .normal
guard !Task.isCancelled else { return }
logger.error("Failed to set bio", metadata: ["error": "\(error)"])
ErrorReporting.captureError(error, reason: "Failed to set bio")
failure = error
}
}
}
Loading
Loading