diff --git a/iosApp/Utils/EventDetail+Identifiable.swift b/iosApp/Utils/EventDetail+Identifiable.swift index 5ad0860..5553c7d 100644 --- a/iosApp/Utils/EventDetail+Identifiable.swift +++ b/iosApp/Utils/EventDetail+Identifiable.swift @@ -7,4 +7,4 @@ import Shared -extension EventDetail: @retroactive Identifiable {} +extension Event: @retroactive Identifiable {} diff --git a/iosApp/iosApp/DesignKit/DesignTheme.swift b/iosApp/iosApp/DesignKit/DesignTheme.swift index 64da323..4d53541 100644 --- a/iosApp/iosApp/DesignKit/DesignTheme.swift +++ b/iosApp/iosApp/DesignKit/DesignTheme.swift @@ -43,6 +43,357 @@ enum DesignTheme { } } +// MARK: - Button Factory + +/// Factory for creating consistent, clickable buttons throughout the app. +/// Each factory method returns a complete Button view with proper hit area targeting. +enum ButtonFactory { + /// Primary action button with full width, blue capsule background. + /// The entire button area is clickable (54pt height). + /// + /// Usage: + /// ``` + /// ButtonFactory.primary( + /// action: { viewModel.login() }, + /// label: "Login", + /// isEnabled: !formEmpty + /// ) + /// .padding(.horizontal, DesignTheme.Spacing.xl) + /// ``` + @ViewBuilder + static func primary( + action: @escaping () -> Void, + label: String, + isEnabled: Bool = true + ) -> some View { + Button(action: action) { + Text(label) + .font(DesignTheme.Typography.button) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(DesignTheme.accentColor, in: .capsule) + } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.3) + } + + /// Primary action button with loading indicator. + /// Shows a spinner and "Loading..." text while isLoading is true. + /// The entire button area is clickable (54pt height). + /// + /// Usage: + /// ``` + /// ButtonFactory.primaryLoading( + /// action: { reducer.login() }, + /// label: "Login", + /// isLoading: reducer.isLoading, + /// isEnabled: !formEmpty + /// ) + /// ``` + @ViewBuilder + static func primaryLoading( + action: @escaping () -> Void, + label: String, + isLoading: Bool = false, + isEnabled: Bool = true + ) -> some View { + Button(action: action) { + primaryContent(label: label, isLoading: isLoading) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(DesignTheme.accentColor, in: .capsule) + } + .disabled(!isEnabled || isLoading) + .opacity(isEnabled ? 1 : 0.3) + } + + @ViewBuilder + private static func primaryContent(label: String, isLoading: Bool) -> some View { + if isLoading { + HStack(spacing: DesignTheme.Spacing.sm) { + ProgressView() + .scaleEffect(0.9) + .tint(.white) + Text("Loading...") + .font(DesignTheme.Typography.button) + .foregroundColor(.white) + } + } else { + Text(label) + .font(DesignTheme.Typography.button) + .foregroundColor(.white) + } + } + + /// Secondary action button with error/red color, light background. + /// Typically used for destructive or decline actions. + /// The entire button area is clickable (54pt height). + /// + /// Usage: + /// ``` + /// ButtonFactory.secondary( + /// action: { reducer.reject() }, + /// label: "Decline", + /// isEnabled: !isLoading + /// ) + /// ``` + @ViewBuilder + static func secondary( + action: @escaping () -> Void, + label: String, + isEnabled: Bool = true + ) -> some View { + Button(action: action) { + Text(label) + .font(DesignTheme.Typography.button) + .foregroundColor(DesignTheme.error) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(DesignTheme.errorLight, in: .capsule) + } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.3) + } + + /// Compact button for inline use (smaller height, no full width). + /// Useful for secondary actions or buttons within HStacks. + /// The entire button area is clickable (44pt height). + /// + /// Usage: + /// ``` + /// HStack { + /// ButtonFactory.compact( + /// action: { dismiss() }, + /// label: "Cancel" + /// ) + /// ButtonFactory.compact( + /// action: { save() }, + /// label: "Save" + /// ) + /// } + /// ``` + @ViewBuilder + static func compact( + action: @escaping () -> Void, + label: String, + isEnabled: Bool = true + ) -> some View { + Button(action: action) { + Text(label) + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(DesignTheme.accentColor) + .padding(.horizontal, DesignTheme.Spacing.md) + .frame(height: 44) + .background(Color(UIColor.systemGray6), in: .capsule) + } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.5) + } + + /// Disabled status button (e.g., "Request Sent", read-only). + /// Shows with a gray background and an optional icon. + /// Not clickable; used to display state information. + /// + /// Usage: + /// ``` + /// ButtonFactory.disabled( + /// label: "Request Sent", + /// icon: "checkmark.circle.fill" + /// ) + /// ``` + @ViewBuilder + static func disabled( + label: String, + icon: String? = nil + ) -> some View { + HStack(spacing: DesignTheme.Spacing.sm) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 16)) + } + Text(label) + .font(DesignTheme.Typography.button) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(Color.gray.opacity(0.6), in: .capsule) + } + + /// Destructive action button with red background. + /// Used for permanent actions like "Remove Friend", "Delete". + /// + /// Usage: + /// ``` + /// ButtonFactory.destructive( + /// action: { reducer.removeFriend() }, + /// label: "Remove Friend", + /// isLoading: isLoading + /// ) + /// ``` + @ViewBuilder + static func destructive( + action: @escaping () -> Void, + label: String, + isLoading: Bool = false + ) -> some View { + Button(action: action) { + content(isLoading: isLoading, label: label) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(DesignTheme.error, in: .capsule) + } + .disabled(isLoading) + } + + @ViewBuilder + private static func content(isLoading: Bool, label: String) -> some View { + if isLoading { + HStack(spacing: DesignTheme.Spacing.sm) { + ProgressView() + .scaleEffect(0.9) + .tint(.white) + Text("Removing...") + .font(DesignTheme.Typography.button) + .foregroundColor(.white) + } + } else { + Text(label) + .font(DesignTheme.Typography.button) + .foregroundColor(.white) + } + } +} + +// MARK: - Indicator Factory + +/// Factory for creating consistent status indicators and badges throughout the app. +enum IndicatorFactory { + /// Green checkmark badge with "Active" label. + /// Used to indicate active friendship status. + @ViewBuilder + static func active() -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(DesignTheme.secondaryAccent) + Text("Active") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(DesignTheme.secondaryAccent) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(DesignTheme.secondaryAccent.opacity(0.1)) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + /// Orange clock badge with "Pending" label. + /// Used to indicate pending friend request from current user's perspective. + @ViewBuilder + static func pending() -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "clock.fill") + .font(.system(size: 16)) + .foregroundColor(Color.orange) + Text("Pending") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(.white) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(Color.orange) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + /// Green checkmark badge with "Accepted" label. + /// Used to indicate accepted friend request. + @ViewBuilder + static func accepted() -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(DesignTheme.secondaryAccent) + Text("Accepted") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(DesignTheme.secondaryAccent) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(DesignTheme.secondaryAccent.opacity(0.1)) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + /// Red X badge with "Declined" label. + /// Used to indicate declined/rejected friend request. + @ViewBuilder + static func declined() -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(DesignTheme.error) + Text("Declined") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(DesignTheme.error) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(DesignTheme.errorLight) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + /// Gray badge with "Sent" label. + /// Used to indicate outgoing friend request (sent by user). + @ViewBuilder + static func sent() -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "paperplane.fill") + .font(.system(size: 16)) + .foregroundColor(.gray) + Text("Sent") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(.gray) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(Color(.systemGray6)) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + /// Generic status indicator with custom label, color, and icon. + /// Use this for status badges that don't fit the predefined patterns. + /// + /// Usage: + /// ``` + /// IndicatorFactory.status( + /// label: "Custom Status", + /// color: Color.purple, + /// icon: "star.fill" + /// ) + /// ``` + @ViewBuilder + static func status( + label: String, + color: Color, + icon: String + ) -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(color) + Text(label) + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(color) + } + .padding(.horizontal, DesignTheme.Spacing.sm) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(color.opacity(0.1)) + .cornerRadius(DesignTheme.CornerRadius.small) + } +} + +// MARK: - Form Field Modifiers + struct FormTextField: ViewModifier { func body(content: Content) -> some View { content @@ -65,6 +416,11 @@ struct FormSecureField: ViewModifier { } } +// MARK: - DEPRECATED: PrimaryButton Modifier + +/// DEPRECATED: Use ButtonFactory.primary() instead. +/// This modifier had limited clickability (only text area was clickable). +/// The factory methods properly handle full-area click targets. struct PrimaryButton: ViewModifier { let isLoading: Bool let isEnabled: Bool @@ -80,6 +436,8 @@ struct PrimaryButton: ViewModifier { } } +// MARK: - View Extensions + extension View { func formTextField() -> some View { modifier(FormTextField()) @@ -89,6 +447,21 @@ extension View { modifier(FormSecureField()) } + /// DEPRECATED: Use ButtonFactory instead for proper clickable buttons. + /// Example migration: + /// ``` + /// // Old (deprecated): + /// Button { ... } + /// .primaryButton(isLoading: isLoading, isEnabled: isEnabled) + /// + /// // New (recommended): + /// ButtonFactory.primaryLoading( + /// action: { ... }, + /// label: "Login", + /// isLoading: isLoading, + /// isEnabled: isEnabled + /// ) + /// ``` func primaryButton(isLoading: Bool = false, isEnabled: Bool = true) -> some View { modifier(PrimaryButton(isLoading: isLoading, isEnabled: isEnabled)) } diff --git a/iosApp/iosApp/DesignKit/ErrorBanner.swift b/iosApp/iosApp/DesignKit/ErrorBanner.swift index 6968430..6045d98 100644 --- a/iosApp/iosApp/DesignKit/ErrorBanner.swift +++ b/iosApp/iosApp/DesignKit/ErrorBanner.swift @@ -9,19 +9,22 @@ import SwiftUI struct ErrorBanner: View { let message: String - + var body: some View { - HStack(spacing: 8) { + HStack(spacing: DesignTheme.Spacing.sm) { Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.white) - + .foregroundColor(DesignTheme.error) + .font(.system(size: 16)) + Text(message) - .font(.subheadline) - .foregroundColor(.white) - + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(DesignTheme.error) + Spacer() } - .padding(12) - .background(Color.red.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(DesignTheme.Spacing.md) + .background(DesignTheme.errorLight) + .cornerRadius(DesignTheme.CornerRadius.small) } } diff --git a/iosApp/iosApp/DesignKit/SearchBar.swift b/iosApp/iosApp/DesignKit/SearchBar.swift index 7ed5b86..cb3b912 100644 --- a/iosApp/iosApp/DesignKit/SearchBar.swift +++ b/iosApp/iosApp/DesignKit/SearchBar.swift @@ -11,15 +11,17 @@ struct SearchBar: View { @Binding var text: String var onSearch: (String) -> Void var onClear: () -> Void - + var body: some View { - HStack(spacing: 8) { + HStack(spacing: DesignTheme.Spacing.sm) { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) - + .font(.system(size: 16)) + TextField("Search users", text: $text) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .font(DesignTheme.Typography.body) .onChange(of: text) { oldValue, newValue in if newValue.isEmpty { onClear() @@ -27,7 +29,7 @@ struct SearchBar: View { onSearch(newValue) } } - + if !text.isEmpty { Button(action: { text = "" @@ -35,11 +37,12 @@ struct SearchBar: View { }) { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) + .font(.system(size: 16)) } } } - .padding(8) + .padding(DesignTheme.Spacing.md) .background(Color(.systemGray6)) - .cornerRadius(8) + .cornerRadius(DesignTheme.CornerRadius.medium) } } diff --git a/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift b/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift index e3296b0..edb91fa 100644 --- a/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift +++ b/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift @@ -11,7 +11,7 @@ import Shared @Observable final class ArchiveEventsReducer { - var archivedEvents: [MainEvent] = [] + var archivedEvents: [Event] = [] var isRefreshing: Bool = false var isLoading: Bool = false var errorMessage: String? diff --git a/iosApp/iosApp/Modules/Archive/ArchiveView.swift b/iosApp/iosApp/Modules/Archive/ArchiveView.swift index 8ada31f..0f22a03 100644 --- a/iosApp/iosApp/Modules/Archive/ArchiveView.swift +++ b/iosApp/iosApp/Modules/Archive/ArchiveView.swift @@ -19,22 +19,19 @@ struct ArchiveView: View { ProgressView() .frame(maxHeight: .infinity, alignment: .center) } else if let errorMessage = reducer.errorMessage { - VStack(spacing: 12) { + VStack(spacing: DesignTheme.Spacing.md) { Image(systemName: "exclamationmark.circle.fill") .font(.largeTitle) - .foregroundColor(.red) + .foregroundColor(DesignTheme.error) Text(errorMessage) - .foregroundColor(.red) + .foregroundColor(DesignTheme.error) .multilineTextAlignment(.center) - Button(action: { - reducer.refresh() - }) { - Text("Retry") - .font(.headline) - } - .buttonStyle(.bordered) + ButtonFactory.primary( + action: { reducer.refresh() }, + label: "Retry" + ) } - .padding() + .padding(DesignTheme.Spacing.lg) .frame(maxHeight: .infinity, alignment: .center) } else if reducer.archivedEvents.isEmpty { VStack(spacing: 12) { diff --git a/iosApp/iosApp/Modules/Events/CreateEvent/CreateEventView.swift b/iosApp/iosApp/Modules/Events/CreateEvent/CreateEventView.swift index 4272901..9a99f18 100644 --- a/iosApp/iosApp/Modules/Events/CreateEvent/CreateEventView.swift +++ b/iosApp/iosApp/Modules/Events/CreateEvent/CreateEventView.swift @@ -9,68 +9,57 @@ import SwiftUI import Shared struct CreateEventView: View { - + @State var reducer: CreateEventReducer @Environment(Router.self) private var router - + init(date: String) { self.reducer = CreateEventReducer(dateString: date) } - + var body: some View { - VStack { - Text(reducer.selectedDate) - - if reducer.isLoadingFriends { - ProgressView() - } - - if let errorMessage = reducer.errorMessage { - Text(errorMessage) - .foregroundStyle(.red) - } - - ScrollView { - VStack { - TextField("Title", text: Binding( - get: { reducer.title }, - set: { reducer.updateTitle($0) } - )) - - TextField("Description", text: Binding( - get: { reducer.description }, - set: { reducer.updateDescription($0) } - )) - - TextField("Location", text: Binding( - get: { reducer.location }, - set: { reducer.updateLocation($0) } - )) - - Button("Select Friends") { - reducer.toggleFriendsSheet() - } - - if !reducer.selectedFriendIds.isEmpty { - VStack(alignment: .leading) { - Text("Selected Friends: \(reducer.selectedFriendIds.count)") - List(reducer.availableFriends.filter { reducer.selectedFriendIds.contains($0.id) }, id: \.id) { friend in - Text(friend.username) - } + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + VStack(spacing: DesignTheme.Spacing.lg) { + dateHeaderSection + + if reducer.isLoadingFriends { + loadingSection + } else { + ScrollView { + VStack(spacing: DesignTheme.Spacing.lg) { + formFieldsSection + friendsSelectionSection } + .padding(.horizontal, DesignTheme.Spacing.lg) + .padding(.bottom, DesignTheme.Spacing.xl) } - - Button("Create") { - reducer.submit() - } - .disabled(!reducer.isCreateButtonEnabled) + + createButtonSection + } + + if reducer.isCreatingEvent { + Spacer() + ProgressView() + .scaleEffect(1.2) + Spacer() } } - - if reducer.isCreatingEvent { - ProgressView() + .padding(.horizontal, DesignTheme.Spacing.lg) + .padding(.vertical, DesignTheme.Spacing.lg) + + if let errorMessage = reducer.errorMessage { + VStack { + FormErrorMessage(message: errorMessage) + .padding(DesignTheme.Spacing.lg) + Spacer() + } } } + .navigationTitle("Create Event") + .navigationBarTitleDisplayMode(.inline) .task { reducer.onNavigateToEventDetail = { eventId in router.push(screen: .eventDetail(id: eventId)) @@ -80,39 +69,327 @@ struct CreateEventView: View { get: { reducer.showFriendsSheet }, set: { reducer.showFriendsSheet = $0 } )) { - VStack { + friendsSelectionSheet + } + } + + // MARK: - Header Section + + private var dateHeaderSection: some View { + VStack(spacing: DesignTheme.Spacing.sm) { + Text("Event Date") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + + Text(reducer.selectedDate) + .font(DesignTheme.Typography.heading) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, DesignTheme.Spacing.lg) + .background(Color(UIColor.systemGray6).opacity(0.5)) + .cornerRadius(DesignTheme.CornerRadius.medium) + } + + // MARK: - Loading Section + + private var loadingSection: some View { + VStack(spacing: DesignTheme.Spacing.md) { + ProgressView() + .scaleEffect(1.2) + Text("Loading available friends...") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Form Fields Section + + private var formFieldsSection: some View { + VStack(spacing: DesignTheme.Spacing.lg) { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { + Label("Title", systemImage: "pencil") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + + TextField("Event title", text: Binding( + get: { reducer.title }, + set: { reducer.updateTitle($0) } + )) + .formTextField() + } + + VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { + Label("Description", systemImage: "doc.text") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + + TextField("Event description", text: Binding( + get: { reducer.description }, + set: { reducer.updateDescription($0) } + )) + .formTextField() + } + + VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { + Label("Location", systemImage: "location.fill") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + + TextField("Event location", text: Binding( + get: { reducer.location }, + set: { reducer.updateLocation($0) } + )) + .formTextField() + } + } + } + + // MARK: - Friends Selection Section + + private var friendsSelectionSection: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + Button(action: { reducer.toggleFriendsSheet() }) { + HStack(spacing: DesignTheme.Spacing.md) { + Image(systemName: "person.2.fill") + .font(.system(size: 14, weight: .semibold)) + + Text("Select Friends") + .font(DesignTheme.Typography.button) + + Spacer() + + if !reducer.selectedFriendIds.isEmpty { + Text("\(reducer.selectedFriendIds.count) selected") + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(.white) + .padding(.horizontal, DesignTheme.Spacing.md) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(DesignTheme.accentColor) + .cornerRadius(DesignTheme.CornerRadius.small) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6)) + .cornerRadius(DesignTheme.CornerRadius.medium) + .foregroundColor(.primary) + } + + if !reducer.selectedFriendIds.isEmpty { + selectedFriendsBadgesSection + } + } + } + + private var selectedFriendsBadgesSection: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + Text("Selected Friends") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + + FlowLayout(spacing: DesignTheme.Spacing.sm) { + ForEach( + reducer.availableFriends.filter { reducer.selectedFriendIds.contains($0.id) }, + id: \.id + ) { friend in + friendBadge(for: friend) + } + } + } + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6).opacity(0.5)) + .cornerRadius(DesignTheme.CornerRadius.medium) + } + + private func friendBadge(for friend: User) -> some View { + HStack(spacing: DesignTheme.Spacing.xs) { + Text(friend.username) + .font(DesignTheme.Typography.bodySmallest) + + Button(action: { reducer.toggleFriend(friend.id) }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(.secondary) + } + .padding(.horizontal, DesignTheme.Spacing.md) + .padding(.vertical, DesignTheme.Spacing.xs) + .background(DesignTheme.accentColor.opacity(0.15)) + .cornerRadius(DesignTheme.CornerRadius.small) + } + + // MARK: - Create Button Section + + private var createButtonSection: some View { + ButtonFactory.primaryLoading( + action: { reducer.submit() }, + label: "Create Event", + isLoading: reducer.isCreatingEvent, + isEnabled: reducer.isCreateButtonEnabled + ) + .padding(.horizontal, DesignTheme.Spacing.lg) + } + + // MARK: - Friends Selection Sheet + + private var friendsSelectionSheet: some View { + NavigationStack { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + if reducer.isLoadingFriends { - ProgressView() + VStack(spacing: DesignTheme.Spacing.md) { + ProgressView() + .scaleEffect(1.2) + Text("Loading friends...") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + } } else if let error = reducer.friendsError { - Text(error) - .foregroundStyle(.red) + VStack(spacing: DesignTheme.Spacing.lg) { + FormErrorMessage(message: error) + Spacer() + } + .padding(DesignTheme.Spacing.lg) } else if reducer.availableFriends.isEmpty { - Text("No friends available on selected date") - } else { - List(reducer.availableFriends, id: \.id) { friend in - Button { - reducer.toggleFriend(friend.id) - } label: { - HStack { - Text(friend.username) - .background { - Color.blue - .opacity(reducer.selectedFriendIds.contains(friend.id) ? 1 : 0) - } - Spacer() - } - } + VStack(spacing: DesignTheme.Spacing.md) { + Image(systemName: "person.slash") + .font(.system(size: 48, weight: .light)) + .foregroundColor(.secondary) + + Text("No Friends Available") + .font(DesignTheme.Typography.button) + .foregroundColor(.primary) + + Text("No friends are available for this date") + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + friendsList } } .navigationTitle("Select Friends") + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { reducer.showFriendsSheet = false } + .font(DesignTheme.Typography.button) + .foregroundColor(DesignTheme.accentColor) + } + } + } + } + + private var friendsList: some View { + List(reducer.availableFriends, id: \.id) { friend in + friendListItem(for: friend) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets( + top: DesignTheme.Spacing.xs, + leading: DesignTheme.Spacing.lg, + bottom: DesignTheme.Spacing.xs, + trailing: DesignTheme.Spacing.lg + )) + } + .listStyle(.plain) + } + + private func friendListItem(for friend: User) -> some View { + Button(action: { reducer.toggleFriend(friend.id) }) { + HStack(spacing: DesignTheme.Spacing.md) { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { + Text(friend.username) + .font(DesignTheme.Typography.body) + .foregroundColor(.primary) + + if let bio = friend.bio, !bio.isEmpty { + Text(bio) + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.secondary) + .lineLimit(1) + } } + + Spacer() + + Image(systemName: reducer.selectedFriendIds.contains(friend.id) ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor( + reducer.selectedFriendIds.contains(friend.id) + ? DesignTheme.accentColor + : Color(.systemGray3) + ) } + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6).opacity(0.5)) + .cornerRadius(DesignTheme.CornerRadius.medium) + } + } +} + +// MARK: - FlowLayout Helper + +struct FlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let maxWidth = proposal.width ?? 0 + var height: CGFloat = 0 + var currentLineWidth: CGFloat = 0 + let lineHeight: CGFloat = 32 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if currentLineWidth + size.width + spacing > maxWidth && currentLineWidth > 0 { + height += lineHeight + spacing + currentLineWidth = 0 + } + + currentLineWidth += size.width + spacing + } + + height += lineHeight + return CGSize(width: maxWidth, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + var currentX: CGFloat = bounds.minX + var currentY: CGFloat = bounds.minY + let maxWidth = bounds.width + let lineHeight: CGFloat = 32 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if currentX + size.width > bounds.maxX && currentX > bounds.minX { + currentY += lineHeight + spacing + currentX = bounds.minX + } + + subview.place( + at: CGPoint(x: currentX, y: currentY), + proposal: .unspecified + ) + + currentX += size.width + spacing } } } diff --git a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailReducer.swift b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailReducer.swift index 37c1026..a6a0cd3 100644 --- a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailReducer.swift +++ b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailReducer.swift @@ -42,15 +42,15 @@ final class EventDetailReducer { self.errorMessage = error.message self.isLoading = false case let content as EventDetailViewState.Content: - self.id = content.eventDetail.id - self.title = content.eventDetail.title - self.description = content.eventDetail.description_ - self.date = content.eventDetail.date - self.time = content.eventDetail.time - self.location = content.eventDetail.location - self.creatorId = content.eventDetail.creatorId - self.status = content.eventDetail.status - self.participants = content.eventDetail.participants + self.id = content.event.id + self.title = content.event.title + self.description = content.event.description_ + self.date = content.event.date + self.time = content.event.time + self.location = content.event.location + self.creatorId = content.event.creatorId + self.status = content.event.status + self.participants = content.event.participants self.isLoading = false self.errorMessage = nil default: diff --git a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift index 7008ee2..68aaea4 100644 --- a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift +++ b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift @@ -104,7 +104,6 @@ struct EventDetailView: View { .cornerRadius(DesignTheme.CornerRadius.medium) } - // Participants Section with Avatars VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { Text("Participants") .font(DesignTheme.Typography.button) @@ -134,6 +133,7 @@ struct EventDetailView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button { + router.onCreatedEventPushBack?() router.root() } label: { Image(systemName: "chevron.left") @@ -271,8 +271,7 @@ private struct ParticipantAvatarRow: View { .font(DesignTheme.Typography.button) .foregroundColor(.white) - // Status badge - statusBadgeIcon(participant.responseStatus) + statusBadgeIcon(participant.status.description()) .frame(width: 20, height: 20) .background(Circle().fill(Color.white)) .offset(x: 2, y: 2) @@ -290,12 +289,12 @@ private struct ParticipantAvatarRow: View { .foregroundColor(.secondary) Circle() - .fill(statusColor(participant.responseStatus)) + .fill(statusColor(participant.status.description())) .frame(width: 6, height: 6) - Text(participant.responseStatus.capitalized) + Text(participant.status.description().capitalized) .font(DesignTheme.Typography.bodySmallest) - .foregroundColor(statusColor(participant.responseStatus)) + .foregroundColor(statusColor(participant.status.description())) } } diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift index 178806c..89e0c4e 100644 --- a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventDetailSheet.swift @@ -9,149 +9,335 @@ import SwiftUI import Shared struct PendingEventDetailSheet: View { - - let eventDetail: EventDetail + + let eventDetail: Event let isLoading: Bool let error: String? let onAccept: () -> Void let onDecline: () -> Void let onDismiss: () -> Void - + @Environment(\.dismiss) var dismiss - + private func statusColor(_ status: String) -> Color { switch status.lowercased() { case "accepted": - return .green + return DesignTheme.secondaryAccent case "declined": - return .red + return DesignTheme.error case "pending": - return .orange + return Color.orange default: - return .gray + return Color.gray } } - + var body: some View { NavigationStack { if let error = error { - VStack { - Text(error) - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding() - } + ErrorState(message: error) } else if isLoading { - ProgressView() + LoadingState() } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text(eventDetail.title) - .font(.title2) - .fontWeight(.bold) - - if let description = eventDetail.description_ { - Text(description) - .font(.body) - .foregroundColor(.secondary) - } - } - - Divider() - - VStack(alignment: .leading, spacing: 12) { - Label(eventDetail.date, systemImage: "calendar") - .font(.subheadline) - - if let time = eventDetail.time { - Label(time, systemImage: "clock") - .font(.subheadline) - } - - if let location = eventDetail.location { - Label(location, systemImage: "location.fill") - .font(.subheadline) - } - - HStack { - Text("Status:") - .font(.subheadline) - Spacer() - Text(eventDetail.status) - .font(.subheadline) - .foregroundColor(statusColor(eventDetail.status)) - .fontWeight(.semibold) - } - } - - Divider() - - VStack(alignment: .leading, spacing: 12) { - Text("Participants") - .font(.headline) - - VStack(spacing: 12) { - ForEach(eventDetail.participants, id: \.userId) { participant in - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(participant.username) - .font(.body) - .foregroundStyle(.primary) - Text(participant.role) - .font(.caption) - .foregroundStyle(.gray) - } - - Spacer() - - Text(participant.responseStatus) - .font(.subheadline) - .foregroundColor(statusColor(participant.responseStatus)) - } - .padding(.vertical, 4) - } - } - } - - Spacer() - .frame(height: 16) - - VStack(spacing: 12) { - Button(action: onAccept) { - Text("Accept Invitation") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.green) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) - - Button(action: onDecline) { - Text("Decline Invitation") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.red) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) + ContentView( + eventDetail: eventDetail, + statusColor: statusColor, + onAccept: onAccept, + onDecline: onDecline + ) + } + } + } + + private struct ErrorState: View { + let message: String + + var body: some View { + VStack(alignment: .center, spacing: DesignTheme.Spacing.lg) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(DesignTheme.error) + + Text("Something went wrong") + .font(DesignTheme.Typography.heading) + .multilineTextAlignment(.center) + + Text(message) + .font(DesignTheme.Typography.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(DesignTheme.Spacing.lg) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + private struct LoadingState: View { + var body: some View { + VStack(alignment: .center, spacing: DesignTheme.Spacing.lg) { + ProgressView() + .scaleEffect(1.5) + + Text("Loading event details...") + .font(DesignTheme.Typography.body) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + private struct ContentView: View { + let eventDetail: Event + let statusColor: (String) -> Color + let onAccept: () -> Void + let onDecline: () -> Void + + @Environment(\.dismiss) var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.xl) { + HeaderSection( + title: eventDetail.title, + description: eventDetail.description_, + status: eventDetail.status, + statusColor: statusColor + ) + + EventDetailsSection( + date: eventDetail.date, + time: eventDetail.time, + location: eventDetail.location + ) + + ParticipantsSection( + participants: eventDetail.participants, + statusColor: statusColor + ) + + ActionsSection( + onAccept: onAccept, + onDecline: onDecline + ) + + Spacer() + .frame(height: DesignTheme.Spacing.md) + } + .padding(DesignTheme.Spacing.lg) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: { dismiss() }) { + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: "chevron.left") + .fontWeight(.semibold) + Text("Back") } + .foregroundColor(DesignTheme.accentColor) } - .padding() } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Close") { - dismiss() - } + } + } + } + + private struct HeaderSection: View { + let title: String + let description: String? + let status: String + let statusColor: (String) -> Color + + var body: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + Text(title) + .font(DesignTheme.Typography.heading) + .lineLimit(nil) + + if let description = description { + Text(description) + .font(DesignTheme.Typography.body) + .foregroundColor(.secondary) + .lineLimit(nil) + } + + HStack(spacing: DesignTheme.Spacing.sm) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(statusColor(status)) + + Text(status.capitalized) + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(statusColor(status)) + + Spacer() + } + .padding(.vertical, DesignTheme.Spacing.sm) + .padding(.horizontal, DesignTheme.Spacing.md) + .background(statusColor(status).opacity(0.08)) + .cornerRadius(DesignTheme.CornerRadius.medium) + } + } + } + + private struct EventDetailsSection: View { + let date: String + let time: String? + let location: String? + + var body: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + Text("Event Details") + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + DetailRow( + icon: "calendar", + label: "Date", + value: date + ) + + if let time = time { + DetailRow( + icon: "clock", + label: "Time", + value: time + ) + } + + if let location = location { + DetailRow( + icon: "location.fill", + label: "Location", + value: location + ) + } + } + } + } + } + + private struct DetailRow: View { + let icon: String + let label: String + let value: String + + var body: some View { + HStack(alignment: .top, spacing: DesignTheme.Spacing.md) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(DesignTheme.accentColor) + .frame(width: 20) + + VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { + Text(label) + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(.secondary) + + Text(value) + .font(DesignTheme.Typography.body) + .lineLimit(nil) + } + + Spacer() + } + } + } + + private struct ParticipantsSection: View { + let participants: [EventParticipant] + let statusColor: (String) -> Color + + var body: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { + Text("Participants") + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + VStack(spacing: DesignTheme.Spacing.md) { + ForEach(participants, id: \.userId) { participant in + ParticipantCard( + participant: participant, + statusColor: statusColor + ) } } } } } + + private struct ParticipantCard: View { + let participant: EventParticipant + let statusColor: (String) -> Color + + var body: some View { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { + HStack(alignment: .center, spacing: DesignTheme.Spacing.md) { + VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { + Text(participant.username) + .font(DesignTheme.Typography.body) + .foregroundStyle(.primary) + + Text(participant.role) + .font(DesignTheme.Typography.bodySmallest) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: DesignTheme.Spacing.xs) { + Image(systemName: statusIcon(participant.status.description())) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(statusColor(participant.status.description())) + + Text(participant.status.description().capitalized) + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(statusColor(participant.status.description())) + } + .padding(.vertical, DesignTheme.Spacing.xs) + .padding(.horizontal, DesignTheme.Spacing.sm) + .background(statusColor(participant.status.description()).opacity(0.08)) + .cornerRadius(DesignTheme.CornerRadius.small) + } + } + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6)) + .cornerRadius(DesignTheme.CornerRadius.medium) + } + + private func statusIcon(_ status: String) -> String { + switch status.lowercased() { + case "accepted": + return "checkmark.circle.fill" + case "declined": + return "xmark.circle.fill" + case "pending": + return "clock.fill" + default: + return "questionmark.circle.fill" + } + } + } + + private struct ActionsSection: View { + let onAccept: () -> Void + let onDecline: () -> Void + + var body: some View { + VStack(spacing: DesignTheme.Spacing.md) { + ButtonFactory.primary( + action: onAccept, + label: "Accept Invitation" + ) + + ButtonFactory.secondary( + action: onDecline, + label: "Decline Invitation" + ) + } + .padding(.top, DesignTheme.Spacing.md) + } + } } diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift index 8d75ba0..ab60a60 100644 --- a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift @@ -28,11 +28,11 @@ struct PendingEventListView: View { } else if let errorMessage = reducer.errorMessage { VStack(spacing: DesignTheme.Spacing.lg) { ErrorBanner(message: errorMessage) - - Button(action: { reducer.refresh() }) { - Text("Retry") - } - .primaryButton(isLoading: false, isEnabled: true) + + ButtonFactory.primary( + action: { reducer.refresh() }, + label: "Retry" + ) } .padding(DesignTheme.Spacing.xl) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -90,11 +90,10 @@ struct PendingEventListView: View { onDismiss: { reducer.closeEventDetail() } ) } - .toast(isPresented: $reducer.showToast, message: reducer.toastMessage ?? "") } @ViewBuilder - private func eventListItem(event: Shared.Event) -> some View { + private func eventListItem(event: Event) -> some View { VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { HStack(spacing: DesignTheme.Spacing.md) { VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift index d5791a5..dee066c 100644 --- a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventReducer.swift @@ -12,7 +12,7 @@ import Shared final class PendingEventReducer { var pendingEvents: [Event] = [] - var selectedEventDetail: EventDetail? + var selectedEventDetail: Event? var isLoading: Bool = false var isRefreshing: Bool = false var detailError: String? @@ -94,6 +94,6 @@ final class PendingEventReducer { } func closeEventDetail() { - sharedVM.closeEventDetail() + selectedEventDetail = nil } } diff --git a/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift b/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift index b4b61bd..07f6370 100644 --- a/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift +++ b/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift @@ -114,81 +114,45 @@ struct FriendProfileView: View { @ViewBuilder private func actionButtons() -> some View { let isLoading = reducer.isActionPending - + switch reducer.status { case .none: - Button(action: { reducer.sendFriendRequest() }) { - HStack(spacing: DesignTheme.Spacing.sm) { - if isLoading { - ProgressView() - .tint(.white) - } - Text(isLoading ? "Sending..." : "Add Friend") - .font(DesignTheme.Typography.button) - } - } - .primaryButton(isLoading: isLoading, isEnabled: !isLoading) - + ButtonFactory.primaryLoading( + action: { reducer.sendFriendRequest() }, + label: "Add Friend", + isLoading: isLoading, + isEnabled: !isLoading + ) + case .requesting: - Button(action: {}) { - HStack(spacing: DesignTheme.Spacing.sm) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 16)) - Text("Request Sent") - .font(DesignTheme.Typography.button) - } - .frame(maxWidth: .infinity) - .frame(height: 54) - .foregroundColor(.white) - .background(Color.gray.opacity(0.6)) - .cornerRadius(DesignTheme.CornerRadius.capsule) - } - .disabled(true) - + ButtonFactory.disabled( + label: "Request Sent", + icon: "checkmark.circle.fill" + ) + case .incoming: VStack(spacing: DesignTheme.Spacing.md) { - Button(action: { reducer.acceptRequest() }) { - HStack(spacing: DesignTheme.Spacing.sm) { - if isLoading { - ProgressView() - .tint(.white) - } - Text(isLoading ? "Accepting..." : "Accept") - .font(DesignTheme.Typography.button) - } - } - .primaryButton(isLoading: isLoading, isEnabled: !isLoading) - - Button(action: { reducer.rejectRequest() }) { - Text("Decline") - .font(DesignTheme.Typography.button) - } - .frame(maxWidth: .infinity) - .frame(height: 54) - .foregroundColor(DesignTheme.error) - .background(DesignTheme.errorLight) - .cornerRadius(DesignTheme.CornerRadius.capsule) - .disabled(isLoading) + ButtonFactory.primaryLoading( + action: { reducer.acceptRequest() }, + label: "Accept", + isLoading: isLoading, + isEnabled: !isLoading + ) + + ButtonFactory.secondary( + action: { reducer.rejectRequest() }, + label: "Decline", + isEnabled: !isLoading + ) } - + case .friends: - Button(action: { reducer.removeFriend() }) { - HStack(spacing: DesignTheme.Spacing.sm) { - if isLoading { - ProgressView() - .tint(.white) - } - Text(isLoading ? "Removing..." : "Remove Friend") - .font(DesignTheme.Typography.button) - } - } - .frame(maxWidth: .infinity) - .frame(height: 54) - .foregroundColor(.white) - .background(DesignTheme.error) - .cornerRadius(DesignTheme.CornerRadius.capsule) - .disabled(isLoading) - + ButtonFactory.destructive( + action: { reducer.removeFriend() }, + label: "Remove Friend", + isLoading: isLoading + ) + default: EmptyView() } diff --git a/iosApp/iosApp/Modules/Friends/FriendsView.swift b/iosApp/iosApp/Modules/Friends/FriendsView.swift index f4db329..4a30881 100644 --- a/iosApp/iosApp/Modules/Friends/FriendsView.swift +++ b/iosApp/iosApp/Modules/Friends/FriendsView.swift @@ -9,13 +9,14 @@ import Shared import SwiftUI struct FriendsView: View { - + @State private var reducer = FriendsReducer() @State private var selectedSegment: Segment = .friends @State private var friendToPresent: Shared.User? - + var body: some View { VStack(spacing: 0) { + // Search Bar SearchBar( text: $reducer.searchText, onSearch: { query in @@ -24,13 +25,17 @@ struct FriendsView: View { reducer.clearSearch() } ) - .padding() + .padding(DesignTheme.Spacing.lg) .background(Color(.systemBackground)) - + + // Error Banner if let errorMessage = reducer.errorMessage { ErrorBanner(message: errorMessage) + .padding(.horizontal, DesignTheme.Spacing.lg) + .padding(.vertical, DesignTheme.Spacing.md) } - + + // Content contentListView() .overlay { if reducer.isLoading { @@ -39,23 +44,21 @@ struct FriendsView: View { } .opacity(reducer.isLoading ? 0 : 1) .safeAreaInset(edge: .bottom) { - VStack(spacing: 0) { - - Picker("", selection: $selectedSegment) { - ForEach(Segment.allCases, id: \.self) { segment in - Text(segment.rawValue) - .tag(segment) - } + // Tab Picker at Bottom + Picker("", selection: $selectedSegment) { + ForEach(Segment.allCases, id: \.self) { segment in + Text(segment.rawValue) + .tag(segment) } - .pickerStyle(.segmented) - .padding() - .background(Color(.systemBackground)) - .onChange(of: selectedSegment) { oldValue, newValue in - if reducer.searchResults != nil { - reducer.clearSearch() - } - reducer.onTabSelected(newValue.requestTab) + } + .pickerStyle(.segmented) + .padding(DesignTheme.Spacing.lg) + .background(Color(.systemBackground)) + .onChange(of: selectedSegment) { oldValue, newValue in + if reducer.searchResults != nil { + reducer.clearSearch() } + reducer.onTabSelected(newValue.requestTab) } } } @@ -69,18 +72,19 @@ struct FriendsView: View { } } } - + @ViewBuilder private func contentListView() -> some View { let listToDisplay = displayedList() let isEmpty = listToDisplay.isEmpty let isSearching = reducer.isSearching - + if isSearching { - VStack(spacing: 12) { + VStack(spacing: DesignTheme.Spacing.lg) { ProgressView() + .scaleEffect(1.2) Text("Searching...") - .font(.subheadline) + .font(DesignTheme.Typography.body) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -89,21 +93,27 @@ struct FriendsView: View { emptyStateView() } else { List(listToDisplay, id: \.id) { user in - UserRowView(user: user) - .listRowSeparator(.hidden) - .onTapGesture { - friendToPresent = user - } + UserRowView( + user: user, + currentTab: reducer.currentTab, + searchText: reducer.searchText + ) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: DesignTheme.Spacing.xs, leading: DesignTheme.Spacing.lg, bottom: DesignTheme.Spacing.xs, trailing: DesignTheme.Spacing.lg)) + .onTapGesture { + friendToPresent = user + } } .listStyle(.plain) + .background(Color(.systemBackground)) } } - + private func displayedList() -> [Shared.User] { if let searchResults = reducer.searchResults { return searchResults } - + switch reducer.currentTab { case .friends: return reducer.friendsList @@ -115,36 +125,40 @@ struct FriendsView: View { fatalError("not implemeted") } } - + @ViewBuilder private func emptyStateView() -> some View { - VStack(spacing: 16) { + VStack(spacing: DesignTheme.Spacing.lg) { Image(systemName: "person.2") - .font(.system(size: 48)) - .foregroundColor(.secondary) - + .font(.system(size: 56)) + .foregroundColor(DesignTheme.accentColor.opacity(0.3)) + if let searchResults = reducer.searchResults, searchResults.isEmpty { - VStack(spacing: 8) { + VStack(spacing: DesignTheme.Spacing.sm) { Text("No users found") - .font(.headline) + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.black) Text("Try searching with a different name") - .font(.subheadline) + .font(DesignTheme.Typography.bodySmall) .foregroundColor(.secondary) + .multilineTextAlignment(.center) } } else { - VStack(spacing: 8) { + VStack(spacing: DesignTheme.Spacing.sm) { Text(emptyStateTitle()) - .font(.headline) + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.black) Text(emptyStateSubtitle()) - .font(.subheadline) + .font(DesignTheme.Typography.bodySmall) .foregroundColor(.secondary) + .multilineTextAlignment(.center) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground)) } - + private func emptyStateTitle() -> String { switch reducer.currentTab { case .friends: @@ -157,7 +171,7 @@ struct FriendsView: View { fatalError("not implemeted") } } - + private func emptyStateSubtitle() -> String { switch reducer.currentTab { case .friends: @@ -174,36 +188,79 @@ struct FriendsView: View { private struct UserRowView: View { let user: Shared.User - + let currentTab: RequestTab + let searchText: String + var body: some View { - HStack(spacing: 14) { - - Circle() - .fill(.gray.opacity(0.15)) - .frame(width: 42, height: 42) - .overlay { - Text(user.username.prefix(1).uppercased()) - .font(.subheadline.weight(.semibold)) + HStack(spacing: DesignTheme.Spacing.md) { + // Avatar with Initial Badge + ZStack(alignment: .bottomTrailing) { + Circle() + .fill(DesignTheme.accentColor) + .frame(width: 52, height: 52) + .overlay { + Text(user.username.prefix(1).uppercased()) + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.white) + } + + // Status Badge + if let avatarUrl = user.avatarUrl, !avatarUrl.isEmpty { + AsyncImage(url: URL(string: avatarUrl)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(DesignTheme.accentColor) + } + .frame(width: 52, height: 52) + .clipShape(Circle()) } - - VStack(alignment: .leading, spacing: 4) { + } + + // User Info + VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { Text(user.username) - .font(.subheadline.weight(.medium)) - + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(.black) + if let bio = user.bio, !bio.isEmpty { Text(bio) - .font(.caption) - .foregroundStyle(.secondary) + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(.secondary) .lineLimit(1) } } - + Spacer() + + // Status Badge + if searchText.isEmpty { + statusBadge() + } } - .padding(14) - .background { - RoundedRectangle(cornerRadius: 16) - .fill(.gray.opacity(0.06)) + .padding(DesignTheme.Spacing.md) + .background( + RoundedRectangle(cornerRadius: DesignTheme.CornerRadius.medium) + .fill(Color(.systemGray6).opacity(0.5)) + ) + } + + @ViewBuilder + private func statusBadge() -> some View { + switch currentTab { + case .friends: + IndicatorFactory.active() + + case .incoming: + IndicatorFactory.pending() + + case .outgoing: + IndicatorFactory.sent() + + default: + EmptyView() } } } diff --git a/iosApp/iosApp/Modules/Login/LoginView.swift b/iosApp/iosApp/Modules/Login/LoginView.swift index c8e3901..0149a57 100644 --- a/iosApp/iosApp/Modules/Login/LoginView.swift +++ b/iosApp/iosApp/Modules/Login/LoginView.swift @@ -43,23 +43,12 @@ struct LoginView: View { } .padding(.horizontal, DesignTheme.Spacing.xl) - Button(action: { - reducer.login() - }) { - if reducer.isLoading { - HStack(spacing: DesignTheme.Spacing.sm) { - ProgressView() - .scaleEffect(0.9) - .tint(.white) - Text("Logging in...") - .font(DesignTheme.Typography.button) - } - } else { - Text("Login") - .font(DesignTheme.Typography.button) - } - } - .primaryButton(isLoading: reducer.isLoading, isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty)) + ButtonFactory.primaryLoading( + action: { reducer.login() }, + label: "Login", + isLoading: reducer.isLoading, + isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty) + ) .padding(.horizontal, DesignTheme.Spacing.xl) HStack(spacing: DesignTheme.Spacing.xs) { diff --git a/iosApp/iosApp/Modules/Main/MainEventsReducer.swift b/iosApp/iosApp/Modules/Main/MainEventsReducer.swift index b14060c..1e17c9b 100644 --- a/iosApp/iosApp/Modules/Main/MainEventsReducer.swift +++ b/iosApp/iosApp/Modules/Main/MainEventsReducer.swift @@ -1,5 +1,5 @@ // -// MainEventsReducer.swift +// EventsReducer.swift // iosApp // // Created by Данил Забинский on 14.05.2026. @@ -23,10 +23,10 @@ enum EventFilter: Int, CaseIterable { } @Observable -final class MainEventsReducer { +final class EventsReducer { - var activeEvents: [MainEvent] = [] - var pendingEvents: [MainEvent] = [] + var activeEvents: [Event] = [] + var pendingEvents: [Event] = [] var isRefreshing: Bool = false var isLoading: Bool = false @@ -83,12 +83,10 @@ final class MainEventsReducer { guard let availability = result else { return true } switch availability { - case is AvailabilityResult.Busy: - return false - case is AvailabilityResult.Available: - return true + case let isAvailable as Bool: + return isAvailable default: - return true + return false } } catch { self.errorMessage = error.localizedDescription @@ -96,7 +94,7 @@ final class MainEventsReducer { } } - var filteredEvents: [MainEvent] { + var filteredEvents: [Event] { switch selectedFilter { case .active: return activeEvents diff --git a/iosApp/iosApp/Modules/Main/MainView.swift b/iosApp/iosApp/Modules/Main/MainView.swift index c2ce52e..faa80b2 100644 --- a/iosApp/iosApp/Modules/Main/MainView.swift +++ b/iosApp/iosApp/Modules/Main/MainView.swift @@ -9,157 +9,233 @@ import SwiftUI import Shared struct MainView: View { - - @State private var reducer = MainEventsReducer() + + @State private var reducer = EventsReducer() @State private var isCreatingEventInProgress = false @State private var selectedDate = Date() @State private var showBusyAlert = false @State private var selectedDateForEvent: String? + @Environment(Router.self) private var router - + private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter }() - + var body: some View { - VStack { - // Header with Bell Button and Badge + ZStack(alignment: .bottomTrailing) { + + backgroundLayer + + VStack(spacing: 0) { + + headerView + + contentView + } + + floatingCreateButton + } + .sheet(isPresented: $isCreatingEventInProgress) { + createEventSheet + } + .alert("You are busy on this day", isPresented: $showBusyAlert) { + Button("OK") { + showBusyAlert = false + } + } + .task { + router.onCreatedEventPushBack = { + reducer.refresh() + } + } + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Main Layout + +private extension MainView { + + var backgroundLayer: some View { + Color(.systemGroupedBackground) + .ignoresSafeArea() + } + + var headerView: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + + VStack(alignment: .leading, spacing: 4) { + Text("Your Events") + .font(.system(size: 30, weight: .bold)) + + Text("Manage active and upcoming plans") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Button { router.push(screen: .pendingEvents) } label: { - ZStack(alignment: .topTrailing) { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .frame(width: 46, height: 46) + Image(systemName: "bell.fill") - .font(.title2) - - if reducer.pendingCount > 0 { - Text("\(reducer.pendingCount)") - .font(.caption2) - .fontWeight(.bold) - .foregroundColor(.white) - .frame(width: 20, height: 20) - .background(Color.red) - .clipShape(Circle()) - .offset(x: 8, y: -8) - } + .font(.title3) + .foregroundColor(DesignTheme.accentColor) } } - .padding() } - - if reducer.isLoading { - ProgressView() - .frame(maxHeight: .infinity, alignment: .center) - } else if let errorMessage = reducer.errorMessage { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.circle.fill") - .font(.largeTitle) - .foregroundColor(.red) - Text(errorMessage) - .foregroundColor(.red) - .multilineTextAlignment(.center) - Button(action: { - reducer.refresh() - }) { - Text("Retry") - .font(.headline) - } - .buttonStyle(.bordered) - } - .padding() - .frame(maxHeight: .infinity, alignment: .center) - } else if reducer.activeEvents.isEmpty && reducer.pendingEvents.isEmpty { - // Empty state: no events at all - VStack(spacing: 12) { - Image(systemName: "calendar") - .font(.largeTitle) - .foregroundColor(.gray) - Text("You have no events, create it now!") - .foregroundColor(.gray) - } - .frame(maxHeight: .infinity, alignment: .center) - } else { - // Events List with sections - ScrollView { - VStack(alignment: .leading, spacing: 16) { - // Active Events Section - if !reducer.activeEvents.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Active") - .font(.headline) - .fontWeight(.semibold) - .padding(.horizontal) - - VStack(spacing: 0) { - ForEach(reducer.activeEvents, id: \.id) { event in - EventRowView(event: event, isPending: false) - .onTapGesture { - router.push(screen: .eventDetail(id: event.id)) - } - .padding(.horizontal) - } + } + .padding(.horizontal, DesignTheme.Spacing.lg) + .padding(.top, DesignTheme.Spacing.lg) + .padding(.bottom, DesignTheme.Spacing.md) + .background( + LinearGradient( + colors: [ + DesignTheme.accentColor.opacity(0.12), + Color(.systemGroupedBackground) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } + + @ViewBuilder + var contentView: some View { + + if reducer.isLoading { + + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + } else if let errorMessage = reducer.errorMessage { + + errorState(message: errorMessage) + + } else if reducer.activeEvents.isEmpty && reducer.pendingEvents.isEmpty { + + emptyState + + } else { + + ScrollView { + VStack(spacing: DesignTheme.Spacing.xl) { + + if !reducer.activeEvents.isEmpty { + sectionView( + title: "Active", + count: reducer.activeEvents.count, + tint: DesignTheme.secondaryAccent + ) { + ForEach(reducer.activeEvents, id: \.id) { event in + EventRowView( + event: event, + isPending: false + ) + .onTapGesture { + router.push( + screen: .eventDetail(id: event.id) + ) } } } - - // Pending Events Section - if !reducer.pendingEvents.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Pending") - .font(.headline) - .fontWeight(.semibold) - .padding(.horizontal) - - VStack(spacing: 0) { - ForEach(reducer.pendingEvents, id: \.id) { event in - EventRowView(event: event, isPending: true) - .onTapGesture { - router.push(screen: .eventDetail(id: event.id)) - } - .padding(.horizontal) - } + } + + if !reducer.pendingEvents.isEmpty { + sectionView( + title: "Pending", + count: reducer.pendingEvents.count, + tint: .orange + ) { + ForEach(reducer.pendingEvents, id: \.id) { event in + EventRowView( + event: event, + isPending: true + ) + .onTapGesture { + router.push( + screen: .eventDetail(id: event.id) + ) } } } } - .padding(.vertical) } + .padding(.horizontal, DesignTheme.Spacing.lg) + .padding(.top, DesignTheme.Spacing.md) + .padding(.bottom, 100) } - - Button(action: { - isCreatingEventInProgress = true - }) { - Text("Create event") - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding() .refreshable { - reducer.refresh() + await withCheckedContinuation { continuation in + let _ = Task { + while reducer.isRefreshing { + try? await Task.sleep( + nanoseconds: 100_000_000 + ) + } + continuation.resume() + } + + reducer.refresh() + } } } - .navigationTitle("Home") - .sheet(isPresented: $isCreatingEventInProgress) { - VStack(spacing: 16) { - DatePicker( - "Select event date", - selection: $selectedDate, - in: Date()..., - displayedComponents: [.date, .hourAndMinute] + } + + var floatingCreateButton: some View { + Button { + isCreatingEventInProgress = true + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .foregroundColor(.white) + .frame(width: 58, height: 58) + .background( + Circle() + .fill(DesignTheme.accentColor) + .shadow( + color: .black.opacity(0.15), + radius: 10, + x: 0, + y: 6 + ) ) - .datePickerStyle(.graphical) - .padding() + } + .padding(.trailing, DesignTheme.Spacing.lg) + .padding(.bottom, DesignTheme.Spacing.lg) + } + + var createEventSheet: some View { + VStack(spacing: DesignTheme.Spacing.xs) { + + Text("Create new event") + .font(.title2.weight(.bold)) + + DatePicker( + "Select event date", + selection: $selectedDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) - Button("Create") { + ButtonFactory.primaryLoading( + action: { let dateString = dateFormatter.string(from: selectedDate) + Task { let isAvailable = await reducer.checkAvailability(date: dateString) + if isAvailable { router.push(screen: .createEvent(date: dateString)) isCreatingEventInProgress = false @@ -168,80 +244,204 @@ struct MainView: View { showBusyAlert = true } } - } - .disabled(reducer.isCheckingAvailability) - } - .presentationDetents([.medium]) + }, + label: "Create", + isLoading: reducer.isCheckingAvailability, + isEnabled: !reducer.isCheckingAvailability + ) } - .alert("You are busy on this day", isPresented: $showBusyAlert) { - Button("OK") { - showBusyAlert = false + .padding(.horizontal) + .presentationDetents([.height(UIScreen.main.bounds.height * 0.6)]) + .presentationCornerRadius(24) + } + + func errorState(message: String) -> some View { + VStack(spacing: DesignTheme.Spacing.lg) { + + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(DesignTheme.error) + + Text(message) + .multilineTextAlignment(.center) + .foregroundColor(DesignTheme.error) + + ButtonFactory.primary( + action: { reducer.refresh() }, + label: "Retry" + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + var emptyState: some View { + VStack(spacing: DesignTheme.Spacing.lg) { + + Image(systemName: "calendar.badge.plus") + .font(.system(size: 52)) + .foregroundColor(.gray) + + Text("No events yet") + .font(.headline) + + Text("Tap + to create your first event") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + func sectionView( + title: String, + count: Int, + tint: Color, + @ViewBuilder content: () -> Content + ) -> some View { + + VStack(alignment: .leading, spacing: 12) { + + HStack { + + Text(title) + .font(.headline) + + Spacer() + + Text("\(count)") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(tint.opacity(0.12)) + ) + .foregroundColor(tint) } + + content() } } } -// MARK: - Event Row Component +// MARK: - Event Row + struct EventRowView: View { - let event: MainEvent + + let event: Event let isPending: Bool - + + private var tint: Color { + isPending ? .orange : DesignTheme.secondaryAccent + } + + private var badgeIcon: String { + isPending + ? "clock.fill" + : "checkmark.circle.fill" + } + + private var badgeText: String { + isPending + ? "Pending" + : "Active" + } + var body: some View { - VStack(alignment: .leading, spacing: 8) { + + VStack( + alignment: .leading, + spacing: DesignTheme.Spacing.sm + ) { + HStack { + Text(event.title) - .font(.headline) - .fontWeight(.semibold) - - if isPending { - Text("PENDING") - .font(.caption2) - .fontWeight(.bold) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.orange) - .cornerRadius(4) - } - + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.primary) + Spacer() + + badgeView } - - HStack(spacing: 16) { - HStack(spacing: 4) { - Image(systemName: "calendar") - .foregroundColor(.gray) - Text(event.date) - .font(.caption) - .foregroundColor(.gray) - } - + + HStack(spacing: 8) { + + infoChip( + icon: "calendar", + text: event.date + ) + if let time = event.time { - HStack(spacing: 4) { - Image(systemName: "clock") - .foregroundColor(.gray) - Text(time) - .font(.caption) - .foregroundColor(.gray) - } + infoChip( + icon: "clock", + text: time + ) } - + + infoChip( + icon: "person.2", + text: "\(event.participants.count)" + ) + Spacer() } - - HStack(spacing: 4) { - Image(systemName: "person.2") - .foregroundColor(.gray) - Text("\(event.participantCount) participants") - .font(.caption) - .foregroundColor(.gray) - } } - .padding(.vertical, 4) - .listRowBackground( - isPending ? - Color(red: 1.0, green: 0.97, blue: 0.92).opacity(0.6) : - Color.clear + .padding() + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Color(.systemBackground)) + .shadow( + color: .black.opacity(0.06), + radius: 8, + x: 0, + y: 4 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke( + tint.opacity(0.15), + lineWidth: 1 + ) + ) + } + + private var badgeView: some View { + HStack(spacing: 4) { + + Image(systemName: badgeIcon) + .font(.caption) + + Text(badgeText) + .font(.caption.weight(.semibold)) + } + .foregroundColor(tint) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + Capsule() + .fill(tint.opacity(0.12)) + ) + } + + private func infoChip( + icon: String, + text: String + ) -> some View { + + HStack(spacing: 4) { + + Image(systemName: icon) + + Text(text) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color(.secondarySystemBackground)) ) } } diff --git a/iosApp/iosApp/Modules/Profile/BusyDays/BusyDayView.swift b/iosApp/iosApp/Modules/Profile/BusyDays/BusyDayView.swift index 78924fe..024c251 100644 --- a/iosApp/iosApp/Modules/Profile/BusyDays/BusyDayView.swift +++ b/iosApp/iosApp/Modules/Profile/BusyDays/BusyDayView.swift @@ -35,15 +35,10 @@ struct BusyDayView: View { .font(DesignTheme.Typography.caption) .foregroundColor(.secondary) } - Button(action: { reducer.retry() }) { - Text("Retry") - .font(DesignTheme.Typography.caption) - .frame(maxWidth: .infinity) - .frame(height: 36) - .background(DesignTheme.accentColor) - .foregroundColor(.white) - .cornerRadius(DesignTheme.CornerRadius.medium) - } + ButtonFactory.compact( + action: { reducer.retry() }, + label: "Retry" + ) } .padding(DesignTheme.Spacing.lg) .frame(maxWidth: .infinity) diff --git a/iosApp/iosApp/Modules/Profile/EditProfile/EditProfileView.swift b/iosApp/iosApp/Modules/Profile/EditProfile/EditProfileView.swift index 4921cfd..4bbf186 100644 --- a/iosApp/iosApp/Modules/Profile/EditProfile/EditProfileView.swift +++ b/iosApp/iosApp/Modules/Profile/EditProfile/EditProfileView.swift @@ -45,22 +45,12 @@ struct EditProfileView: View { Spacer() - Button(action: { reducer.save() }) { - if reducer.isSaving { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } else { - Text("Save") - .font(DesignTheme.Typography.button) - } - } - .frame(maxWidth: .infinity) - .frame(height: 54) - .foregroundColor(.white) - .background(DesignTheme.accentColor) - .cornerRadius(DesignTheme.CornerRadius.capsule) - .disabled(reducer.isSaving) + ButtonFactory.primaryLoading( + action: { reducer.save() }, + label: "Save", + isLoading: reducer.isSaving, + isEnabled: !reducer.isSaving + ) } .padding(DesignTheme.Spacing.lg) .navigationTitle("Edit Profile") diff --git a/iosApp/iosApp/Modules/Profile/ProfileReducer.swift b/iosApp/iosApp/Modules/Profile/ProfileReducer.swift index 2baf08f..7f7fc2a 100644 --- a/iosApp/iosApp/Modules/Profile/ProfileReducer.swift +++ b/iosApp/iosApp/Modules/Profile/ProfileReducer.swift @@ -10,11 +10,12 @@ import Shared @Observable final class ProfileReducer { - + var profile: Profile? var isLoading = false + var isRefreshing = false var errorMessage: String? - + var onLogoutRequested: (() -> Void)? var onEditProfileRequested: (() -> Void)? @@ -31,12 +32,16 @@ final class ProfileReducer { switch profileState { case is ProfileViewState.Loading: self.isLoading = true + self.isRefreshing = false case let error as ProfileViewState.Error: self.errorMessage = error.message + self.isRefreshing = false case let content as ProfileViewState.Content: self.profile = content.profile + self.isRefreshing = content.isRefreshing default: isLoading = false + isRefreshing = false profile = nil } } @@ -76,4 +81,8 @@ final class ProfileReducer { func navigateToEdit() { sharedVM.obtainEvent(event: ProfileEvent.OnEditClick()) } + + func onRefreshProfile() { + sharedVM.obtainEvent(event: ProfileEvent.OnRefreshProfile()) + } } diff --git a/iosApp/iosApp/Modules/Profile/ProfileView.swift b/iosApp/iosApp/Modules/Profile/ProfileView.swift index bf8bd56..54858c4 100644 --- a/iosApp/iosApp/Modules/Profile/ProfileView.swift +++ b/iosApp/iosApp/Modules/Profile/ProfileView.swift @@ -21,51 +21,40 @@ struct ProfileView: View { UserView(user: profile, dimension: .vertical) .frame(maxWidth: .infinity) - VStack(spacing: DesignTheme.Spacing.md) { - Button(action: { profileReducer.loadProfile() }) { - HStack(spacing: DesignTheme.Spacing.sm) { - Image(systemName: "arrow.clockwise") - Text("Refresh Activity") - } - .font(DesignTheme.Typography.button) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(DesignTheme.accentColor) - .foregroundColor(.white) - .cornerRadius(DesignTheme.CornerRadius.capsule) - } - - BusyDayView(userId: profile.id) - .frame(maxWidth: .infinity, alignment: .topLeading) - } + BusyDayView(userId: profile.id) + .frame(maxWidth: .infinity, alignment: .topLeading) WishPlacesView(userId: profile.id, mode: .editable) .frame(maxWidth: .infinity, alignment: .topLeading) VStack(spacing: DesignTheme.Spacing.md) { - Button(action: { profileReducer.navigateToEdit() }) { - Text("Edit Profile") - .font(DesignTheme.Typography.button) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(DesignTheme.accentColor) - .foregroundColor(.white) - .cornerRadius(DesignTheme.CornerRadius.capsule) - } + ButtonFactory.primary( + action: { profileReducer.navigateToEdit() }, + label: "Edit Profile" + ) - Button(action: { profileReducer.logout() }) { - Text("Log Out") - .font(DesignTheme.Typography.button) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(DesignTheme.error) - .foregroundColor(.white) - .cornerRadius(DesignTheme.CornerRadius.capsule) - } + ButtonFactory.destructive( + action: { profileReducer.logout() }, + label: "Log Out" + ) } } .padding(DesignTheme.Spacing.lg) } + .refreshable { + await withCheckedContinuation { continuation in + let _ = Task { + while profileReducer.isRefreshing { + try? await Task.sleep( + nanoseconds: 100_000_000 + ) + } + continuation.resume() + } + + profileReducer.onRefreshProfile() + } + } .navigationTitle("Profile") .onAppear { profileReducer.loadProfile() diff --git a/iosApp/iosApp/Modules/Profile/WishPlaces/CreateWishPlaceSheet.swift b/iosApp/iosApp/Modules/Profile/WishPlaces/CreateWishPlaceSheet.swift index 32bee6d..1af9ada 100644 --- a/iosApp/iosApp/Modules/Profile/WishPlaces/CreateWishPlaceSheet.swift +++ b/iosApp/iosApp/Modules/Profile/WishPlaces/CreateWishPlaceSheet.swift @@ -52,19 +52,19 @@ struct CreateWishPlaceSheet: View { Spacer() .frame(height: DesignTheme.Spacing.md) - - Button(action: { - onCreate( - title, - description.isEmpty ? nil : description, - location.isEmpty ? nil : location, - link.isEmpty ? nil : link - ) - }) { - Text("Create") - .font(DesignTheme.Typography.button) - } - .primaryButton(isEnabled: isCreateButtonEnabled) + + ButtonFactory.primary( + action: { + onCreate( + title, + description.isEmpty ? nil : description, + location.isEmpty ? nil : location, + link.isEmpty ? nil : link + ) + }, + label: "Create", + isEnabled: isCreateButtonEnabled + ) .padding(.horizontal, DesignTheme.Spacing.lg) Spacer() diff --git a/iosApp/iosApp/Modules/Profile/WishPlaces/WishPlacesView.swift b/iosApp/iosApp/Modules/Profile/WishPlaces/WishPlacesView.swift index 872b32a..522be7a 100644 --- a/iosApp/iosApp/Modules/Profile/WishPlaces/WishPlacesView.swift +++ b/iosApp/iosApp/Modules/Profile/WishPlaces/WishPlacesView.swift @@ -44,11 +44,11 @@ struct WishPlacesView: View { } else if let errorMessage = reducer.errorMessage { VStack(spacing: DesignTheme.Spacing.md) { ErrorBanner(message: errorMessage) - - Button(action: { reducer.retry() }) { - Text("Retry") - } - .primaryButton(isLoading: false, isEnabled: true) + + ButtonFactory.primary( + action: { reducer.retry() }, + label: "Retry" + ) } } else if reducer.places.isEmpty { Text("No wish places yet") diff --git a/iosApp/iosApp/Modules/Register/RegisterView.swift b/iosApp/iosApp/Modules/Register/RegisterView.swift index 5cd3472..6a904df 100644 --- a/iosApp/iosApp/Modules/Register/RegisterView.swift +++ b/iosApp/iosApp/Modules/Register/RegisterView.swift @@ -53,23 +53,12 @@ struct RegisterView: View { } .padding(.horizontal, DesignTheme.Spacing.xl) - Button(action: { - reducer.register() - }) { - if reducer.isLoading { - HStack(spacing: DesignTheme.Spacing.sm) { - ProgressView() - .scaleEffect(0.9) - .tint(.white) - Text("Creating account...") - .font(DesignTheme.Typography.button) - } - } else { - Text("Create Account") - .font(DesignTheme.Typography.button) - } - } - .primaryButton(isLoading: reducer.isLoading, isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty)) + ButtonFactory.primaryLoading( + action: { reducer.register() }, + label: "Create Account", + isLoading: reducer.isLoading, + isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty) + ) .padding(.horizontal, DesignTheme.Spacing.xl) HStack(spacing: DesignTheme.Spacing.xs) { diff --git a/iosApp/iosApp/Navigation/Router.swift b/iosApp/iosApp/Navigation/Router.swift index 49f9fcd..09cdd5d 100644 --- a/iosApp/iosApp/Navigation/Router.swift +++ b/iosApp/iosApp/Navigation/Router.swift @@ -13,6 +13,7 @@ final class Router { var path = NavigationPath() var session: AuthSession? + var onCreatedEventPushBack: (() -> Void)? func push(screen: AppRouter) { path.append(screen) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/repository/EventsRepositoryImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/repository/EventsRepositoryImpl.kt index cb702f1..9b7c50f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/repository/EventsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/repository/EventsRepositoryImpl.kt @@ -48,6 +48,11 @@ internal class EventsRepositoryImpl( response.id } + override suspend fun getPendingEvents(): ResultWrapper> = safeApiCall { + val response = api.getPendingEvents() + eventMapper.mapEventResponseToEvents(response) + } + override suspend fun getWaitingEvents(): ResultWrapper> = safeApiCall { val response = api.getWaitingEvents() eventMapper.mapEventResponseToEvents(response) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt index 7421d97..14c9bde 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetPendingEventsUseCaseImpl.kt @@ -11,7 +11,7 @@ internal class GetPendingEventsUseCaseImpl( ) : GetPendingEventsUseCase { override suspend fun invoke(): ResultWrapper> { - val result = repository.getWaitingEvents() + val result = repository.getPendingEvents() if (result is ResultWrapper.Error) return result val events = (result as ResultWrapper.Success).data diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetWaitingEventsUseCaseImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetWaitingEventsUseCaseImpl.kt new file mode 100644 index 0000000..577708e --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/data/usecase/GetWaitingEventsUseCaseImpl.kt @@ -0,0 +1,15 @@ +package friends.mobile.feature.events.data.usecase + +import friends.mobile.core.domain.model.ResultWrapper +import friends.mobile.feature.events.domain.model.Event +import friends.mobile.feature.events.domain.repository.EventsRepository +import friends.mobile.feature.events.domain.usecase.GetWaitingEventsUseCase + +internal class GetWaitingEventsUseCaseImpl( + private val repository: EventsRepository, +) : GetWaitingEventsUseCase { + + override suspend fun invoke(): ResultWrapper> { + return repository.getWaitingEvents() + } +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/di/EventsModule.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/di/EventsModule.kt index be49039..98f40e5 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/di/EventsModule.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/di/EventsModule.kt @@ -13,6 +13,7 @@ import friends.mobile.feature.events.data.usecase.GetAcceptedEventsUseCaseImpl import friends.mobile.feature.events.data.usecase.GetArchivedEventsUseCaseImpl import friends.mobile.feature.events.data.usecase.GetEventDetailUseCaseImpl import friends.mobile.feature.events.data.usecase.GetPendingEventsUseCaseImpl +import friends.mobile.feature.events.data.usecase.GetWaitingEventsUseCaseImpl import friends.mobile.feature.events.domain.repository.EventsRepository import friends.mobile.feature.events.domain.usecase.AcceptEventUseCase import friends.mobile.feature.events.domain.usecase.CheckFriendsAvailabilityUseCase @@ -23,6 +24,7 @@ import friends.mobile.feature.events.domain.usecase.GetAcceptedEventsUseCase import friends.mobile.feature.events.domain.usecase.GetArchivedEventsUseCase import friends.mobile.feature.events.domain.usecase.GetEventDetailUseCase import friends.mobile.feature.events.domain.usecase.GetPendingEventsUseCase +import friends.mobile.feature.events.domain.usecase.GetWaitingEventsUseCase import friends.mobile.feature.events.presentation.CreateEventViewModel import friends.mobile.feature.events.presentation.eventdetail.EventDetailViewModel import friends.mobile.feature.events.presentation.pendingevents.PendingEventViewModel @@ -60,6 +62,10 @@ val eventsModule = module { GetPendingEventsUseCaseImpl(repository = get()) } + factory { + GetWaitingEventsUseCaseImpl(repository = get()) + } + factory { GetEventDetailUseCaseImpl(repository = get()) } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/repository/EventsRepository.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/repository/EventsRepository.kt index dd797ab..4f500c7 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/repository/EventsRepository.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/repository/EventsRepository.kt @@ -18,6 +18,7 @@ interface EventsRepository { invitedFriendIds: List, ): ResultWrapper + suspend fun getPendingEvents(): ResultWrapper> suspend fun getWaitingEvents(): ResultWrapper> suspend fun getAcceptedEvents(): ResultWrapper> suspend fun getArchivedEvents(): ResultWrapper> diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/usecase/GetWaitingEventsUseCase.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/usecase/GetWaitingEventsUseCase.kt new file mode 100644 index 0000000..de146b6 --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/domain/usecase/GetWaitingEventsUseCase.kt @@ -0,0 +1,8 @@ +package friends.mobile.feature.events.domain.usecase + +import friends.mobile.core.domain.model.ResultWrapper +import friends.mobile.feature.events.domain.model.Event + +interface GetWaitingEventsUseCase { + suspend operator fun invoke(): ResultWrapper> +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt index 81843bd..0697bde 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt @@ -7,7 +7,7 @@ import friends.mobile.core.viewmodel.BaseViewModel import friends.mobile.feature.events.domain.usecase.AcceptEventUseCase import friends.mobile.feature.events.domain.usecase.DeclineEventUseCase import friends.mobile.feature.events.domain.usecase.GetEventDetailUseCase -import friends.mobile.feature.events.domain.usecase.GetPendingEventsUseCase +import friends.mobile.feature.events.domain.usecase.GetWaitingEventsUseCase import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -16,7 +16,7 @@ class PendingEventViewModel : BaseViewModel { viewState = PendingViewState.Content( diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt index 19eaa31..fa5b31f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt @@ -26,6 +26,8 @@ class MainViewModel : BaseViewModel< private val checkUserAvailabilityUseCase: CheckUserAvailabilityUseCase by inject() + private var isLoadingInProgress = false + init { loadEvents() } @@ -36,42 +38,81 @@ class MainViewModel : BaseViewModel< } } - private fun loadEvents() { + private fun loadEvents(showLoading: Boolean = true) { viewModelScope.launch { + if (isLoadingInProgress) return@launch - when (val activeResult = getAcceptedEventsUseCase()) { + isLoadingInProgress = true - is ResultWrapper.Success -> { + try { + if (showLoading) { + viewState = MainViewState.Loading + } else { + val currentState = viewState + if (currentState is MainViewState.Content) { + viewState = currentState.copy(isRefreshing = true) + } + } - when (val pendingResult = getPendingEventsUseCase()) { + when (val activeResult = getAcceptedEventsUseCase()) { - is ResultWrapper.Success -> { - viewState = MainViewState.Content( - activeEvents = activeResult.data, - pendingEvents = pendingResult.data, - isRefreshing = false, - ) - } + is ResultWrapper.Success -> { - is ResultWrapper.Error -> { - handleError(pendingResult.error) + when (val pendingResult = getPendingEventsUseCase()) { + + is ResultWrapper.Success -> { + viewState = MainViewState.Content( + activeEvents = activeResult.data, + pendingEvents = pendingResult.data, + isRefreshing = false, + ) + } + + is ResultWrapper.Error -> { + val currentState = viewState + val userError = mapApiErrorToUserFriendly(pendingResult.error) + + if (currentState is MainViewState.Content) { + viewState = currentState.copy(isRefreshing = false) + } else { + viewState = MainViewState.Error( + message = getErrorMessage(userError), + ) + } + } } } - } - is ResultWrapper.Error -> { - handleError(activeResult.error) + is ResultWrapper.Error -> { + val currentState = viewState + val userError = mapApiErrorToUserFriendly(activeResult.error) + + if (currentState is MainViewState.Content) { + viewState = currentState.copy(isRefreshing = false) + } else { + viewState = MainViewState.Error( + message = getErrorMessage(userError), + ) + } + } } + } finally { + isLoadingInProgress = false } } } private fun onRefresh() { - updateContent { - copy(isRefreshing = true) + if (isLoadingInProgress) return + + val currentState = viewState + if (currentState is MainViewState.Content) { + viewState = currentState.copy(isRefreshing = true) + } else if (currentState is MainViewState.Error) { + viewState = MainViewState.Content(isRefreshing = true) } - loadEvents() + loadEvents(showLoading = false) } suspend fun checkAvailability( @@ -92,16 +133,6 @@ class MainViewModel : BaseViewModel< } } - private fun handleError( - error: ApiError, - ) { - val userError = mapApiErrorToUserFriendly(error) - - viewState = MainViewState.Error( - message = getErrorMessage(userError), - ) - } - private inline fun updateContent( transform: MainViewState.Content.() -> MainViewState.Content, ) { diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt index c15dd57..0293c2a 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt @@ -14,6 +14,7 @@ class ProfileViewModel : BaseViewModel loadProfile(showLoading = true) - is ProfileEvent.OnRefreshProfile -> loadProfile(showLoading = false) + is ProfileEvent.OnRefreshProfile -> onRefresh() is ProfileEvent.OnLogoutClick -> onLogoutClick() is ProfileEvent.OnEditClick -> onEditClick() } @@ -30,22 +31,57 @@ class ProfileViewModel : BaseViewModel { - viewState = ProfileViewState.Content(profile = result.data) + if (isLoadingInProgress) return@launch + + isLoadingInProgress = true + + try { + if (showLoading) { + viewState = ProfileViewState.Loading + } else { + val currentState = viewState + if (currentState is ProfileViewState.Content) { + viewState = currentState.copy(isRefreshing = true) + } } - is ResultWrapper.Error -> { - val userError = mapApiErrorToUserFriendly(result.error) - viewState = ProfileViewState.Error(message = getErrorMessage(userError)) + + when (val result = getMeUseCase()) { + is ResultWrapper.Success -> { + viewState = ProfileViewState.Content( + profile = result.data, + isRefreshing = false + ) + } + is ResultWrapper.Error -> { + val currentState = viewState + val userError = mapApiErrorToUserFriendly(result.error) + + if (currentState is ProfileViewState.Content) { + viewState = currentState.copy(isRefreshing = false) + } else { + viewState = ProfileViewState.Error( + message = getErrorMessage(userError) + ) + } + } } + } finally { + isLoadingInProgress = false } } } + private fun onRefresh() { + if (isLoadingInProgress) return + + val currentState = viewState + if (currentState is ProfileViewState.Content) { + viewState = currentState.copy(isRefreshing = true) + } + + loadProfile(showLoading = false) + } + private fun onEditClick() { (viewState as? ProfileViewState.Content)?.let { content -> viewAction = ProfileAction.NavigateToEdit(content.profile) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewState.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewState.kt index 9c4c8d8..c19497e 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewState.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewState.kt @@ -7,6 +7,7 @@ sealed class ProfileViewState { data class Error(val message: String) : ProfileViewState() data class Content( val profile: Profile, - val isLoggingOut: Boolean = false + val isLoggingOut: Boolean = false, + val isRefreshing: Boolean = false ) : ProfileViewState() }