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
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ public enum UserMode {
case registered
}

public enum UserRole: String, Hashable {
case volunteer
case moderator
case admin
}

public protocol UserProfileServiceHolder {
var profileService: UserProfileServiceProtocol { get }
}

public protocol UserProfileValidationModel {
var isSignedIn: Bool { get }
var userMode: UserMode? { get }
var roles: Set<UserRole> { get }
var validated: Bool { get }
var phoneNumberVerified: Bool { get }
var emailVerified: Bool { get }
Expand All @@ -28,7 +35,13 @@ public protocol UserModeObservable: AnyObject {
var userModePublisher: AnyPublisher<UserMode?, Never> { get }
}

public protocol UserProfileServiceProtocol: UserModeObservable {
/// Protocol for services that provide observable user role changes
public protocol UserRoleObservable: AnyObject {
/// Publisher that emits user role changes
var userRolePublisher: AnyPublisher<Set<UserRole>, Never> { get }
}

public protocol UserProfileServiceProtocol: UserModeObservable, UserRoleObservable {
/// Returns the currently logged in user.
///
func getCurrentUser() async -> UserCurrentProfile?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ open class ButtonView: UIView {

// MARK: - Private properties
public let contentView: UIButton
private let height: CGFloat

// MARK: - Public properties
public var identifier: String
public var onTap: ((String) -> Void)?

// MARK: - Initialization
public init(contentView: UIButton) {
public init(contentView: UIButton, height: CGFloat = 60.0) {
self.contentView = contentView
self.identifier = UUID().uuidString
self.height = height
super.init(frame: CGRect.zero)
setup()
}
Expand All @@ -68,7 +70,7 @@ open class ButtonView: UIView {
contentView.topAnchor.constraint(equalTo: topAnchor).isActive = true
contentView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
contentView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
contentView.heightAnchor.constraint(equalToConstant: Constants.height).isActive = true
contentView.heightAnchor.constraint(equalToConstant: height).isActive = true

contentView.addTarget(
self,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UIKit
import Style

public protocol ButtonViewGenerating {
func makeSignInWithAppleButton() -> ButtonView
Expand Down Expand Up @@ -146,4 +147,25 @@
public func makeTextButton() -> ButtonView {
TextButtonView(contentView: UIButton(type: .system))
}

Check warning on line 150 in UIComponents/Sources/UIComponents/Components/Buttons/ButtonViewFactory.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
public func makeArrowButton() -> ButtonView {
let button = UIButton().apply(style: .arrowIcon)
return ButtonView(contentView: button, height: 16)
}
}

private extension Style where Component == UIButton {
static var arrowIcon: Style<UIButton> {
.init { button in
let designEngine = button.designEngine

button.setTitle(nil, for: .normal)
button.backgroundColor = .clear
button.tintColor = designEngine.colors.textPrimary
button.imageView?.contentMode = .scaleAspectFit

let font = designEngine.fonts.primary.bold(16) ?? .systemFont(ofSize: 16, weight: .bold)
button.setPreferredSymbolConfiguration(.init(font: font), forImageIn: .normal)
}
}
}
4 changes: 4 additions & 0 deletions animeal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@
C21173D3F00EA2227C126EF6 /* QuestionI18n+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353541A22B004F10D26B741A /* QuestionI18n+Schema.swift */; };
C2744A856776544005086938 /* FeedingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FD88DC75911C04106149D6 /* FeedingStatus.swift */; };
C539E64E7DD90F921CB32D61 /* UserAttribute+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE3D1C1303F2703ACF8DF4E /* UserAttribute+Schema.swift */; };
C7C990982F1850D500286979 /* ModeratorsShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C990972F1850D500286979 /* ModeratorsShimmerView.swift */; };
CAAFF8418A0419B14F7461B1 /* RelationPetFeedingPoint+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E8D4947DDBC6278B003F58 /* RelationPetFeedingPoint+Schema.swift */; };
CBC2C7CF54215BF6AF61524E /* FeedingPointStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761104F96C616340746249C7 /* FeedingPointStatus.swift */; };
CEE0B500A76F27824D7AD4E8 /* FeedingPointDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8F2323D475DB12AA76C105 /* FeedingPointDetails.swift */; };
Expand Down Expand Up @@ -678,6 +679,7 @@
C189FE7FA4A378D3B032D1DB /* FeedingHistory.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; name = FeedingHistory.swift; path = amplify/generated/models/FeedingHistory.swift; sourceTree = "<group>"; };
C45B7650A014076E116C8BD5 /* Category.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; name = Category.swift; path = amplify/generated/models/Category.swift; sourceTree = "<group>"; };
C5A3F3180005C29FF765CAC8 /* FeedingPoint+Schema.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; name = "FeedingPoint+Schema.swift"; path = "amplify/generated/models/FeedingPoint+Schema.swift"; sourceTree = "<group>"; };
C7C990972F1850D500286979 /* ModeratorsShimmerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorsShimmerView.swift; sourceTree = "<group>"; };
D09306992965933E00BABB17 /* AttachPhotoAssembler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachPhotoAssembler.swift; sourceTree = "<group>"; };
D093069B2965934400BABB17 /* AttachPhotoContract.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachPhotoContract.swift; sourceTree = "<group>"; };
D093069D2965934900BABB17 /* AttachPhotoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachPhotoViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1079,6 +1081,7 @@
children = (
807874D129D579510005D669 /* FeederShimmerView.swift */,
807874D329D57E790005D669 /* FeedingHistoryShimmerView.swift */,
C7C990972F1850D500286979 /* ModeratorsShimmerView.swift */,
);
path = Shimmers;
sourceTree = "<group>";
Expand Down Expand Up @@ -2987,6 +2990,7 @@
F0C51AC9C71D7585539B42A1 /* Question+Schema.swift in Sources */,
A8C82F5EA242B4E574055D0F /* Question.swift in Sources */,
BB4A5BFB84887A576BBEA62D /* QuestionI18n+Schema.swift in Sources */,
C7C990982F1850D500286979 /* ModeratorsShimmerView.swift in Sources */,
4AA077256022E92280815D22 /* QuestionI18n.swift in Sources */,
1F770667D42E31D263AE67AB /* RelationPetFeedingPoint+Schema.swift in Sources */,
B8A233D7B15B4A46582895E9 /* RelationPetFeedingPoint.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions animeal/res/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@
"action.termsAndConditions" = "Terms & Conditions";
"action.privacyPolicy" = "Privacy Policy";
"action.acknowledgeTCandPP" = "By continuing, you agree to Animeal's Terms & Conditions and Privacy Policy";
"text.header.assignedModerators" = "Assigned Moderators";
1 change: 1 addition & 0 deletions animeal/res/ka.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@
"action.termsAndConditions" = "";
"action.privacyPolicy" = "";
"action.acknowledgeTCandPP" = "";
"text.header.assignedModerators" = "დანიშნული მოდერატორები";
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ final class UserProfileService: UserProfileServiceProtocol {
var userModePublisher: AnyPublisher<UserMode?, Never> {
userValidationModel.userModePublisher
}


var userRolePublisher: AnyPublisher<Set<UserRole>, Never> {
userValidationModel.userRolePublisher
}

// MARK: - Main methods
func getCurrentUser() async -> UserCurrentProfile? {
guard let user = try? await Amplify.Auth.getCurrentUser() else {
Expand Down
52 changes: 48 additions & 4 deletions animeal/src/Business/Services/Profile/UserValidationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,33 @@ final class UserValidationModel: UserProfileValidationModel {
// MARK: - Private Properties
private var listeners = [AuthChannelEventsListener]()
private let userModeSubject = CurrentValueSubject<UserMode?, Never>(nil)

private let userRoleSubject = CurrentValueSubject<Set<UserRole>, Never>([])

// MARK: - Accessible properties
private(set) var isSignedIn = false
private(set) var userMode: UserMode? {
didSet {
userModeSubject.send(userMode)
}
}
private(set) var roles: Set<UserRole> = [] {
didSet {
userRoleSubject.send(roles)
}
}
private(set) var phoneNumberVerified = false
private(set) var emailVerified = false
private(set) var areAllNecessaryFieldsFilled = false

// MARK: - Publishers
var userModePublisher: AnyPublisher<UserMode?, Never> {
userModeSubject.eraseToAnyPublisher()
}


var userRolePublisher: AnyPublisher<Set<UserRole>, Never> {
userRoleSubject.eraseToAnyPublisher()
}

// MARK: - Initialization
init() {
listenAuthChannelMessages()
Expand Down Expand Up @@ -68,6 +78,7 @@ final class UserValidationModel: UserProfileValidationModel {
phoneNumberVerified = false
emailVerified = false
areAllNecessaryFieldsFilled = false
roles = []
}
}

Expand All @@ -89,15 +100,22 @@ private extension UserValidationModel {
self.emailVerified = false
self.phoneNumberVerified = false
self.isSignedIn = false
self.roles = []
case HubPayload.EventName.Auth.fetchUserAttributesAPI:
logInfo("[App] \(#function) Auth.fetchUserAttributesAPI event occurred in AUTH channel")
case HubPayload.EventName.Auth.sessionExpired:
logInfo("[App] \(#function) Auth.sessionExpired event occurred in AUTH channel")
self.isSignedIn = false
self.roles = []
self.handleSessionExpiredEvent()
case HubPayload.EventName.Auth.fetchSessionAPI:
logInfo("[App] \(#function) Auth.fetchSessionAPI event occurred in AUTH channel")
self.isSignedIn = self.checkIfUserSignedIn(payload.data)
if !self.isSignedIn {
self.roles = []
} else {
self.updateRolesFromSessionData(payload.data)
}
default:
break
}
Expand All @@ -118,10 +136,36 @@ private extension UserValidationModel {
}
return false
}

func handleSessionExpiredEvent() {
listeners.forEach { listener in
listener.listenAuthChannelEvents(event: .sessionExpired)
}
}
}

private extension UserValidationModel {
/// Updates user roles from Cognito group claims (`cognito:groups`) in the ID token using the current auth session.
func updateRolesFromSessionData(_ data: Any?) {
Comment thread
steryokhin marked this conversation as resolved.
guard let event = data as? Result<AuthSession, AuthError>,
case let .success(session) = event,
let tokens = try? (session as? AuthCognitoTokensProvider)?.getCognitoTokens().get(),
let claims = try? AWSAuthService().getTokenClaims(tokenString: tokens.idToken).get()
else {
roles = []
return
}

let groups = (claims["cognito:groups"] as? [String]) ?? []
let normalized = Set(groups.map { $0.lowercased() })

var newRoles = Set<UserRole>()
if normalized.contains("administrator") { newRoles.insert(.admin) }
if normalized.contains("moderator") { newRoles.insert(.moderator) }
if normalized.contains("volunteer") { newRoles.insert(.volunteer) }

if roles != newRoles {
roles = newRoles
}
}
}
2 changes: 2 additions & 0 deletions animeal/src/Common/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ internal enum L10n {
/// Thank You!
internal static let thankYou = L10n.tr("Localizable", "text.thankYou", fallback: "Thank You!")
internal enum Header {
/// Assigned Moderators
internal static let assignedModerators = L10n.tr("Localizable", "text.header.assignedModerators", fallback: "Assigned Moderators")
/// Last feeder
internal static let lastFeeder = L10n.tr("Localizable", "text.header.lastFeeder", fallback: "Last feeder")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,21 @@ protocol FeedingPointDetailsViewState: AnyObject {
var onContentHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointDetailsViewItem) -> Void)? { get set }
var onFeedingHistoryHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointFeeders) -> Void)? { get set }
var onMediaContentHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointMediaContent) -> Void)? { get set }
var onModeratorsHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointModerators) -> Void)? { get set }
var onFavoriteMutationFailed: (() -> Void)? { get set }
var onFavoriteMutation: (() -> Void)? { get set }
var showOnMapAction: ButtonView.Model? { get }
var shimmerScheduler: ShimmerViewScheduler { get }
var historyInitialized: Bool { get }
var moderatorsInitialized: Bool { get }
}

// MARK: - Model

// sourcery: AutoMockable
protocol FeedingPointDetailsModelProtocol: AnyObject {
var onFeedingPointChange: ((FeedingPointDetailsModel.PointContent, Bool) -> Void)? { get set }
var onModeratorsChange: (([FeedingPointDetailsModel.Moderator]) -> Void)? { get set }

func fetchFeedingPoint(_ completion: ((FeedingPointDetailsModel.PointContent) -> Void)?)
func fetchFeedingHistory(_ completion: (([FeedingPointDetailsModel.Feeder]) -> Void)?)
Expand Down Expand Up @@ -76,4 +79,5 @@ enum FeedingPointEvent {
case tapFavorite
case tapShowOnMap
case tapCancelLocationRequest
case tapShowMoreModerators
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ final class FeedingPointDetailsViewModel: FeedingPointDetailsViewModelLifeCycle,
var onContentHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointDetailsViewItem) -> Void)?
var onFeedingHistoryHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointFeeders) -> Void)?
var onMediaContentHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointMediaContent) -> Void)?
var onModeratorsHaveBeenPrepared: ((FeedingPointDetailsViewMapper.FeedingPointModerators) -> Void)?
var onFavoriteMutationFailed: (() -> Void)?
var onFavoriteMutation: (() -> Void)?
var onRequestLocationAccess: (() -> Void)?
var historyInitialized = false
var moderatorsInitialized = false

// TODO: Move this strange logic to model
let isOverMap: Bool
Expand All @@ -35,7 +37,8 @@ final class FeedingPointDetailsViewModel: FeedingPointDetailsViewModelLifeCycle,
title: L10n.Action.showOnMap
)
}

private var allModerators: [FeedingPointDetailsModel.Moderator] = []
private var isModeratorsExpanded = false
let shimmerScheduler = ShimmerViewScheduler()

// MARK: - Initialization
Expand Down Expand Up @@ -69,6 +72,12 @@ final class FeedingPointDetailsViewModel: FeedingPointDetailsViewModelLifeCycle,
self?.updateFeedingHistoryContent(content)
}
}
model.onModeratorsChange = { [weak self] moderators in
DispatchQueue.main.async {
self?.moderatorsInitialized = true
self?.updateModeratorsContent(moderators)
}
}
model.onFeedingPointChange = { [weak self] content, mutateFavorites in
DispatchQueue.main.async {
if mutateFavorites {
Expand Down Expand Up @@ -96,6 +105,35 @@ final class FeedingPointDetailsViewModel: FeedingPointDetailsViewModelLifeCycle,
loadMediaContent(modelContent.content.header.cover)
onContentHaveBeenPrepared?(contentMapper.mapFeedingPoint(modelContent))
}

private func updateModeratorsContent(_ moderators: [FeedingPointDetailsModel.Moderator]) {
guard !moderators.isEmpty else {
allModerators = []
Comment thread
steryokhin marked this conversation as resolved.
Comment thread
steryokhin marked this conversation as resolved.
isModeratorsExpanded = false
let mapped = contentMapper.mapModerators([], canShowMore: false, totalCount: 0)
onModeratorsHaveBeenPrepared?(mapped)
return
}
allModerators = moderators
let moderatorsToDisplay: [FeedingPointDetailsModel.Moderator]
let canShowMore: Bool

if isModeratorsExpanded {
let limit = ModeratorDisplayConstants.expandedLimit
moderatorsToDisplay = Array(moderators.prefix(limit))
canShowMore = false
} else {
if allModerators.count > ModeratorDisplayConstants.collapsedThreshold {
moderatorsToDisplay = []
canShowMore = true
} else {
moderatorsToDisplay = moderators
canShowMore = false
}
}
let mapped = contentMapper.mapModerators(moderatorsToDisplay, canShowMore: canShowMore, totalCount: allModerators.count)
onModeratorsHaveBeenPrepared?(mapped)
}

private func updateFavorites() {
onFavoriteMutation?()
Expand Down Expand Up @@ -148,6 +186,17 @@ final class FeedingPointDetailsViewModel: FeedingPointDetailsViewModelLifeCycle,

case .tapCancelLocationRequest:
break

case .tapShowMoreModerators:
isModeratorsExpanded = true
updateModeratorsContent(allModerators)
}
}
}

private extension FeedingPointDetailsViewModel {
enum ModeratorDisplayConstants {
static let collapsedThreshold = 5 /// show "Show more" if total > 5
static let expandedLimit = 10 /// max shown after expand
}
}
Loading
Loading