|
@@ -152,6 +152,14 @@ final class MyProfilePageView: NSView {
|
|
|
private var nameEmailRow: ProfileDualFieldRow!
|
|
private var nameEmailRow: ProfileDualFieldRow!
|
|
|
private var phoneJobRow: ProfileDualFieldRow!
|
|
private var phoneJobRow: ProfileDualFieldRow!
|
|
|
|
|
|
|
|
|
|
+ private let topChrome = NSView()
|
|
|
|
|
+ private let backButton = NSButton(title: "← All profiles", target: nil, action: nil)
|
|
|
|
|
+ private let contextLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private var editingProfileID: UUID?
|
|
|
|
|
+
|
|
|
|
|
+ /// Called from the back control and after a successful save (returns to the profiles list).
|
|
|
|
|
+ var onDismiss: (() -> Void)?
|
|
|
|
|
+
|
|
|
private let workExperienceRowsStack = NSStackView()
|
|
private let workExperienceRowsStack = NSStackView()
|
|
|
private var workExperienceEntries: [WorkExperienceEntryView] = []
|
|
private var workExperienceEntries: [WorkExperienceEntryView] = []
|
|
|
private let educationRowsStack = NSStackView()
|
|
private let educationRowsStack = NSStackView()
|
|
@@ -278,15 +286,52 @@ final class MyProfilePageView: NSView {
|
|
|
formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
|
|
|
|
+ topChrome.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ topChrome.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+
|
|
|
|
|
+ backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ backButton.bezelStyle = .rounded
|
|
|
|
|
+ backButton.isBordered = false
|
|
|
|
|
+ backButton.font = .systemFont(ofSize: 13, weight: .medium)
|
|
|
|
|
+ backButton.contentTintColor = ProfilePagePalette.brandBlue
|
|
|
|
|
+ backButton.target = self
|
|
|
|
|
+ backButton.action = #selector(didTapBack)
|
|
|
|
|
+
|
|
|
|
|
+ contextLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contextLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
|
|
|
+ contextLabel.textColor = ProfilePagePalette.primaryText
|
|
|
|
|
+ contextLabel.stringValue = "New profile"
|
|
|
|
|
+ contextLabel.backgroundColor = .clear
|
|
|
|
|
+ contextLabel.isBordered = false
|
|
|
|
|
+ contextLabel.isEditable = false
|
|
|
|
|
+ contextLabel.isSelectable = false
|
|
|
|
|
+ ProfileLayoutEnforcement.applyLeftAlignedTextField(contextLabel)
|
|
|
|
|
+
|
|
|
|
|
+ topChrome.addSubview(backButton)
|
|
|
|
|
+ topChrome.addSubview(contextLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ backButton.leadingAnchor.constraint(equalTo: topChrome.leadingAnchor, constant: 4),
|
|
|
|
|
+ backButton.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
|
|
|
|
|
+ contextLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 10),
|
|
|
|
|
+ contextLabel.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
|
|
|
|
|
+ contextLabel.trailingAnchor.constraint(lessThanOrEqualTo: topChrome.trailingAnchor, constant: -8)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(topChrome)
|
|
|
addSubview(scrollView)
|
|
addSubview(scrollView)
|
|
|
scrollView.documentView = documentView
|
|
scrollView.documentView = documentView
|
|
|
documentView.addSubview(cardView)
|
|
documentView.addSubview(cardView)
|
|
|
cardView.addSubview(formStack)
|
|
cardView.addSubview(formStack)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
+ topChrome.leftAnchor.constraint(equalTo: leftAnchor),
|
|
|
|
|
+ topChrome.rightAnchor.constraint(equalTo: rightAnchor),
|
|
|
|
|
+ topChrome.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
|
|
|
|
+ topChrome.heightAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+
|
|
|
scrollView.leftAnchor.constraint(equalTo: leftAnchor),
|
|
scrollView.leftAnchor.constraint(equalTo: leftAnchor),
|
|
|
scrollView.rightAnchor.constraint(equalTo: rightAnchor),
|
|
scrollView.rightAnchor.constraint(equalTo: rightAnchor),
|
|
|
- scrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
|
|
+ scrollView.topAnchor.constraint(equalTo: topChrome.bottomAnchor, constant: 2),
|
|
|
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
|
|
|
// Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
|
|
// Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
|
|
@@ -369,6 +414,124 @@ final class MyProfilePageView: NSView {
|
|
|
ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
|
|
ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func prepareNewProfile() {
|
|
|
|
|
+ editingProfileID = nil
|
|
|
|
|
+ contextLabel.stringValue = "New profile"
|
|
|
|
|
+ applyForm(
|
|
|
|
|
+ from: SavedProfile(
|
|
|
|
|
+ id: UUID(),
|
|
|
|
|
+ profileDisplayName: "",
|
|
|
|
|
+ personal: .empty,
|
|
|
|
|
+ careerSummary: "",
|
|
|
|
|
+ workExperiences: [.empty],
|
|
|
|
|
+ educations: [.empty],
|
|
|
|
|
+ certificates: "",
|
|
|
|
|
+ interests: "",
|
|
|
|
|
+ languages: "",
|
|
|
|
|
+ referral: ""
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func loadSavedProfile(_ profile: SavedProfile) {
|
|
|
|
|
+ editingProfileID = profile.id
|
|
|
|
|
+ contextLabel.stringValue = "Edit profile"
|
|
|
|
|
+ applyForm(from: profile)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyForm(from profile: SavedProfile) {
|
|
|
|
|
+ profileNameField.stringValue = profile.profileDisplayName
|
|
|
|
|
+ applyPersonalInformation(profile.personal)
|
|
|
|
|
+ careerField.stringValue = profile.careerSummary
|
|
|
|
|
+ certificatesField.stringValue = profile.certificates
|
|
|
|
|
+ interestsField.stringValue = profile.interests
|
|
|
|
|
+ languagesField.stringValue = profile.languages
|
|
|
|
|
+ referralField.stringValue = profile.referral
|
|
|
|
|
+
|
|
|
|
|
+ let workCount = max(1, profile.workExperiences.count)
|
|
|
|
|
+ syncWorkExperienceRowCount(to: workCount)
|
|
|
|
|
+ if profile.workExperiences.isEmpty {
|
|
|
|
|
+ workExperienceEntries[0].applyPayload(.empty)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ for (i, payload) in profile.workExperiences.enumerated() where i < workExperienceEntries.count {
|
|
|
|
|
+ workExperienceEntries[i].applyPayload(payload)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let eduCount = max(1, profile.educations.count)
|
|
|
|
|
+ syncEducationRowCount(to: eduCount)
|
|
|
|
|
+ if profile.educations.isEmpty {
|
|
|
|
|
+ educationEntries[0].applyPayload(.empty)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ for (i, payload) in profile.educations.enumerated() where i < educationEntries.count {
|
|
|
|
|
+ educationEntries[i].applyPayload(payload)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lastCompactLayout = nil
|
|
|
|
|
+ needsLayout = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func syncWorkExperienceRowCount(to target: Int) {
|
|
|
|
|
+ let n = max(1, target)
|
|
|
|
|
+ while workExperienceEntries.count < n {
|
|
|
|
|
+ appendWorkExperienceEntry()
|
|
|
|
|
+ }
|
|
|
|
|
+ while workExperienceEntries.count > n {
|
|
|
|
|
+ guard let last = workExperienceEntries.last, workExperienceEntries.count > 1 else { break }
|
|
|
|
|
+ removeWorkExperienceEntry(last)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func syncEducationRowCount(to target: Int) {
|
|
|
|
|
+ let n = max(1, target)
|
|
|
|
|
+ while educationEntries.count < n {
|
|
|
|
|
+ appendEducationEntry()
|
|
|
|
|
+ }
|
|
|
|
|
+ while educationEntries.count > n {
|
|
|
|
|
+ guard let last = educationEntries.last, educationEntries.count > 1 else { break }
|
|
|
|
|
+ removeEducationEntry(last)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func captureSavedProfileForSave() -> SavedProfile {
|
|
|
|
|
+ let id = editingProfileID ?? UUID()
|
|
|
|
|
+ return SavedProfile(
|
|
|
|
|
+ id: id,
|
|
|
|
|
+ profileDisplayName: profileNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ personal: collectPersonalInformationFromFields(),
|
|
|
|
|
+ careerSummary: careerField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ workExperiences: workExperienceEntries.map { $0.capturePayload() },
|
|
|
|
|
+ educations: educationEntries.map { $0.capturePayload() },
|
|
|
|
|
+ certificates: certificatesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ interests: interestsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ languages: languagesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ referral: referralField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func didTapBack() {
|
|
|
|
|
+ onDismiss?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyPersonalInformation(_ info: PersonalInformation) {
|
|
|
|
|
+ fullNameField.stringValue = info.fullName
|
|
|
|
|
+ emailField.stringValue = info.email
|
|
|
|
|
+ phoneField.stringValue = info.phone
|
|
|
|
|
+ jobTitleField.stringValue = info.jobTitle
|
|
|
|
|
+ addressField.stringValue = info.address
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func collectPersonalInformationFromFields() -> PersonalInformation {
|
|
|
|
|
+ PersonalInformation(
|
|
|
|
|
+ fullName: fullNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ email: emailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ phone: phoneField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ address: addressField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func applyResponsiveRowsIfNeeded() {
|
|
private func applyResponsiveRowsIfNeeded() {
|
|
|
let w = cardView.bounds.width
|
|
let w = cardView.bounds.width
|
|
|
guard w > 1 else { return }
|
|
guard w > 1 else { return }
|
|
@@ -881,7 +1044,29 @@ final class MyProfilePageView: NSView {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func didTapSave() {
|
|
@objc private func didTapSave() {
|
|
|
- // UI shell only; wire persistence when profiles are stored.
|
|
|
|
|
|
|
+ window?.makeFirstResponder(nil)
|
|
|
|
|
+ let profile = captureSavedProfileForSave()
|
|
|
|
|
+ var missing: [String] = []
|
|
|
|
|
+ if profile.profileDisplayName.isEmpty { missing.append("Profile name") }
|
|
|
|
|
+ if profile.personal.fullName.isEmpty { missing.append("Full Name") }
|
|
|
|
|
+ if profile.personal.email.isEmpty { missing.append("Email") }
|
|
|
|
|
+ if profile.personal.jobTitle.isEmpty { missing.append("Job Title") }
|
|
|
|
|
+ guard missing.isEmpty else {
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = "Complete required fields"
|
|
|
|
|
+ alert.informativeText = "Please fill in: " + missing.joined(separator: ", ") + "."
|
|
|
|
|
+ alert.alertStyle = .informational
|
|
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
|
|
+ if let window = window {
|
|
|
|
|
+ alert.beginSheetModal(for: window) { _ in }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ alert.runModal()
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ SavedProfilesStore.upsert(profile)
|
|
|
|
|
+ editingProfileID = profile.id
|
|
|
|
|
+ onDismiss?()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -924,6 +1109,22 @@ private final class WorkExperienceEntryView: NSView {
|
|
|
jobCompanyRow.setCompact(compact)
|
|
jobCompanyRow.setCompact(compact)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func capturePayload() -> WorkExperiencePayload {
|
|
|
|
|
+ WorkExperiencePayload(
|
|
|
|
|
+ jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ company: companyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ duration: durationField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ description: descriptionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func applyPayload(_ payload: WorkExperiencePayload) {
|
|
|
|
|
+ jobTitleField.stringValue = payload.jobTitle
|
|
|
|
|
+ companyField.stringValue = payload.company
|
|
|
|
|
+ durationField.stringValue = payload.duration
|
|
|
|
|
+ descriptionField.stringValue = payload.description
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func configure() {
|
|
private func configure() {
|
|
|
userInterfaceLayoutDirection = .leftToRight
|
|
userInterfaceLayoutDirection = .leftToRight
|
|
|
ProfileLayoutEnforcement.applyForcedLTR(to: self)
|
|
ProfileLayoutEnforcement.applyForcedLTR(to: self)
|
|
@@ -1192,6 +1393,20 @@ private final class EducationEntryView: NSView {
|
|
|
degreeInstitutionRow.setCompact(compact)
|
|
degreeInstitutionRow.setCompact(compact)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func capturePayload() -> EducationPayload {
|
|
|
|
|
+ EducationPayload(
|
|
|
|
|
+ degree: degreeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ institution: institutionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ year: yearField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func applyPayload(_ payload: EducationPayload) {
|
|
|
|
|
+ degreeField.stringValue = payload.degree
|
|
|
|
|
+ institutionField.stringValue = payload.institution
|
|
|
|
|
+ yearField.stringValue = payload.year
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func configure() {
|
|
private func configure() {
|
|
|
userInterfaceLayoutDirection = .leftToRight
|
|
userInterfaceLayoutDirection = .leftToRight
|
|
|
ProfileLayoutEnforcement.applyForcedLTR(to: self)
|
|
ProfileLayoutEnforcement.applyForcedLTR(to: self)
|