EmployeeDirectory-Observed/EmployeeDirectory-Observed/ContentView.swift
Matt Bruce d4a98106a3 added search
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-03-17 09:17:34 -05:00

440 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ContentView.swift
// EmployeeDirectory-Observed
//
// Created by Matt Bruce on 3/14/25.
//
import SwiftUI
struct ContentView: View {
let service: EmployeeDirectoryService
@Environment(\.editMode) private var editMode
var body: some View {
EmployeeDirectoryList(viewModel: .init(service: service))
.onChange(of: editMode?.wrappedValue) { oldValue, newValue in
print("Edit mode changed from \(String(describing: oldValue)) to \(String(describing: newValue))")
}
}
}
#Preview {
ContentView(service: MockEmployeeService())
}
//MARK: - Views
public struct EmployeeDirectoryList: View {
@State public var viewModel: EmployeesViewModel
@State public var path = NavigationPath()
@State private var selectedEmployees = Set<Employee.ID>()
@State private var editMode: EditMode = .inactive
@State private var searchText = ""
init(viewModel: EmployeesViewModel? = nil) {
_viewModel = .init(wrappedValue: viewModel ?? .init(service: EmployeeService()))
}
var filteredEmployees: [Employee] {
if searchText.isEmpty {
return viewModel.employees
} else {
return viewModel.employees.filter { employee in
employee.firstName.lowercased().contains(searchText.lowercased()) ||
employee.lastName.lowercased().contains(searchText.lowercased())
}
}
}
public var body: some View {
NavigationStack(path: $path) {
List(selection: $selectedEmployees) {
ForEach(filteredEmployees, id: \.id) { employee in
let employeeViewModel = EmployeeViewModel(employee: employee)
EmployeeListItem(employeeViewModel: employeeViewModel)
.onTapGesture {
if editMode != .active {
path.append(employee)
}
}
.swipeActions(edge: .leading) {
Button("Pin") {
print("\(employee.firstName) pinned")
}
.tint(.orange)
Button("Flagged") {
print("\(employee.firstName) flagged")
}
.tint(.yellow)
}
}
.onMove(perform: moveEmployee)
.onDelete(perform: deleteEmployees)
if viewModel.hasNextPage {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
.task {
await viewModel.loadNextPage()
}
}
}
.refreshable {
if editMode != .active {
await viewModel.refresh()
}
}
.searchable(text: $searchText, prompt: "Search employees by name")
.navigationTitle("Employee Directory")
.navigationDestination(for: Employee.self) { employee in
EmployeeDetailView(viewModel: .init(employee: employee))
}
.listStyle(.insetGrouped)
.task {
if viewModel.employees.isEmpty {
await viewModel.loadNextPage()
}
}
.toolbar {
EditButton()
.onTapGesture {
editMode = editMode == .active ? .inactive : .active
}
}
.toolbar {
if !selectedEmployees.isEmpty {
ToolbarItemGroup(placement: .bottomBar) {
Button("Delete") {
withAnimation {
viewModel.delete(selectedEmployees: selectedEmployees)
selectedEmployees.removeAll()
}
}
Spacer()
}
}
}
.environment(\.editMode, $editMode)
}
}
private func moveEmployee(from source: IndexSet, to destination: Int) {
viewModel.move(employeeFrom: source, to: destination)
print("moveEmployee called")
}
private func deleteEmployees(at offsets: IndexSet) {
viewModel.delete(employeeAt: offsets)
print("deleteEmployees called")
}
}
public struct EmployeeListItem: View {
public let employeeViewModel: EmployeeViewModel
public var body: some View {
HStack(spacing: 10) {
ProfileImageView(urlString: employeeViewModel.smallPhoto, size: 51)
VStack(alignment: .leading, spacing: 5) {
Text(employeeViewModel.fullName)
.font(.headline)
if let bio = employeeViewModel.biography {
Text(bio)
.font(.footnote)
.foregroundColor(.gray)
.lineLimit(2)
}
Label("Email: \(employeeViewModel.emailAddress)", systemImage: "envelope.fill")
.foregroundColor(.gray)
.font(.caption)
if let phoneNumber = employeeViewModel.phoneNumber {
Label("Call: \(phoneNumber)", systemImage: "phone.fill")
.foregroundColor(.gray)
.font(.caption)
}
}
Spacer()
}
.contentShape(Rectangle())
}
}
#Preview("EmployeeListItem") {
EmployeeListItem(employeeViewModel: .init(employee: MockEmployeeService.sample))
}
public struct EmployeeDetailView: View {
public let viewModel: EmployeeViewModel
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Profile Image with Default Placeholder
ProfileImageView(urlString: viewModel.largePhoto, size: 250) // Uses reusable component
.padding(.bottom)
Text(viewModel.fullName)
.font(.headline)
if let bio = viewModel.biography {
Text(bio)
.font(.footnote)
.foregroundColor(.gray)
.lineLimit(2)
}
Label("Email: \(viewModel.emailAddress)", systemImage: "envelope.fill")
.foregroundColor(viewModel.emailAddressURL != nil ? .blue : .gray)
.font(.caption)
.onTapGesture {
if let url = viewModel.emailAddressURL {
UIApplication.shared.open(url)
}
}
if let phoneNumber = viewModel.phoneNumber {
Label("Call: \(phoneNumber)", systemImage: "phone.fill")
.foregroundColor(viewModel.phoneNumberURL != nil ? .blue : .gray)
.font(.caption)
.onTapGesture {
if let url = viewModel.phoneNumberURL {
UIApplication.shared.open(url)
}
}
}
Spacer()
}
.navigationTitle("Employee Details")
.padding(.all)
}
}
public struct ProfileImageView: View {
public let urlString: String?
public let size: CGFloat
public var body: some View {
return AsyncImage(url: URL(string: urlString ?? "")) { phase in
if let image = phase.image {
image.resizable()
} else if phase.error != nil || urlString == nil {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundColor(.gray)
} else {
ProgressView()
.scaleEffect(min(size / 50, 2)) // Dynamically adjust based on `size`
}
}
.scaledToFill()
.frame(width: size, height: size)
.clipShape(.circle)
.overlay(Circle().stroke(Color.gray, lineWidth: 1))
}
}
//MARK: - ViewModels
@Observable
public class EmployeesViewModel {
public var employees: [Employee] = []
public var isLoading: Bool = false
public var hasNextPage: Bool = true
private var currentPage: Int = 1
private let service: EmployeeDirectoryService
public init(service: EmployeeDirectoryService = EmployeeService()) {
self.service = service
}
public func loadNextPage() async {
guard !isLoading else { return }
isLoading = true
do {
let response = try await service.fetchEmployees(page: currentPage)
employees.append(contentsOf: response.result)
hasNextPage = response.hasNextPage
currentPage += 1
} catch {
print("Error loading employees: \(error)")
}
isLoading = false
}
public func refresh() async {
currentPage = 1
employees.removeAll()
await loadNextPage()
}
public func move(employeeFrom source: IndexSet, to destination: Int) {
employees.move(fromOffsets: source, toOffset: destination)
}
public func delete(employeeAt offsets: IndexSet) {
employees.remove(atOffsets: offsets)
}
public func delete(employee: Employee) {
employees.removeAll { $0.id == employee.id }
}
public func delete(selectedEmployees: Set<Employee.ID>) {
employees.removeAll { selectedEmployees.contains($0.id) }
}
}
public struct EmployeeViewModel {
// MARK: - Properties
private let employee: Employee
public let uuid: String
public let fullName: String
public let phoneNumber: String?
public let emailAddress: String
public let biography: String?
public let smallPhoto: String?
public let largePhoto: String?
public let emailAddressURL: URL?
public let phoneNumberURL: URL?
// MARK: - Initializer
public init(employee: Employee) {
self.employee = employee
uuid = employee.uuid.uuidString
fullName = "\(employee.firstName) \(employee.lastName)"
phoneNumber = employee.phoneNumber
emailAddress = employee.emailAddress
biography = employee.biography
smallPhoto = employee.photoURLSmall
largePhoto = employee.photoURLLarge
if let url = URL(string: "mailto:\(emailAddress)"), UIApplication.shared.canOpenURL(url) {
emailAddressURL = url
} else { emailAddressURL = nil }
if let phoneNumber, let url = URL(string: "tel:\(phoneNumber)"), UIApplication.shared.canOpenURL(url) {
phoneNumberURL = url
} else { phoneNumberURL = nil }
}
}
//MARK: - Protocols
public protocol EmployeeDirectoryService {
func fetchEmployees(page: Int?) async throws -> Employees
}
//MARK: - Services
public class EmployeeService: EmployeeDirectoryService {
public init() {}
public func fetchEmployees(page: Int? = nil) async throws -> Employees {
var endpoint = "https://my.api.mockaroo.com/users.json?key=f298b840"
if let page {
endpoint += "&page=\(page)"
}
return try await NetworkService.shared.fetch(endpoint, type: Employees.self)
}
}
struct MockEmployeeService: EmployeeDirectoryService {
static let sample = createUser(page: 1, index: 1)
static func createUser(page: Int?, index: Int) -> Employee {
let id = UUID()
return Employee(
uuid: id,
firstName: "First \(index + ((page ?? 1) - 1) * 20)",
lastName: "Last \(index + ((page ?? 1) - 1) * 20)",
phoneNumber: "555555\(1000 + index)",
emailAddress: "user\(index)@example.com",
biography: "Biography for employee \(index)",
photoURLSmall: "https://robohash.org/\(id.uuidString).png?size=100x100&set=set1",
photoURLLarge: "https://robohash.org/\(id.uuidString).png?size=400x400&set=set1"
)
}
func fetchEmployees(page: Int?) async throws -> Employees {
// Simulate network delay.
try await Task.sleep(nanoseconds: 500_000_000)
// Create dummy data (20 employees per page).
let dummyEmployees: [Employee] = (0..<20).map { i in
Self.createUser(page: page, index: i)
}
// Simulate that there are more pages if page is less than 5.
let hasNext = (page ?? 1) < 5
return Employees(result: dummyEmployees, hasNextPage: hasNext)
}
}
public class NetworkService {
public static let shared = NetworkService()
public init() {}
public func fetch<T: Decodable>(_ endpoint: String, type: T.Type) async throws -> T {
guard let url = URL(string: endpoint) else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
//MARK: - Models
public struct Employee: Codable, Identifiable, CustomStringConvertible, Hashable {
public var id: UUID { uuid }
/// The unique identifier for the employee. Represented as a UUID.
public let uuid: UUID
/// The first name of the employee.
public let firstName: String
/// The last name of the employee.
public let lastName: String
/// The phone number of the employee, sent as an unformatted string (eg, 5556661234).
public let phoneNumber: String?
/// The email address of the employee.
public let emailAddress: String
/// A short, tweet-length (~300 chars) string that the employee provided to describe themselves.
public let biography: String?
/// The URL of the employees small photo. Useful for list view.
public let photoURLSmall: String?
/// The URL of the employees full-size photo.
public let photoURLLarge: String?
private enum CodingKeys: String, CodingKey {
case uuid
case firstName = "first_name"
case lastName = "last_name"
case phoneNumber = "phone_number"
case emailAddress = "email_address"
case biography
case photoURLSmall = "photo_url_small"
case photoURLLarge = "photo_url_large"
}
public var description: String {
"\(firstName) \(lastName)"
}
}
public struct Employees: Codable {
/// Array of Employees
public var result: [Employee]
public var hasNextPage: Bool = false
}