diff --git a/Sample Project/swiftui material components proto/swiftui material components proto/ContentView.swift b/Sample Project/swiftui material components proto/swiftui material components proto/ContentView.swift index 0655691..166e68e 100644 --- a/Sample Project/swiftui material components proto/swiftui material components proto/ContentView.swift +++ b/Sample Project/swiftui material components proto/swiftui material components proto/ContentView.swift @@ -2,50 +2,514 @@ // ContentView.swift // swiftui material components proto // -// Created by Kristhian De Oliveira on 2/1/23. +// Material Design 3 component showcase for MattiUI. +// Demonstrates all M3 components with SwiftUI previews. // import SwiftUI import MattiUI +// MARK: - ContentView (root navigation) + struct ContentView: View { + @State private var selectedTab = 0 + var body: some View { - ContainerWithFloatingButton(buttonContent: {Text("Help")}, backgroundColor: .black, bodyContent: { - VStack { - Card(width: 300, height: 200) { - Rating(values: 4, starsSelected: { stars in - print("\(stars) stars") - - }) + VStack(spacing: 0) { + // Content area + Group { + switch selectedTab { + case 0: ButtonsShowcaseView() + case 1: CardsAndChipsShowcaseView() + case 2: InputsShowcaseView() + default: MiscShowcaseView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // M3 NavigationBar + NavigationBar( + items: [ + NavigationBarItem(icon: Image(systemName: "button.horizontal"), selectedIcon: Image(systemName: "button.horizontal.fill"), label: "Buttons"), + NavigationBarItem(icon: Image(systemName: "rectangle.on.rectangle"), selectedIcon: Image(systemName: "rectangle.fill.on.rectangle.fill"), label: "Cards"), + NavigationBarItem(icon: Image(systemName: "keyboard"), selectedIcon: Image(systemName: "keyboard.fill"), label: "Inputs"), + NavigationBarItem(icon: Image(systemName: "sparkles"), label: "Misc"), + ], + selectedIndex: $selectedTab + ) + } + .materialTheme(.light) + .ignoresSafeArea(edges: .bottom) + } +} + +// MARK: - Buttons Showcase + +struct ButtonsShowcaseView: View { + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + TopAppBar(title: "Buttons & FAB") + + SectionHeader("Filled Button") + ShowcaseRow { + FilledButton("Save") {} + FilledButton("Upload", icon: Image(systemName: "arrow.up")) {} + } + + SectionHeader("Outlined Button") + ShowcaseRow { + OutlinedButton("Cancel") {} + OutlinedButton("Share", icon: Image(systemName: "square.and.arrow.up")) {} + } + + SectionHeader("Text Button") + ShowcaseRow { + TextButton("Learn more") {} + TextButton("Open", icon: Image(systemName: "arrow.right")) {} + } + + SectionHeader("Elevated Button") + ShowcaseRow { + ElevatedButton("Reply") {} + ElevatedButton("Add Photo", icon: Image(systemName: "photo")) {} + } + + SectionHeader("Filled Tonal Button") + ShowcaseRow { + FilledTonalButton("Explore") {} + FilledTonalButton("Location", icon: Image(systemName: "location.fill")) {} + } + + SectionHeader("Disabled States") + ShowcaseRow { + FilledButton("Disabled") {}.disabled(true) + OutlinedButton("Disabled") {}.disabled(true) + TextButton("Disabled") {}.disabled(true) + } + + SectionHeader("Floating Action Buttons") + HStack(spacing: 20) { + VStack(spacing: 4) { + SmallFloatingActionButton(icon: Image(systemName: "pencil")) {} + Text("Small").font(.caption2) + } + VStack(spacing: 4) { + FloatingActionButton(icon: Image(systemName: "plus")) {} + Text("FAB").font(.caption2) + } + VStack(spacing: 4) { + LargeFloatingActionButton(icon: Image(systemName: "camera")) {} + Text("Large").font(.caption2) + } } - Card(width: 300, height: 200) { - MaterialDateTimePicker(month: 2, year: 2023, DaysSelected: [], onDateChange: {days in - print("days: \(days)") - }).padding() + .frame(maxWidth: .infinity) + .padding() + + SectionHeader("Extended FAB") + ShowcaseRow { + ExtendedFloatingActionButton("Compose", icon: Image(systemName: "pencil")) {} + ExtendedFloatingActionButton("New Trip") {} } - Card(width: 200, height: 100, bodyContent: { + + MaterialDivider().padding(.top, 16) + } + } + } +} + +// MARK: - Cards & Chips Showcase + +struct CardsAndChipsShowcaseView: View { + @State private var filter1 = true + @State private var filter2 = false + @State private var filter3 = false + @State private var chips = ["SwiftUI", "Kotlin", "Material 3"] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + TopAppBar(title: "Cards & Chips") + + SectionHeader("Elevated Card") + ElevatedCard { + SampleCardContent(title: "Elevated Card", subtitle: "Uses a drop shadow for elevation") + } + .padding(.horizontal) + .padding(.bottom, 8) + + SectionHeader("Filled Card") + FilledCard { + SampleCardContent(title: "Filled Card", subtitle: "Uses surfaceVariant color, no shadow") + } + .padding(.horizontal) + .padding(.bottom, 8) + + SectionHeader("Outlined Card") + OutlinedCard { + SampleCardContent(title: "Outlined Card", subtitle: "Uses a border stroke, no shadow") + } + .padding(.horizontal) + .padding(.bottom, 8) + + SectionHeader("Assist Chips") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + AssistChip("Set reminder", icon: Image(systemName: "bell")) {} + AssistChip("Directions", icon: Image(systemName: "location")) {} + AssistChip("Add to calendar", icon: Image(systemName: "calendar.badge.plus")) {} + } + .padding(.horizontal) + } + .padding(.bottom, 8) + + SectionHeader("Filter Chips") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip("Sci-Fi", isSelected: $filter1) + FilterChip("Drama", isSelected: $filter2) + FilterChip("Comedy", isSelected: $filter3) + } + .padding(.horizontal) + } + .padding(.bottom, 8) + + SectionHeader("Input Chips (tap × to remove)") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(chips, id: \.self) { chip in + InputChip(chip, icon: Image(systemName: "tag")) { + chips.removeAll { $0 == chip } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 8) + + SectionHeader("Suggestion Chips") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + SuggestionChip("Reply all", icon: Image(systemName: "arrowshape.turn.up.left.2")) {} + SuggestionChip("Forward") {} + SuggestionChip("Snooze", icon: Image(systemName: "moon")) {} + } + .padding(.horizontal) + } + .padding(.bottom, 16) + + MaterialDivider() + } + } + } +} + +// MARK: - Inputs Showcase + +struct InputsShowcaseView: View { + @State private var email = "" + @State private var password = "" + @State private var username = "johndoe" + @State private var switchOn = true + @State private var switchOff = false + @State private var check1 = true + @State private var check2 = false + @State private var radioSelection = "Option B" + let radioOptions = ["Option A", "Option B", "Option C"] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + TopAppBar(title: "Inputs & Selection") + + SectionHeader("Filled Text Field") + VStack(spacing: 12) { + FilledTextField("Email address", text: $email, + leadingIcon: Image(systemName: "envelope"), + supportingText: "We'll never share your email") + FilledTextField("Password", text: $password, isSecure: true, + trailingIcon: Image(systemName: "eye.slash")) + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Outlined Text Field") + VStack(spacing: 12) { + OutlinedTextField("Username", text: $username, + leadingIcon: Image(systemName: "person")) + OutlinedTextField("Disabled", text: .constant("")) + .disabled(true) + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Switch") + VStack(spacing: 4) { HStack { - Text("Face ID") - MaterialToggle(color: .green, isOn: true, didChange: {value in }) + Text("Airplane mode") + Spacer() + MaterialSwitch(isOn: $switchOn, thumbIcon: Image(systemName: "checkmark")) } - }) + HStack { + Text("Bluetooth") + Spacer() + MaterialSwitch(isOn: $switchOff) + } + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Checkbox") + VStack(alignment: .leading, spacing: 0) { + HStack { MaterialCheckbox(isChecked: $check1); Text("Enable notifications") } + HStack { MaterialCheckbox(isChecked: $check2); Text("Dark mode") } + HStack { MaterialCheckbox(isChecked: .constant(true)).disabled(true); Text("Disabled (checked)").foregroundColor(.secondary) } + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Radio Button") + MaterialRadioGroup(options: radioOptions, selection: $radioSelection) { $0 } + .padding(.horizontal) + .padding(.bottom, 16) + + MaterialDivider() + } + } + } +} + +// MARK: - Misc Showcase + +struct MiscShowcaseView: View { + @State private var showDialog = false + @State private var snack: SnackbarData? = nil + @State private var progress = 0.6 + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + TopAppBar(title: "Progress, Badges & More") + + SectionHeader("Linear Progress Indicator") + VStack(spacing: 16) { + LinearProgressIndicator() + LinearProgressIndicator(progress: progress) + Slider(value: $progress, in: 0...1) + .tint(Color(m3hex: "#6750A4")) + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Circular Progress Indicator") + HStack(spacing: 32) { + VStack(spacing: 8) { + CircularProgressIndicator() + Text("Indeterminate").font(.caption2) + } + VStack(spacing: 8) { + CircularProgressIndicator(progress: progress) + Text("Determinate").font(.caption2) + } + VStack(spacing: 8) { + CircularProgressIndicator(progress: 0.85, size: 64, strokeWidth: 6) + Text("Large").font(.caption2) + } + } + .frame(maxWidth: .infinity) .padding() - MaterialButton(tittle: "Cancel", shape: .rounded, style: .outline, action: { - - }, backgroundColor: .red, textColor: .red, icon: {}) - MaterialButton(tittle: "Done", shape: .rounded, style: .fill, action: { - - }, backgroundColor: .green, textColor: .white, icon: {}) + .padding(.bottom, 12) + + SectionHeader("Badges") + HStack(spacing: 32) { + VStack(spacing: 8) { + BadgedBox(badge: Badge()) { + Image(systemName: "bell").resizable().scaledToFit().frame(width: 28, height: 28) + } + Text("Dot").font(.caption2) + } + VStack(spacing: 8) { + BadgedBox(badge: Badge(count: 5)) { + Image(systemName: "envelope").resizable().scaledToFit().frame(width: 28, height: 28) + } + Text("Count").font(.caption2) + } + VStack(spacing: 8) { + BadgedBox(badge: Badge(count: 1200)) { + Image(systemName: "message").resizable().scaledToFit().frame(width: 28, height: 28) + } + Text("999+").font(.caption2) + } + } + .frame(maxWidth: .infinity) + .padding() + .padding(.bottom, 12) + + SectionHeader("Divider") + VStack(spacing: 12) { + Text("Full-width").frame(maxWidth: .infinity, alignment: .leading) + MaterialDivider() + Text("Inset (16 pt)").frame(maxWidth: .infinity, alignment: .leading) + MaterialDivider(inset: 16) + HStack(spacing: 16) { + Text("Vertical") + MaterialDivider(isVertical: true, length: 20) + Text("Divider") + } + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Alert Dialog") + FilledButton("Show Alert Dialog") { showDialog = true } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Snackbar") + VStack(spacing: 8) { + FilledButton("Simple Snackbar") { + snack = SnackbarData(message: "Connection restored") + } + FilledTonalButton("Snackbar with Action") { + snack = SnackbarData(message: "Item deleted", actionLabel: "Undo") + } + } + .padding(.horizontal) + .padding(.bottom, 12) + + SectionHeader("Top App Bar variants") + VStack(spacing: 2) { + TopAppBar(title: "Small App Bar", + navigationIcon: Image(systemName: "chevron.left"), + actions: [TopAppBarAction(icon: Image(systemName: "ellipsis.circle"), action: {})]) + MaterialDivider() + CenterAlignedTopAppBar( + title: "Centered", + navigationIcon: Image(systemName: "chevron.left"), + actions: [TopAppBarAction(icon: Image(systemName: "magnifyingglass"), action: {})] + ) + MaterialDivider() + MediumTopAppBar(title: "Medium App Bar", + navigationIcon: Image(systemName: "chevron.left")) + MaterialDivider() + LargeTopAppBar(title: "Large App Bar", + navigationIcon: Image(systemName: "chevron.left")) + } + .padding(.bottom, 16) + + MaterialDivider() + } + } + .overlay { + AlertDialog( + isPresented: $showDialog, + icon: Image(systemName: "trash"), + title: "Delete item?", + text: "This action cannot be undone. The item will be permanently removed.", + confirmButton: AlertDialogButton(label: "Delete", role: .destructive) { showDialog = false }, + dismissButton: AlertDialogButton(label: "Cancel") { showDialog = false } + ) + } + .snackbar(data: $snack, onAction: { print("Snackbar action tapped") }) + } +} + +// MARK: - Reusable helpers + +private struct SectionHeader: View { + let title: String + init(_ title: String) { self.title = title } + + var body: some View { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + .padding(.horizontal) + .padding(.top, 20) + .padding(.bottom, 8) + } +} + +private struct ShowcaseRow: View { + let content: () -> Content + init(@ViewBuilder content: @escaping () -> Content) { self.content = content } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { content() } + .padding(.horizontal) + } + .padding(.bottom, 8) + } +} + +private struct SampleCardContent: View { + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "photo") + .resizable() + .scaledToFit() + .frame(height: 80) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.15)) + .clipped() + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle).font(.subheadline).foregroundColor(.secondary) } - .padding() - }) { - + .padding([.horizontal, .bottom]) } } } +// MARK: - Previews + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() + .materialTheme(.light) + .previewDisplayName("M3 Showcase — Light") + + ContentView() + .materialTheme(.dark) + .previewDisplayName("M3 Showcase — Dark") + } +} + +// MARK: - Individual section previews + +struct ButtonsShowcase_Previews: PreviewProvider { + static var previews: some View { + ButtonsShowcaseView() + .materialTheme(.light) + .previewDisplayName("Buttons Showcase — Light") + } +} + +struct CardsChips_Previews: PreviewProvider { + static var previews: some View { + CardsAndChipsShowcaseView() + .materialTheme(.light) + .previewDisplayName("Cards & Chips — Light") + } +} + +struct Inputs_Previews: PreviewProvider { + static var previews: some View { + InputsShowcaseView() + .materialTheme(.light) + .previewDisplayName("Inputs & Selection — Light") + } +} + +struct Misc_Previews: PreviewProvider { + static var previews: some View { + MiscShowcaseView() + .materialTheme(.light) + .previewDisplayName("Progress, Badges & More — Light") } } diff --git a/Sources/MattiUI/AppBars/TopAppBar.swift b/Sources/MattiUI/AppBars/TopAppBar.swift new file mode 100644 index 0000000..156730d --- /dev/null +++ b/Sources/MattiUI/AppBars/TopAppBar.swift @@ -0,0 +1,312 @@ +// +// TopAppBar.swift +// MattiUI +// +// Material Design 3 — Top App Bar variants +// Reference: https://m3.material.io/components/top-app-bar/specs + +import SwiftUI + +// MARK: - TopAppBar (Small / Default) + +/// A Material Design 3 **Top App Bar** (small, 64 pt tall). +/// +/// Equivalent to `TopAppBar` / `SmallTopAppBar` in Jetpack Compose. +/// +/// ```swift +/// TopAppBar(title: "Inbox") +/// TopAppBar(title: "Settings", navigationIcon: Image(systemName: "chevron.left")) { /* back */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct TopAppBar: View { + @Environment(\.materialTheme) private var theme + + public var title: String + public var navigationIcon: Image? + public var navigationAction: (() -> Void)? + public var actions: [TopAppBarAction] + + public init( + title: String, + navigationIcon: Image? = nil, + navigationAction: (() -> Void)? = nil, + actions: [TopAppBarAction] = [] + ) { + self.title = title + self.navigationIcon = navigationIcon + self.navigationAction = navigationAction + self.actions = actions + } + + public var body: some View { + HStack(spacing: 4) { + if let navIcon = navigationIcon { + Button(action: { navigationAction?() }) { + navIcon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurface) + .padding(8) + } + } + + Text(title) + .font(theme.typography.titleLarge.font) + .foregroundColor(theme.colorScheme.onSurface) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, navigationIcon == nil ? 16 : 4) + + ForEach(actions.indices, id: \.self) { i in + Button(action: actions[i].action) { + actions[i].icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurfaceVariant) + .padding(8) + } + } + } + .frame(height: 64) + .padding(.horizontal, 4) + .background(theme.colorScheme.surface) + } +} + +// MARK: - CenterAlignedTopAppBar + +/// A Material Design 3 **Center-Aligned Top App Bar** — title centered. +/// +/// Equivalent to `CenterAlignedTopAppBar` in Jetpack Compose. +@available(iOS 15, macOS 12.0, *) +public struct CenterAlignedTopAppBar: View { + @Environment(\.materialTheme) private var theme + + public var title: String + public var navigationIcon: Image? + public var navigationAction: (() -> Void)? + public var actions: [TopAppBarAction] + + public init( + title: String, + navigationIcon: Image? = nil, + navigationAction: (() -> Void)? = nil, + actions: [TopAppBarAction] = [] + ) { + self.title = title + self.navigationIcon = navigationIcon + self.navigationAction = navigationAction + self.actions = actions + } + + public var body: some View { + ZStack { + Text(title) + .font(theme.typography.titleLarge.font) + .foregroundColor(theme.colorScheme.onSurface) + + HStack { + if let navIcon = navigationIcon { + Button(action: { navigationAction?() }) { + navIcon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurface) + .padding(8) + } + } + Spacer() + ForEach(actions.indices, id: \.self) { i in + Button(action: actions[i].action) { + actions[i].icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurfaceVariant) + .padding(8) + } + } + } + .padding(.horizontal, 4) + } + .frame(height: 64) + .background(theme.colorScheme.surface) + } +} + +// MARK: - MediumTopAppBar + +/// A Material Design 3 **Medium Top App Bar** (112 pt tall) — title in a second row, larger. +/// +/// Equivalent to `MediumTopAppBar` in Jetpack Compose. +@available(iOS 15, macOS 12.0, *) +public struct MediumTopAppBar: View { + @Environment(\.materialTheme) private var theme + + public var title: String + public var navigationIcon: Image? + public var navigationAction: (() -> Void)? + public var actions: [TopAppBarAction] + + public init( + title: String, + navigationIcon: Image? = nil, + navigationAction: (() -> Void)? = nil, + actions: [TopAppBarAction] = [] + ) { + self.title = title + self.navigationIcon = navigationIcon + self.navigationAction = navigationAction + self.actions = actions + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + if let navIcon = navigationIcon { + Button(action: { navigationAction?() }) { + navIcon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurface) + .padding(8) + } + } + Spacer() + ForEach(actions.indices, id: \.self) { i in + Button(action: actions[i].action) { + actions[i].icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurfaceVariant) + .padding(8) + } + } + } + .frame(height: 64) + .padding(.horizontal, 4) + + Text(title) + .font(theme.typography.headlineSmall.font) + .foregroundColor(theme.colorScheme.onSurface) + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .background(theme.colorScheme.surface) + } +} + +// MARK: - LargeTopAppBar + +/// A Material Design 3 **Large Top App Bar** (152 pt tall) — prominent headline title. +/// +/// Equivalent to `LargeTopAppBar` in Jetpack Compose. +@available(iOS 15, macOS 12.0, *) +public struct LargeTopAppBar: View { + @Environment(\.materialTheme) private var theme + + public var title: String + public var navigationIcon: Image? + public var navigationAction: (() -> Void)? + public var actions: [TopAppBarAction] + + public init( + title: String, + navigationIcon: Image? = nil, + navigationAction: (() -> Void)? = nil, + actions: [TopAppBarAction] = [] + ) { + self.title = title + self.navigationIcon = navigationIcon + self.navigationAction = navigationAction + self.actions = actions + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + if let navIcon = navigationIcon { + Button(action: { navigationAction?() }) { + navIcon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurface) + .padding(8) + } + } + Spacer() + ForEach(actions.indices, id: \.self) { i in + Button(action: actions[i].action) { + actions[i].icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onSurfaceVariant) + .padding(8) + } + } + } + .frame(height: 64) + .padding(.horizontal, 4) + + Text(title) + .font(theme.typography.headlineMedium.font) + .foregroundColor(theme.colorScheme.onSurface) + .padding(.horizontal, 16) + .padding(.bottom, 28) + } + .background(theme.colorScheme.surface) + } +} + +// MARK: - TopAppBarAction + +/// An action button placed in the top app bar's trailing area. +@available(iOS 15, macOS 12.0, *) +public struct TopAppBarAction { + public var icon: Image + public var action: () -> Void + + public init(icon: Image, action: @escaping () -> Void) { + self.icon = icon + self.action = action + } +} + +// MARK: - Previews + +@available(iOS 15, macOS 12.0, *) +struct TopAppBar_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 2) { + TopAppBar( + title: "Top App Bar", + navigationIcon: Image(systemName: "chevron.left"), + actions: [ + TopAppBarAction(icon: Image(systemName: "heart"), action: {}), + TopAppBarAction(icon: Image(systemName: "ellipsis.circle"), action: {}) + ] + ) + + CenterAlignedTopAppBar( + title: "Centered", + navigationIcon: Image(systemName: "chevron.left"), + actions: [TopAppBarAction(icon: Image(systemName: "ellipsis.circle"), action: {})] + ) + + MediumTopAppBar(title: "Medium App Bar", + navigationIcon: Image(systemName: "chevron.left"), + actions: [TopAppBarAction(icon: Image(systemName: "magnifyingglass"), action: {})]) + + LargeTopAppBar(title: "Large App Bar", + navigationIcon: Image(systemName: "chevron.left")) + } + .materialTheme(.light) + .previewDisplayName("TopAppBar variants — Light") + } +} diff --git a/Sources/MattiUI/Badges/BadgedBox.swift b/Sources/MattiUI/Badges/BadgedBox.swift new file mode 100644 index 0000000..12b6766 --- /dev/null +++ b/Sources/MattiUI/Badges/BadgedBox.swift @@ -0,0 +1,119 @@ +// +// BadgedBox.swift +// MattiUI +// +// Material Design 3 — Badge / BadgedBox +// Reference: https://m3.material.io/components/badges/specs + +import SwiftUI + +// MARK: - Badge + +/// A Material Design 3 **Badge** — a small status indicator showing a count or dot. +/// +/// Equivalent to `Badge` in Jetpack Compose. +/// +/// ```swift +/// Badge() // dot badge +/// Badge(count: 3) // numeric badge +/// Badge(count: 1000) // shows "999+" +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct Badge: View { + @Environment(\.materialTheme) private var theme + + public var count: Int? + + public init(count: Int? = nil) { + self.count = count + } + + private var displayText: String? { + guard let count = count else { return nil } + return count > 999 ? "999+" : "\(count)" + } + + public var body: some View { + Group { + if let text = displayText { + Text(text) + .font(theme.typography.labelSmall.font) + .foregroundColor(theme.colorScheme.onError) + .padding(.horizontal, 4) + .frame(minWidth: 16, minHeight: 16) + .background( + Capsule() + .fill(theme.colorScheme.error) + ) + } else { + // Dot badge + Circle() + .fill(theme.colorScheme.error) + .frame(width: 6, height: 6) + } + } + } +} + +// MARK: - BadgedBox + +/// Wraps content with an optional `Badge` anchored to the top-trailing corner. +/// +/// Equivalent to `BadgedBox` in Jetpack Compose. +/// +/// ```swift +/// BadgedBox(badge: Badge(count: 5)) { +/// Image(systemName: "bell") +/// } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct BadgedBox: View { + public var badge: Badge + public var content: () -> Content + + public init(badge: Badge, @ViewBuilder content: @escaping () -> Content) { + self.badge = badge + self.content = content + } + + public var body: some View { + ZStack(alignment: .topTrailing) { + content() + badge + .offset(x: 6, y: -6) + } + } +} + +// MARK: - Previews + +@available(iOS 15, macOS 12.0, *) +struct Badge_Previews: PreviewProvider { + static var previews: some View { + HStack(spacing: 32) { + BadgedBox(badge: Badge()) { + Image(systemName: "bell") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + + BadgedBox(badge: Badge(count: 3)) { + Image(systemName: "envelope") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + + BadgedBox(badge: Badge(count: 1200)) { + Image(systemName: "message") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + } + .padding() + .materialTheme(.light) + .previewDisplayName("Badge / BadgedBox — Light") + } +} diff --git a/Sources/MattiUI/Buttons/ElevatedButton.swift b/Sources/MattiUI/Buttons/ElevatedButton.swift new file mode 100644 index 0000000..b25eb06 --- /dev/null +++ b/Sources/MattiUI/Buttons/ElevatedButton.swift @@ -0,0 +1,73 @@ +// +// ElevatedButton.swift +// MattiUI +// +// Material Design 3 — Elevated Button +// Reference: https://m3.material.io/components/buttons/specs#fd9f6b5b-7c5b-4c83-91d5-69f70c9e3b5b + +import SwiftUI + +/// A Material Design 3 **Elevated** button — low emphasis with a shadow for distinction. +/// +/// Equivalent to `ElevatedButton` in Jetpack Compose. +/// +/// ```swift +/// ElevatedButton("Reply") { /* action */ } +/// ElevatedButton("Add Photo", icon: Image(systemName: "photo")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct ElevatedButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .padding(.horizontal, icon != nil ? 16 : 24) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.opacity(0.38)) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.full, style: .continuous) + .fill(isEnabled ? theme.colorScheme.surface : theme.colorScheme.onSurface.opacity(0.12)) + .shadow(color: isEnabled ? theme.colorScheme.scrim.opacity(0.3) : .clear, radius: 1, x: 0, y: 1) + .shadow(color: isEnabled ? theme.colorScheme.scrim.opacity(0.15) : .clear, radius: 2, x: 0, y: 2) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct ElevatedButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + ElevatedButton("Elevated Button") {} + ElevatedButton("With Icon", icon: Image(systemName: "photo")) {} + ElevatedButton("Disabled") {} + .disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("ElevatedButton — Light") + } +} diff --git a/Sources/MattiUI/Buttons/FilledButton.swift b/Sources/MattiUI/Buttons/FilledButton.swift new file mode 100644 index 0000000..e094d17 --- /dev/null +++ b/Sources/MattiUI/Buttons/FilledButton.swift @@ -0,0 +1,80 @@ +// +// FilledButton.swift +// MattiUI +// +// Material Design 3 — Filled Button +// Reference: https://m3.material.io/components/buttons/specs#0b1b7bd2-3de8-431a-afa1-d692e2e18b0d + +import SwiftUI + +/// A Material Design 3 **Filled** button — the highest emphasis button. +/// +/// Equivalent to `Button` with a `ButtonDefaults.filledButtonColors()` style in Jetpack Compose. +/// +/// ```swift +/// FilledButton("Save") { /* action */ } +/// FilledButton("Upload", icon: Image(systemName: "arrow.up")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FilledButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .padding(.horizontal, icon != nil ? 16 : 24) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface.opacity(0.38)) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.full, style: .continuous) + .fill(isEnabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.opacity(0.12)) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct FilledButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + FilledButton("Filled Button") {} + FilledButton("With Icon", icon: Image(systemName: "star.fill")) {} + FilledButton("Disabled") {} + .disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("FilledButton — Light") + + VStack(spacing: 16) { + FilledButton("Filled Button") {} + FilledButton("With Icon", icon: Image(systemName: "star.fill")) {} + } + .padding() + .background(Color(m3hex: "#1C1B1F")) + .materialTheme(.dark) + .previewDisplayName("FilledButton — Dark") + } +} diff --git a/Sources/MattiUI/Buttons/FilledTonalButton.swift b/Sources/MattiUI/Buttons/FilledTonalButton.swift new file mode 100644 index 0000000..ad0035b --- /dev/null +++ b/Sources/MattiUI/Buttons/FilledTonalButton.swift @@ -0,0 +1,71 @@ +// +// FilledTonalButton.swift +// MattiUI +// +// Material Design 3 — Filled Tonal Button +// Reference: https://m3.material.io/components/buttons/specs#158f0a18-67fb-4ac4-9d22-cc4d1adc4579 + +import SwiftUI + +/// A Material Design 3 **Filled Tonal** button — medium-high emphasis using secondary container color. +/// +/// Equivalent to `FilledTonalButton` in Jetpack Compose. +/// +/// ```swift +/// FilledTonalButton("Explore") { /* action */ } +/// FilledTonalButton("Location", icon: Image(systemName: "location.fill")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FilledTonalButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .padding(.horizontal, icon != nil ? 16 : 24) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? theme.colorScheme.onSecondaryContainer : theme.colorScheme.onSurface.opacity(0.38)) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.full, style: .continuous) + .fill(isEnabled ? theme.colorScheme.secondaryContainer : theme.colorScheme.onSurface.opacity(0.12)) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct FilledTonalButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + FilledTonalButton("Filled Tonal") {} + FilledTonalButton("With Icon", icon: Image(systemName: "location.fill")) {} + FilledTonalButton("Disabled") {} + .disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("FilledTonalButton — Light") + } +} diff --git a/Sources/MattiUI/Buttons/FloatingActionButton.swift b/Sources/MattiUI/Buttons/FloatingActionButton.swift new file mode 100644 index 0000000..8688745 --- /dev/null +++ b/Sources/MattiUI/Buttons/FloatingActionButton.swift @@ -0,0 +1,186 @@ +// +// FloatingActionButton.swift +// MattiUI +// +// Material Design 3 — Floating Action Button variants +// Reference: https://m3.material.io/components/floating-action-button/specs + +import SwiftUI + +// MARK: - FloatingActionButton + +/// A Material Design 3 standard **Floating Action Button** (56×56 pt). +/// +/// Equivalent to `FloatingActionButton` in Jetpack Compose. +/// +/// ```swift +/// FloatingActionButton(icon: Image(systemName: "plus")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FloatingActionButton: View { + @Environment(\.materialTheme) private var theme + + public var icon: Image + public var action: () -> Void + + public init(icon: Image, action: @escaping () -> Void) { + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onPrimaryContainer) + .frame(width: 56, height: 56) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.large, style: .continuous) + .fill(theme.colorScheme.primaryContainer) + .shadow(color: theme.colorScheme.scrim.opacity(0.3), radius: 3, x: 0, y: 1) + .shadow(color: theme.colorScheme.scrim.opacity(0.15), radius: 8, x: 0, y: 3) + ) + } +} + +// MARK: - SmallFloatingActionButton + +/// A Material Design 3 **Small FAB** (40×40 pt) — for less prominent actions. +/// +/// Equivalent to `SmallFloatingActionButton` in Jetpack Compose. +@available(iOS 15, macOS 12.0, *) +public struct SmallFloatingActionButton: View { + @Environment(\.materialTheme) private var theme + + public var icon: Image + public var action: () -> Void + + public init(icon: Image, action: @escaping () -> Void) { + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.onPrimaryContainer) + .frame(width: 40, height: 40) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.medium, style: .continuous) + .fill(theme.colorScheme.primaryContainer) + .shadow(color: theme.colorScheme.scrim.opacity(0.3), radius: 3, x: 0, y: 1) + .shadow(color: theme.colorScheme.scrim.opacity(0.15), radius: 8, x: 0, y: 3) + ) + } +} + +// MARK: - LargeFloatingActionButton + +/// A Material Design 3 **Large FAB** (96×96 pt) — for the most prominent action on screen. +/// +/// Equivalent to `LargeFloatingActionButton` in Jetpack Compose. +@available(iOS 15, macOS 12.0, *) +public struct LargeFloatingActionButton: View { + @Environment(\.materialTheme) private var theme + + public var icon: Image + public var action: () -> Void + + public init(icon: Image, action: @escaping () -> Void) { + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + icon + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .foregroundColor(theme.colorScheme.onPrimaryContainer) + .frame(width: 96, height: 96) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraLarge, style: .continuous) + .fill(theme.colorScheme.primaryContainer) + .shadow(color: theme.colorScheme.scrim.opacity(0.3), radius: 3, x: 0, y: 1) + .shadow(color: theme.colorScheme.scrim.opacity(0.15), radius: 8, x: 0, y: 3) + ) + } +} + +// MARK: - ExtendedFloatingActionButton + +/// A Material Design 3 **Extended FAB** — a wider FAB with an icon and a text label. +/// +/// Equivalent to `ExtendedFloatingActionButton` in Jetpack Compose. +/// +/// ```swift +/// ExtendedFloatingActionButton("New message", icon: Image(systemName: "plus")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct ExtendedFloatingActionButton: View { + @Environment(\.materialTheme) private var theme + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 12) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .foregroundColor(theme.colorScheme.onPrimaryContainer) + .padding(.horizontal, 16) + .frame(height: 56) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.large, style: .continuous) + .fill(theme.colorScheme.primaryContainer) + .shadow(color: theme.colorScheme.scrim.opacity(0.3), radius: 3, x: 0, y: 1) + .shadow(color: theme.colorScheme.scrim.opacity(0.15), radius: 8, x: 0, y: 3) + ) + } +} + +// MARK: - Previews + +@available(iOS 15, macOS 12.0, *) +struct FloatingActionButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 24) { + HStack(spacing: 16) { + SmallFloatingActionButton(icon: Image(systemName: "pencil")) {} + FloatingActionButton(icon: Image(systemName: "plus")) {} + LargeFloatingActionButton(icon: Image(systemName: "camera")) {} + } + ExtendedFloatingActionButton("Compose", icon: Image(systemName: "pencil")) {} + ExtendedFloatingActionButton("New message") {} + } + .padding() + .materialTheme(.light) + .previewDisplayName("FAB variants — Light") + } +} diff --git a/Sources/MattiUI/Buttons/OutlinedButton.swift b/Sources/MattiUI/Buttons/OutlinedButton.swift new file mode 100644 index 0000000..22509da --- /dev/null +++ b/Sources/MattiUI/Buttons/OutlinedButton.swift @@ -0,0 +1,74 @@ +// +// OutlinedButton.swift +// MattiUI +// +// Material Design 3 — Outlined Button +// Reference: https://m3.material.io/components/buttons/specs#de72d8b1-ba16-4cd7-989e-e2ad3293cf63 + +import SwiftUI + +/// A Material Design 3 **Outlined** button — medium emphasis, with a visible border. +/// +/// Equivalent to `OutlinedButton` in Jetpack Compose. +/// +/// ```swift +/// OutlinedButton("Cancel") { /* action */ } +/// OutlinedButton("Share", icon: Image(systemName: "square.and.arrow.up")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct OutlinedButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .padding(.horizontal, icon != nil ? 16 : 24) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.opacity(0.38)) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.full, style: .continuous) + .strokeBorder( + isEnabled ? theme.colorScheme.outline : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: 1 + ) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct OutlinedButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + OutlinedButton("Outlined Button") {} + OutlinedButton("With Icon", icon: Image(systemName: "arrow.up")) {} + OutlinedButton("Disabled") {} + .disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("OutlinedButton — Light") + } +} diff --git a/Sources/MattiUI/Buttons/TextButton.swift b/Sources/MattiUI/Buttons/TextButton.swift new file mode 100644 index 0000000..c643ea9 --- /dev/null +++ b/Sources/MattiUI/Buttons/TextButton.swift @@ -0,0 +1,67 @@ +// +// TextButton.swift +// MattiUI +// +// Material Design 3 — Text Button +// Reference: https://m3.material.io/components/buttons/specs#899b9107-0127-4a01-8f4c-87f19323a1b4 + +import SwiftUI + +/// A Material Design 3 **Text** button — lowest emphasis, no container. +/// +/// Equivalent to `TextButton` in Jetpack Compose. +/// +/// ```swift +/// TextButton("Learn more") { /* action */ } +/// TextButton("Open", icon: Image(systemName: "arrow.right")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct TextButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.opacity(0.38)) + } + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct TextButton_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + TextButton("Text Button") {} + TextButton("With Icon", icon: Image(systemName: "plus")) {} + TextButton("Disabled") {} + .disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("TextButton — Light") + } +} diff --git a/Sources/MattiUI/Cards/ElevatedCard.swift b/Sources/MattiUI/Cards/ElevatedCard.swift new file mode 100644 index 0000000..30e8e8f --- /dev/null +++ b/Sources/MattiUI/Cards/ElevatedCard.swift @@ -0,0 +1,58 @@ +// +// ElevatedCard.swift +// MattiUI +// +// Material Design 3 — Elevated Card +// Reference: https://m3.material.io/components/cards/specs#a012d40d-7a5c-4b07-8740-491dec79d58b + +import SwiftUI + +/// A Material Design 3 **Elevated Card** — a surface with a shadow to indicate elevation. +/// +/// Equivalent to `ElevatedCard` in Jetpack Compose. +/// +/// ```swift +/// ElevatedCard { +/// Text("Card content") +/// } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct ElevatedCard: View { + @Environment(\.materialTheme) private var theme + + public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + content() + .background( + RoundedRectangle(cornerRadius: theme.shapes.medium, style: .continuous) + .fill(theme.colorScheme.surface) + .shadow(color: theme.colorScheme.scrim.opacity(0.15), radius: 1, x: 0, y: 1) + .shadow(color: theme.colorScheme.scrim.opacity(0.3), radius: 2, x: 0, y: 1) + ) + } +} + +@available(iOS 15, macOS 12.0, *) +struct ElevatedCard_Previews: PreviewProvider { + static var previews: some View { + ElevatedCard { + VStack(alignment: .leading, spacing: 8) { + Text("Elevated Card") + .font(.headline) + Text("Cards contain content and actions about a single subject.") + .font(.body) + .foregroundColor(.secondary) + } + .padding() + .frame(width: 300) + } + .padding() + .materialTheme(.light) + .previewDisplayName("ElevatedCard — Light") + } +} diff --git a/Sources/MattiUI/Cards/FilledCard.swift b/Sources/MattiUI/Cards/FilledCard.swift new file mode 100644 index 0000000..e9d4cf5 --- /dev/null +++ b/Sources/MattiUI/Cards/FilledCard.swift @@ -0,0 +1,56 @@ +// +// FilledCard.swift +// MattiUI +// +// Material Design 3 — Filled Card +// Reference: https://m3.material.io/components/cards/specs#6a7f2d06-e0ef-4580-a3b0-8a6d0f0aa7b6 + +import SwiftUI + +/// A Material Design 3 **Filled Card** — uses the surface-variant color, no shadow. +/// +/// Equivalent to `Card` (default) in Jetpack Compose Material 3. +/// +/// ```swift +/// FilledCard { +/// Text("Card content") +/// } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FilledCard: View { + @Environment(\.materialTheme) private var theme + + public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + content() + .background( + RoundedRectangle(cornerRadius: theme.shapes.medium, style: .continuous) + .fill(theme.colorScheme.surfaceVariant) + ) + } +} + +@available(iOS 15, macOS 12.0, *) +struct FilledCard_Previews: PreviewProvider { + static var previews: some View { + FilledCard { + VStack(alignment: .leading, spacing: 8) { + Text("Filled Card") + .font(.headline) + Text("Cards contain content and actions about a single subject.") + .font(.body) + .foregroundColor(.secondary) + } + .padding() + .frame(width: 300) + } + .padding() + .materialTheme(.light) + .previewDisplayName("FilledCard — Light") + } +} diff --git a/Sources/MattiUI/Cards/OutlinedCard.swift b/Sources/MattiUI/Cards/OutlinedCard.swift new file mode 100644 index 0000000..df26444 --- /dev/null +++ b/Sources/MattiUI/Cards/OutlinedCard.swift @@ -0,0 +1,60 @@ +// +// OutlinedCard.swift +// MattiUI +// +// Material Design 3 — Outlined Card +// Reference: https://m3.material.io/components/cards/specs#6a7f2d06-e0ef-4580-a3b0-8a6d0f0aa7b6 + +import SwiftUI + +/// A Material Design 3 **Outlined Card** — surface color with a visible border, no shadow. +/// +/// Equivalent to `OutlinedCard` in Jetpack Compose. +/// +/// ```swift +/// OutlinedCard { +/// Text("Card content") +/// } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct OutlinedCard: View { + @Environment(\.materialTheme) private var theme + + public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + content() + .background( + RoundedRectangle(cornerRadius: theme.shapes.medium, style: .continuous) + .fill(theme.colorScheme.surface) + .overlay( + RoundedRectangle(cornerRadius: theme.shapes.medium, style: .continuous) + .strokeBorder(theme.colorScheme.outlineVariant, lineWidth: 1) + ) + ) + } +} + +@available(iOS 15, macOS 12.0, *) +struct OutlinedCard_Previews: PreviewProvider { + static var previews: some View { + OutlinedCard { + VStack(alignment: .leading, spacing: 8) { + Text("Outlined Card") + .font(.headline) + Text("Cards contain content and actions about a single subject.") + .font(.body) + .foregroundColor(.secondary) + } + .padding() + .frame(width: 300) + } + .padding() + .materialTheme(.light) + .previewDisplayName("OutlinedCard — Light") + } +} diff --git a/Sources/MattiUI/Chips/AssistChip.swift b/Sources/MattiUI/Chips/AssistChip.swift new file mode 100644 index 0000000..5ba7ed5 --- /dev/null +++ b/Sources/MattiUI/Chips/AssistChip.swift @@ -0,0 +1,78 @@ +// +// AssistChip.swift +// MattiUI +// +// Material Design 3 — Assist Chip +// Reference: https://m3.material.io/components/chips/specs#e900592e-41a7-4c3e-9eb9-d9ad8ec2f49e + +import SwiftUI + +/// A Material Design 3 **Assist Chip** — guides the user to complete a task. +/// +/// Equivalent to `AssistChip` in Jetpack Compose. +/// +/// ```swift +/// AssistChip("Set a reminder", icon: Image(systemName: "bell")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct AssistChip: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(isEnabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.opacity(0.38)) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + .foregroundColor(isEnabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.opacity(0.38)) + } + .padding(.leading, icon != nil ? 8 : 16) + .padding(.trailing, 16) + .frame(height: 32) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(isEnabled ? theme.colorScheme.surface : theme.colorScheme.onSurface.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .strokeBorder( + isEnabled ? theme.colorScheme.outline : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: 1 + ) + ) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct AssistChip_Previews: PreviewProvider { + static var previews: some View { + HStack(spacing: 8) { + AssistChip("Set reminder", icon: Image(systemName: "bell")) {} + AssistChip("Directions") {} + AssistChip("Disabled") {}.disabled(true) + } + .padding() + .materialTheme(.light) + .previewDisplayName("AssistChip — Light") + } +} diff --git a/Sources/MattiUI/Chips/FilterChip.swift b/Sources/MattiUI/Chips/FilterChip.swift new file mode 100644 index 0000000..fc5e168 --- /dev/null +++ b/Sources/MattiUI/Chips/FilterChip.swift @@ -0,0 +1,93 @@ +// +// FilterChip.swift +// MattiUI +// +// Material Design 3 — Filter Chip +// Reference: https://m3.material.io/components/chips/specs#68b1ae13-daa6-4e12-a9df-28f4c3f4e998 + +import SwiftUI + +/// A Material Design 3 **Filter Chip** — allows the user to narrow down a set of results. +/// +/// Equivalent to `FilterChip` in Jetpack Compose. +/// +/// ```swift +/// @State private var selected = false +/// FilterChip("Sci-Fi", isSelected: $selected) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FilterChip: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + @Binding public var isSelected: Bool + + public init(_ label: String, isSelected: Binding) { + self.label = label + self._isSelected = isSelected + } + + public var body: some View { + Button(action: { if isEnabled { isSelected.toggle() } }) { + HStack(spacing: 8) { + if isSelected { + Image(systemName: "checkmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .foregroundColor(isEnabled ? theme.colorScheme.onSecondaryContainer : theme.colorScheme.onSurface.opacity(0.38)) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + .foregroundColor(isEnabled + ? (isSelected ? theme.colorScheme.onSecondaryContainer : theme.colorScheme.onSurfaceVariant) + : theme.colorScheme.onSurface.opacity(0.38)) + } + .padding(.horizontal, 16) + .frame(height: 32) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(isEnabled + ? (isSelected ? theme.colorScheme.secondaryContainer : .clear) + : theme.colorScheme.onSurface.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .strokeBorder( + isEnabled + ? (isSelected ? .clear : theme.colorScheme.outline) + : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: 1 + ) + ) + ) + .animation(.easeInOut(duration: 0.15), value: isSelected) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct FilterChip_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var sci = true + @State private var drama = false + @State private var comedy = false + + var body: some View { + HStack(spacing: 8) { + FilterChip("Sci-Fi", isSelected: $sci) + FilterChip("Drama", isSelected: $drama) + FilterChip("Comedy", isSelected: $comedy) + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("FilterChip — Light") + } +} diff --git a/Sources/MattiUI/Chips/InputChip.swift b/Sources/MattiUI/Chips/InputChip.swift new file mode 100644 index 0000000..1aeea4c --- /dev/null +++ b/Sources/MattiUI/Chips/InputChip.swift @@ -0,0 +1,103 @@ +// +// InputChip.swift +// MattiUI +// +// Material Design 3 — Input Chip +// Reference: https://m3.material.io/components/chips/specs#facb7c02-74c4-4b81-bd5f-72d470f1e5b6 + +import SwiftUI + +/// A Material Design 3 **Input Chip** — represents a discrete piece of information (e.g. a tag). +/// Includes a leading avatar/icon and a trailing dismiss button. +/// +/// Equivalent to `InputChip` in Jetpack Compose. +/// +/// ```swift +/// @State private var visible = true +/// if visible { +/// InputChip("Kotlin", icon: Image(systemName: "k.circle")) { visible = false } +/// } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct InputChip: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var onDismiss: () -> Void + + public init(_ label: String, icon: Image? = nil, onDismiss: @escaping () -> Void) { + self.label = label + self.icon = icon + self.onDismiss = onDismiss + } + + public var body: some View { + HStack(spacing: 0) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(isEnabled ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface.opacity(0.38)) + .padding(.leading, 4) + } else { + Spacer().frame(width: 12) + } + + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + .foregroundColor(isEnabled ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface.opacity(0.38)) + .padding(.horizontal, 8) + + // Dismiss button + Button(action: onDismiss) { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .foregroundColor(isEnabled ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface.opacity(0.38)) + } + .padding(.trailing, 8) + } + .frame(height: 32) + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(isEnabled ? theme.colorScheme.surface : theme.colorScheme.onSurface.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .strokeBorder( + isEnabled ? theme.colorScheme.outline : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: 1 + ) + ) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct InputChip_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var chips = ["SwiftUI", "Kotlin", "Material"] + + var body: some View { + HStack(spacing: 8) { + ForEach(chips, id: \.self) { chip in + InputChip(chip, icon: Image(systemName: "tag")) { + chips.removeAll { $0 == chip } + } + } + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("InputChip — Light") + } +} diff --git a/Sources/MattiUI/Chips/SuggestionChip.swift b/Sources/MattiUI/Chips/SuggestionChip.swift new file mode 100644 index 0000000..b83368c --- /dev/null +++ b/Sources/MattiUI/Chips/SuggestionChip.swift @@ -0,0 +1,78 @@ +// +// SuggestionChip.swift +// MattiUI +// +// Material Design 3 — Suggestion Chip +// Reference: https://m3.material.io/components/chips/specs#8e2e4a24-2d92-4cf5-ad90-7ac2c19e7bde + +import SwiftUI + +/// A Material Design 3 **Suggestion Chip** — presents dynamically generated suggestions. +/// +/// Equivalent to `SuggestionChip` in Jetpack Compose. +/// +/// ```swift +/// SuggestionChip("Reply", icon: Image(systemName: "arrowshape.turn.up.left")) { /* action */ } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct SuggestionChip: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var label: String + public var icon: Image? + public var action: () -> Void + + public init(_ label: String, icon: Image? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(isEnabled ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface.opacity(0.38)) + } + Text(label) + .font(theme.typography.labelLarge.font) + .kerning(theme.typography.labelLarge.letterSpacing) + .foregroundColor(isEnabled ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface.opacity(0.38)) + } + .padding(.leading, icon != nil ? 8 : 16) + .padding(.trailing, 16) + .frame(height: 32) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(isEnabled ? theme.colorScheme.surface : theme.colorScheme.onSurface.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .strokeBorder( + isEnabled ? theme.colorScheme.outline : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: 1 + ) + ) + ) + .disabled(!isEnabled) + } +} + +@available(iOS 15, macOS 12.0, *) +struct SuggestionChip_Previews: PreviewProvider { + static var previews: some View { + HStack(spacing: 8) { + SuggestionChip("Reply all", icon: Image(systemName: "arrowshape.turn.up.left.2")) {} + SuggestionChip("Forward") {} + SuggestionChip("Snooze", icon: Image(systemName: "moon")) {} + } + .padding() + .materialTheme(.light) + .previewDisplayName("SuggestionChip — Light") + } +} diff --git a/Sources/MattiUI/Dialogs/AlertDialog.swift b/Sources/MattiUI/Dialogs/AlertDialog.swift new file mode 100644 index 0000000..29187fa --- /dev/null +++ b/Sources/MattiUI/Dialogs/AlertDialog.swift @@ -0,0 +1,174 @@ +// +// AlertDialog.swift +// MattiUI +// +// Material Design 3 — Alert Dialog +// Reference: https://m3.material.io/components/dialogs/specs + +import SwiftUI + +/// A Material Design 3 **Alert Dialog** — presents critical information requiring user acknowledgement. +/// +/// Equivalent to `AlertDialog` in Jetpack Compose. +/// +/// ```swift +/// @State private var showDialog = false +/// +/// AlertDialog( +/// isPresented: $showDialog, +/// icon: Image(systemName: "exclamationmark.triangle"), +/// title: "Discard draft?", +/// text: "All unsaved changes will be lost.", +/// confirmButton: AlertDialogButton(label: "Discard", role: .destructive) { /* action */ }, +/// dismissButton: AlertDialogButton(label: "Cancel") { showDialog = false } +/// ) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct AlertDialog: View { + @Environment(\.materialTheme) private var theme + + @Binding public var isPresented: Bool + public var icon: Image? + public var title: String + public var text: String + public var confirmButton: AlertDialogButton + public var dismissButton: AlertDialogButton? + + public init( + isPresented: Binding, + icon: Image? = nil, + title: String, + text: String, + confirmButton: AlertDialogButton, + dismissButton: AlertDialogButton? = nil + ) { + self._isPresented = isPresented + self.icon = icon + self.title = title + self.text = text + self.confirmButton = confirmButton + self.dismissButton = dismissButton + } + + public var body: some View { + if isPresented { + ZStack { + // Scrim + theme.colorScheme.scrim.opacity(0.32) + .ignoresSafeArea() + .onTapGesture { isPresented = false } + + // Dialog surface + VStack(alignment: .center, spacing: 0) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(theme.colorScheme.secondary) + .padding(.top, 24) + .padding(.bottom, 16) + } else { + Spacer().frame(height: 24) + } + + Text(title) + .font(theme.typography.headlineSmall.font) + .foregroundColor(theme.colorScheme.onSurface) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + Text(text) + .font(theme.typography.bodyMedium.font) + .foregroundColor(theme.colorScheme.onSurfaceVariant) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 16) + + HStack(spacing: 8) { + Spacer() + if let dismiss = dismissButton { + Button(action: dismiss.action) { + Text(dismiss.label) + .font(theme.typography.labelLarge.font) + .foregroundColor(theme.colorScheme.primary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + } + + Button(action: confirmButton.action) { + Text(confirmButton.label) + .font(theme.typography.labelLarge.font) + .foregroundColor( + confirmButton.role == .destructive + ? theme.colorScheme.error + : theme.colorScheme.primary + ) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + } + .padding(.horizontal, 16) + .padding(.top, 24) + .padding(.bottom, 24) + } + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraLarge, style: .continuous) + .fill(theme.colorScheme.surface) + ) + .frame(minWidth: 280, maxWidth: 560) + .padding(.horizontal, 40) + } + } + } +} + +// MARK: - AlertDialogButton + +/// A button configuration for use inside `AlertDialog`. +@available(iOS 15, macOS 12.0, *) +public struct AlertDialogButton { + public enum Role { case `default`, destructive } + + public var label: String + public var role: Role + public var action: () -> Void + + public init(label: String, role: Role = .default, action: @escaping () -> Void) { + self.label = label + self.role = role + self.action = action + } +} + +// MARK: - Previews + +@available(iOS 15, macOS 12.0, *) +struct AlertDialog_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var showDialog = true + + var body: some View { + ZStack { + Color.gray.opacity(0.2).ignoresSafeArea() + Button("Show Dialog") { showDialog = true } + + AlertDialog( + isPresented: $showDialog, + icon: Image(systemName: "trash"), + title: "Delete item?", + text: "This action cannot be undone. The item will be permanently removed.", + confirmButton: AlertDialogButton(label: "Delete", role: .destructive) { showDialog = false }, + dismissButton: AlertDialogButton(label: "Cancel") { showDialog = false } + ) + } + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("AlertDialog — Light") + } +} diff --git a/Sources/MattiUI/Dividers/MaterialDivider.swift b/Sources/MattiUI/Dividers/MaterialDivider.swift new file mode 100644 index 0000000..e8295b5 --- /dev/null +++ b/Sources/MattiUI/Dividers/MaterialDivider.swift @@ -0,0 +1,71 @@ +// +// MaterialDivider.swift +// MattiUI +// +// Material Design 3 — Divider +// Reference: https://m3.material.io/components/divider/specs + +import SwiftUI + +/// A Material Design 3 **Divider** — a thin line separating content. +/// +/// Equivalent to `Divider` / `HorizontalDivider` / `VerticalDivider` in Jetpack Compose. +/// +/// ```swift +/// MaterialDivider() // full-width horizontal divider +/// MaterialDivider(inset: 16) // inset divider +/// MaterialDivider(isVertical: true, length: 40) // vertical divider +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct MaterialDivider: View { + @Environment(\.materialTheme) private var theme + + public var isVertical: Bool + public var inset: CGFloat + public var length: CGFloat? + + public init(isVertical: Bool = false, inset: CGFloat = 0, length: CGFloat? = nil) { + self.isVertical = isVertical + self.inset = inset + self.length = length + } + + public var body: some View { + if isVertical { + Rectangle() + .fill(theme.colorScheme.outlineVariant) + .frame(width: 1, height: length) + } else { + Rectangle() + .fill(theme.colorScheme.outlineVariant) + .frame(height: 1) + .padding(.leading, inset) + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct MaterialDivider_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 24) { + // Full-width divider + VStack(spacing: 16) { + Text("Item 1").frame(maxWidth: .infinity, alignment: .leading) + MaterialDivider() + Text("Item 2").frame(maxWidth: .infinity, alignment: .leading) + MaterialDivider(inset: 16) + Text("Item 3").frame(maxWidth: .infinity, alignment: .leading) + } + + // Vertical divider + HStack(spacing: 16) { + Text("Left") + MaterialDivider(isVertical: true, length: 40) + Text("Right") + } + } + .padding() + .materialTheme(.light) + .previewDisplayName("MaterialDivider — Light") + } +} diff --git a/Sources/MattiUI/Navigation/NavigationBar.swift b/Sources/MattiUI/Navigation/NavigationBar.swift new file mode 100644 index 0000000..c9271dd --- /dev/null +++ b/Sources/MattiUI/Navigation/NavigationBar.swift @@ -0,0 +1,128 @@ +// +// NavigationBar.swift +// MattiUI +// +// Material Design 3 — Navigation Bar +// Reference: https://m3.material.io/components/navigation-bar/specs + +import SwiftUI + +// MARK: - NavigationBarItem Model + +/// Represents a single item in a `NavigationBar`. +@available(iOS 15, macOS 12.0, *) +public struct NavigationBarItem { + public var icon: Image + public var selectedIcon: Image? + public var label: String + + public init(icon: Image, selectedIcon: Image? = nil, label: String) { + self.icon = icon + self.selectedIcon = selectedIcon + self.label = label + } +} + +// MARK: - NavigationBar + +/// A Material Design 3 **Navigation Bar** — bottom navigation for 3–5 primary destinations. +/// +/// Equivalent to `NavigationBar` + `NavigationBarItem` in Jetpack Compose. +/// +/// ```swift +/// @State private var selectedIndex = 0 +/// NavigationBar( +/// items: [ +/// NavigationBarItem(icon: Image(systemName: "house"), selectedIcon: Image(systemName: "house.fill"), label: "Home"), +/// NavigationBarItem(icon: Image(systemName: "magnifyingglass"), label: "Search"), +/// ], +/// selectedIndex: $selectedIndex +/// ) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct NavigationBar: View { + @Environment(\.materialTheme) private var theme + + public var items: [NavigationBarItem] + @Binding public var selectedIndex: Int + + public init(items: [NavigationBarItem], selectedIndex: Binding) { + self.items = items + self._selectedIndex = selectedIndex + } + + public var body: some View { + HStack(spacing: 0) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + let isSelected = selectedIndex == index + + Button(action: { selectedIndex = index }) { + VStack(spacing: 4) { + ZStack { + // Active indicator pill + if isSelected { + RoundedRectangle(cornerRadius: theme.shapes.full, style: .continuous) + .fill(theme.colorScheme.secondaryContainer) + .frame(width: 64, height: 32) + } + + // Icon + (isSelected ? (item.selectedIcon ?? item.icon) : item.icon) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor( + isSelected + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onSurfaceVariant + ) + } + + // Label + Text(item.label) + .font(theme.typography.labelMedium.font) + .foregroundColor( + isSelected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant + ) + .fontWeight(isSelected ? .medium : .regular) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + } + .background(theme.colorScheme.surface) + .overlay(alignment: .top) { + Divider() + .background(theme.colorScheme.outlineVariant) + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct NavigationBar_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var selected = 0 + let items = [ + NavigationBarItem(icon: Image(systemName: "house"), selectedIcon: Image(systemName: "house.fill"), label: "Home"), + NavigationBarItem(icon: Image(systemName: "magnifyingglass"), label: "Search"), + NavigationBarItem(icon: Image(systemName: "heart"), selectedIcon: Image(systemName: "heart.fill"), label: "Favorites"), + NavigationBarItem(icon: Image(systemName: "person"), selectedIcon: Image(systemName: "person.fill"), label: "Profile"), + ] + + var body: some View { + NavigationBar(items: items, selectedIndex: $selected) + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("NavigationBar — Light") + .previewLayout(.sizeThatFits) + } +} diff --git a/Sources/MattiUI/Progress/CircularProgressIndicator.swift b/Sources/MattiUI/Progress/CircularProgressIndicator.swift new file mode 100644 index 0000000..6ed0f31 --- /dev/null +++ b/Sources/MattiUI/Progress/CircularProgressIndicator.swift @@ -0,0 +1,86 @@ +// +// CircularProgressIndicator.swift +// MattiUI +// +// Material Design 3 — Circular Progress Indicator +// Reference: https://m3.material.io/components/progress-indicators/specs + +import SwiftUI + +/// A Material Design 3 **Circular Progress Indicator**. +/// +/// Use `progress = nil` for an indeterminate (spinning) indicator. +/// Use a value between 0.0 and 1.0 for a determinate indicator. +/// +/// Equivalent to `CircularProgressIndicator` in Jetpack Compose. +/// +/// ```swift +/// CircularProgressIndicator() // indeterminate +/// CircularProgressIndicator(progress: 0.5) // 50% complete +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct CircularProgressIndicator: View { + @Environment(\.materialTheme) private var theme + + public var progress: Double? + public var size: CGFloat + public var strokeWidth: CGFloat + + @State private var rotation: Double = 0 + @State private var trimEnd: Double = 0.75 + + public init(progress: Double? = nil, size: CGFloat = 48, strokeWidth: CGFloat = 4) { + self.progress = progress + self.size = size + self.strokeWidth = strokeWidth + } + + public var body: some View { + ZStack { + // Track circle + Circle() + .stroke(theme.colorScheme.secondaryContainer, lineWidth: strokeWidth) + + if let progress = progress { + // Determinate + Circle() + .trim(from: 0, to: CGFloat(max(0, min(1, progress)))) + .stroke( + theme.colorScheme.primary, + style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.3), value: progress) + } else { + // Indeterminate + Circle() + .trim(from: 0, to: 0.75) + .stroke( + theme.colorScheme.primary, + style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round) + ) + .rotationEffect(.degrees(rotation)) + .onAppear { + withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + } + } + .frame(width: size, height: size) + } +} + +@available(iOS 15, macOS 12.0, *) +struct CircularProgressIndicator_Previews: PreviewProvider { + static var previews: some View { + HStack(spacing: 32) { + CircularProgressIndicator() + CircularProgressIndicator(progress: 0.35) + CircularProgressIndicator(progress: 0.75, size: 64, strokeWidth: 6) + } + .padding() + .materialTheme(.light) + .previewDisplayName("CircularProgressIndicator — Light") + } +} diff --git a/Sources/MattiUI/Progress/LinearProgressIndicator.swift b/Sources/MattiUI/Progress/LinearProgressIndicator.swift new file mode 100644 index 0000000..0a2b656 --- /dev/null +++ b/Sources/MattiUI/Progress/LinearProgressIndicator.swift @@ -0,0 +1,81 @@ +// +// LinearProgressIndicator.swift +// MattiUI +// +// Material Design 3 — Linear Progress Indicator +// Reference: https://m3.material.io/components/progress-indicators/specs + +import SwiftUI + +/// A Material Design 3 **Linear Progress Indicator**. +/// +/// Use `progress = nil` for an indeterminate (animated) indicator. +/// Use a value between 0.0 and 1.0 for a determinate indicator. +/// +/// Equivalent to `LinearProgressIndicator` in Jetpack Compose. +/// +/// ```swift +/// LinearProgressIndicator() // indeterminate +/// LinearProgressIndicator(progress: 0.65) // 65% complete +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct LinearProgressIndicator: View { + @Environment(\.materialTheme) private var theme + + public var progress: Double? + @State private var animationOffset: CGFloat = -1.0 + + public init(progress: Double? = nil) { + self.progress = progress + } + + public var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + // Track + Capsule() + .fill(theme.colorScheme.secondaryContainer) + .frame(height: 4) + + if let progress = progress { + // Determinate + Capsule() + .fill(theme.colorScheme.primary) + .frame(width: geo.size.width * max(0, min(1, progress)), height: 4) + .animation(.easeInOut(duration: 0.3), value: progress) + } else { + // Indeterminate + Capsule() + .fill(theme.colorScheme.primary) + .frame(width: geo.size.width * 0.4, height: 4) + .offset(x: animationOffset * geo.size.width) + .onAppear { + withAnimation( + .linear(duration: 1.2) + .repeatForever(autoreverses: false) + ) { + animationOffset = 1.6 + } + } + .clipped() + } + } + } + .frame(height: 4) + } +} + +@available(iOS 15, macOS 12.0, *) +struct LinearProgressIndicator_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 24) { + LinearProgressIndicator() + LinearProgressIndicator(progress: 0.35) + LinearProgressIndicator(progress: 0.7) + } + .padding() + .frame(width: 300) + .materialTheme(.light) + .previewDisplayName("LinearProgressIndicator — Light") + } +} diff --git a/Sources/MattiUI/Selection/MaterialCheckbox.swift b/Sources/MattiUI/Selection/MaterialCheckbox.swift new file mode 100644 index 0000000..e00a215 --- /dev/null +++ b/Sources/MattiUI/Selection/MaterialCheckbox.swift @@ -0,0 +1,92 @@ +// +// MaterialCheckbox.swift +// MattiUI +// +// Material Design 3 — Checkbox +// Reference: https://m3.material.io/components/checkbox/specs + +import SwiftUI + +/// A Material Design 3 **Checkbox** — a square selection control. +/// +/// Equivalent to `Checkbox` in Jetpack Compose. +/// +/// ```swift +/// @State private var checked = false +/// MaterialCheckbox(isChecked: $checked) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct MaterialCheckbox: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + @Binding public var isChecked: Bool + + public init(isChecked: Binding) { + self._isChecked = isChecked + } + + private var boxColor: Color { + if !isEnabled { return isChecked ? theme.colorScheme.onSurface.opacity(0.38) : .clear } + return isChecked ? theme.colorScheme.primary : .clear + } + + private var borderColor: Color { + if !isEnabled { return theme.colorScheme.onSurface.opacity(0.38) } + return isChecked ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant + } + + public var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(boxColor) + .overlay( + RoundedRectangle(cornerRadius: 2, style: .continuous) + .strokeBorder(borderColor, lineWidth: 2) + ) + .frame(width: 18, height: 18) + + if isChecked { + Image(systemName: "checkmark") + .resizable() + .scaledToFit() + .frame(width: 10, height: 10) + .foregroundColor(isEnabled ? theme.colorScheme.onPrimary : theme.colorScheme.surface) + .fontWeight(.bold) + } + } + .frame(width: 40, height: 40) + .contentShape(Rectangle()) + .onTapGesture { + if isEnabled { + withAnimation(.easeInOut(duration: 0.1)) { + isChecked.toggle() + } + } + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct MaterialCheckbox_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var a = true + @State private var b = false + @State private var c = true + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { MaterialCheckbox(isChecked: $a); Text("Option A") } + HStack { MaterialCheckbox(isChecked: $b); Text("Option B") } + HStack { MaterialCheckbox(isChecked: $c).disabled(true); Text("Disabled (checked)") } + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("MaterialCheckbox — Light") + } +} diff --git a/Sources/MattiUI/Selection/MaterialRadioButton.swift b/Sources/MattiUI/Selection/MaterialRadioButton.swift new file mode 100644 index 0000000..031f220 --- /dev/null +++ b/Sources/MattiUI/Selection/MaterialRadioButton.swift @@ -0,0 +1,106 @@ +// +// MaterialRadioButton.swift +// MattiUI +// +// Material Design 3 — Radio Button +// Reference: https://m3.material.io/components/radio-button/specs + +import SwiftUI + +/// A Material Design 3 **Radio Button** — a circular selection control for mutually exclusive options. +/// +/// Equivalent to `RadioButton` in Jetpack Compose. +/// +/// ```swift +/// @State private var selected = "Option A" +/// MaterialRadioButton(isSelected: selected == "Option A") { selected = "Option A" } +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct MaterialRadioButton: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + public var isSelected: Bool + public var onSelect: () -> Void + + public init(isSelected: Bool, onSelect: @escaping () -> Void) { + self.isSelected = isSelected + self.onSelect = onSelect + } + + private var ringColor: Color { + if !isEnabled { return theme.colorScheme.onSurface.opacity(0.38) } + return isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant + } + + public var body: some View { + ZStack { + Circle() + .strokeBorder(ringColor, lineWidth: 2) + .frame(width: 20, height: 20) + + if isSelected { + Circle() + .fill(ringColor) + .frame(width: 10, height: 10) + } + } + .frame(width: 40, height: 40) + .contentShape(Circle()) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .onTapGesture { + if isEnabled && !isSelected { + onSelect() + } + } + } +} + +// MARK: - MaterialRadioGroup + +/// Convenience wrapper for a group of radio buttons with a shared selection binding. +@available(iOS 15, macOS 12.0, *) +public struct MaterialRadioGroup: View { + public var options: [T] + @Binding public var selection: T + public var label: (T) -> String + + public init(options: [T], selection: Binding, label: @escaping (T) -> String) { + self.options = options + self._selection = selection + self.label = label + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(options, id: \.self) { option in + HStack(spacing: 0) { + MaterialRadioButton(isSelected: selection == option) { + selection = option + } + Text(label(option)) + .font(.body) + } + } + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct MaterialRadioButton_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var selection = "Option A" + let options = ["Option A", "Option B", "Option C"] + + var body: some View { + MaterialRadioGroup(options: options, selection: $selection) { $0 } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("MaterialRadioButton — Light") + } +} diff --git a/Sources/MattiUI/Snackbar/Snackbar.swift b/Sources/MattiUI/Snackbar/Snackbar.swift new file mode 100644 index 0000000..47c1839 --- /dev/null +++ b/Sources/MattiUI/Snackbar/Snackbar.swift @@ -0,0 +1,163 @@ +// +// Snackbar.swift +// MattiUI +// +// Material Design 3 — Snackbar +// Reference: https://m3.material.io/components/snackbar/specs + +import SwiftUI + +// MARK: - SnackbarData + +/// Configuration data for a `Snackbar`. +@available(iOS 15, macOS 12.0, *) +public struct SnackbarData: Equatable { + public var message: String + public var actionLabel: String? + public var duration: TimeInterval + + public init(message: String, actionLabel: String? = nil, duration: TimeInterval = 4.0) { + self.message = message + self.actionLabel = actionLabel + self.duration = duration + } +} + +// MARK: - Snackbar + +/// A Material Design 3 **Snackbar** — brief, non-intrusive messages at the bottom of the screen. +/// +/// Equivalent to `Snackbar` in Jetpack Compose. +/// +/// Use the `.snackbar(data:onAction:)` view modifier to show a snackbar from any view. +/// +/// ```swift +/// @State private var snack: SnackbarData? = nil +/// +/// MyView() +/// .snackbar(data: $snack, onAction: { +/// print("Action tapped") +/// }) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct Snackbar: View { + @Environment(\.materialTheme) private var theme + + public var data: SnackbarData + public var onAction: (() -> Void)? + public var onDismiss: (() -> Void)? + + public init(data: SnackbarData, onAction: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil) { + self.data = data + self.onAction = onAction + self.onDismiss = onDismiss + } + + public var body: some View { + HStack(spacing: 8) { + Text(data.message) + .font(theme.typography.bodyMedium.font) + .foregroundColor(theme.colorScheme.inverseOnSurface) + .frame(maxWidth: .infinity, alignment: .leading) + + if let actionLabel = data.actionLabel { + Button(action: { + onAction?() + onDismiss?() + }) { + Text(actionLabel) + .font(theme.typography.labelLarge.font) + .foregroundColor(theme.colorScheme.inversePrimary) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(theme.colorScheme.inverseSurface) + ) + .padding(.horizontal, 16) + } +} + +// MARK: - Snackbar View Modifier + +@available(iOS 15, macOS 12.0, *) +extension View { + /// Displays a snackbar overlay when `data` is non-nil. + /// + /// The snackbar auto-dismisses after `data.duration` seconds. + public func snackbar( + data: Binding, + onAction: (() -> Void)? = nil + ) -> some View { + self.modifier(SnackbarModifier(data: data, onAction: onAction)) + } +} + +@available(iOS 15, macOS 12.0, *) +private struct SnackbarModifier: ViewModifier { + @Environment(\.materialTheme) private var theme + @Binding var data: SnackbarData? + var onAction: (() -> Void)? + @State private var isVisible = false + + func body(content: Content) -> some View { + ZStack(alignment: .bottom) { + content + + if let snack = data { + Snackbar( + data: snack, + onAction: onAction, + onDismiss: { withAnimation { data = nil } } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + snack.duration) { + withAnimation { data = nil } + } + } + } + } + .animation(.easeInOut(duration: 0.25), value: data != nil) + } +} + +// MARK: - Previews + +@available(iOS 15, macOS 12.0, *) +struct Snackbar_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var snack: SnackbarData? = SnackbarData(message: "Photo archived", actionLabel: "Undo") + + var body: some View { + VStack { + Spacer() + Button("Show Snackbar") { + snack = SnackbarData(message: "Item deleted", actionLabel: "Undo") + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(m3hex: "#FFFBFE")) + .snackbar(data: $snack, onAction: { print("Undo") }) + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("Snackbar — Light") + + VStack(alignment: .leading, spacing: 12) { + Snackbar(data: SnackbarData(message: "Connection restored")) + Snackbar(data: SnackbarData(message: "Photo archived", actionLabel: "Undo")) + } + .padding() + .background(Color(m3hex: "#FFFBFE")) + .materialTheme(.light) + .previewDisplayName("Snackbar static — Light") + } +} diff --git a/Sources/MattiUI/Switches/MaterialSwitch.swift b/Sources/MattiUI/Switches/MaterialSwitch.swift new file mode 100644 index 0000000..958fe0a --- /dev/null +++ b/Sources/MattiUI/Switches/MaterialSwitch.swift @@ -0,0 +1,129 @@ +// +// MaterialSwitch.swift +// MattiUI +// +// Material Design 3 — Switch +// Reference: https://m3.material.io/components/switch/specs + +import SwiftUI + +/// A Material Design 3 **Switch** — a two-state toggle control. +/// +/// Equivalent to `Switch` in Jetpack Compose. +/// +/// ```swift +/// @State private var isOn = false +/// MaterialSwitch(isOn: $isOn) +/// MaterialSwitch(isOn: $isOn, thumbIcon: Image(systemName: "checkmark")) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct MaterialSwitch: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + + @Binding public var isOn: Bool + public var thumbIcon: Image? + + public init(isOn: Binding, thumbIcon: Image? = nil) { + self._isOn = isOn + self.thumbIcon = thumbIcon + } + + // M3 Switch dimensions + private let trackWidth: CGFloat = 52 + private let trackHeight: CGFloat = 32 + private let thumbSize: CGFloat = 24 + private let thumbSizeSelected: CGFloat = 24 + + private var trackColor: Color { + guard isEnabled else { + return isOn + ? theme.colorScheme.onSurface.opacity(0.12) + : theme.colorScheme.surfaceVariant.opacity(0.12) + } + return isOn ? theme.colorScheme.primary : theme.colorScheme.surfaceVariant + } + + private var thumbColor: Color { + guard isEnabled else { + return isOn + ? theme.colorScheme.surface.opacity(0.38) + : theme.colorScheme.onSurface.opacity(0.38) + } + return isOn ? theme.colorScheme.onPrimary : theme.colorScheme.outline + } + + private var borderColor: Color { + guard isEnabled else { return theme.colorScheme.onSurface.opacity(0.12) } + return isOn ? .clear : theme.colorScheme.outline + } + + public var body: some View { + ZStack { + // Track + RoundedRectangle(cornerRadius: trackHeight / 2, style: .continuous) + .fill(trackColor) + .overlay( + RoundedRectangle(cornerRadius: trackHeight / 2, style: .continuous) + .strokeBorder(borderColor, lineWidth: 2) + ) + .frame(width: trackWidth, height: trackHeight) + + // Thumb + ZStack { + Circle() + .fill(thumbColor) + .frame(width: isOn ? thumbSizeSelected : 16, height: isOn ? thumbSizeSelected : 16) + + if isOn, let icon = thumbIcon { + icon + .resizable() + .scaledToFit() + .foregroundColor(theme.colorScheme.onPrimaryContainer) + .frame(width: 14, height: 14) + } + } + .offset(x: isOn ? 10 : -10) + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isOn) + } + .onTapGesture { + if isEnabled { + isOn.toggle() + } + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct MaterialSwitch_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var isOn1 = true + @State private var isOn2 = false + @State private var isOn3 = true + + var body: some View { + VStack(spacing: 20) { + HStack(spacing: 20) { + Text("With icon (on)") + MaterialSwitch(isOn: $isOn1, thumbIcon: Image(systemName: "checkmark")) + } + HStack(spacing: 20) { + Text("Without icon (off)") + MaterialSwitch(isOn: $isOn2) + } + HStack(spacing: 20) { + Text("Disabled (on)") + MaterialSwitch(isOn: $isOn3) + .disabled(true) + } + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("MaterialSwitch — Light") + } +} diff --git a/Sources/MattiUI/TextFields/FilledTextField.swift b/Sources/MattiUI/TextFields/FilledTextField.swift new file mode 100644 index 0000000..293b844 --- /dev/null +++ b/Sources/MattiUI/TextFields/FilledTextField.swift @@ -0,0 +1,164 @@ +// +// FilledTextField.swift +// MattiUI +// +// Material Design 3 — Filled Text Field +// Reference: https://m3.material.io/components/text-fields/specs#e4964192-72ad-414f-85b4-4b4357abb068 + +import SwiftUI + +/// A Material Design 3 **Filled Text Field** — uses a filled container background. +/// +/// Equivalent to `TextField` (default) in Jetpack Compose Material 3. +/// +/// ```swift +/// @State private var text = "" +/// FilledTextField("Email", text: $text) +/// FilledTextField("Password", text: $text, isSecure: true) +/// FilledTextField("Search", text: $text, leadingIcon: Image(systemName: "magnifyingglass")) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct FilledTextField: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + @FocusState private var isFocused: Bool + + public var label: String + @Binding public var text: String + public var isSecure: Bool + public var leadingIcon: Image? + public var trailingIcon: Image? + public var supportingText: String? + public var isError: Bool + + public init( + _ label: String, + text: Binding, + isSecure: Bool = false, + leadingIcon: Image? = nil, + trailingIcon: Image? = nil, + supportingText: String? = nil, + isError: Bool = false + ) { + self.label = label + self._text = text + self.isSecure = isSecure + self.leadingIcon = leadingIcon + self.trailingIcon = trailingIcon + self.supportingText = supportingText + self.isError = isError + } + + private var labelIsFloating: Bool { isFocused || !text.isEmpty } + + private var activeColor: Color { + if !isEnabled { return theme.colorScheme.onSurface.opacity(0.38) } + if isError { return theme.colorScheme.error } + if isFocused { return theme.colorScheme.primary } + return theme.colorScheme.onSurfaceVariant + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .fill(isEnabled + ? theme.colorScheme.surfaceVariant + : theme.colorScheme.onSurface.opacity(0.04)) + .overlay(alignment: .bottom) { + // Indicator line + Rectangle() + .fill(activeColor) + .frame(height: isFocused ? 2 : 1) + } + + HStack(spacing: 16) { + if let leadingIcon = leadingIcon { + leadingIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(activeColor) + } + + ZStack(alignment: .leading) { + // Floating label + Text(label) + .font(labelIsFloating + ? theme.typography.bodySmall.font + : theme.typography.bodyLarge.font) + .foregroundColor(activeColor) + .offset(y: labelIsFloating ? -12 : 0) + .animation(.easeInOut(duration: 0.15), value: labelIsFloating) + + // Input field + if isSecure { + SecureField("", text: $text) + .focused($isFocused) + .font(theme.typography.bodyLarge.font) + .foregroundColor(isEnabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.opacity(0.38)) + .offset(y: 8) + } else { + TextField("", text: $text) + .focused($isFocused) + .font(theme.typography.bodyLarge.font) + .foregroundColor(isEnabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.opacity(0.38)) + .offset(y: 8) + } + } + + if let trailingIcon = trailingIcon { + trailingIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(activeColor) + } + } + .padding(.horizontal, 16) + .padding(.top, labelIsFloating ? 8 : 0) + } + .frame(height: 56) + .disabled(!isEnabled) + + // Supporting text + if let supportingText = supportingText { + Text(supportingText) + .font(theme.typography.bodySmall.font) + .foregroundColor(isError ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant) + .padding(.horizontal, 16) + } + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct FilledTextField_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var email = "" + @State private var password = "" + @State private var search = "SwiftUI" + + var body: some View { + VStack(spacing: 16) { + FilledTextField("Email address", text: $email, + leadingIcon: Image(systemName: "envelope"), + supportingText: "We'll never share your email") + FilledTextField("Password", text: $password, isSecure: true, + trailingIcon: Image(systemName: "eye.slash")) + FilledTextField("Search", text: $search, + leadingIcon: Image(systemName: "magnifyingglass"), + isError: true, + supportingText: "No results found") + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("FilledTextField — Light") + } +} diff --git a/Sources/MattiUI/TextFields/OutlinedTextField.swift b/Sources/MattiUI/TextFields/OutlinedTextField.swift new file mode 100644 index 0000000..ba68977 --- /dev/null +++ b/Sources/MattiUI/TextFields/OutlinedTextField.swift @@ -0,0 +1,155 @@ +// +// OutlinedTextField.swift +// MattiUI +// +// Material Design 3 — Outlined Text Field +// Reference: https://m3.material.io/components/text-fields/specs#68b1ae13-daa6-4e12-a9df-28f4c3f4e998 + +import SwiftUI + +/// A Material Design 3 **Outlined Text Field** — uses a bordered container instead of a filled one. +/// +/// Equivalent to `OutlinedTextField` in Jetpack Compose. +/// +/// ```swift +/// @State private var username = "" +/// OutlinedTextField("Username", text: $username) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct OutlinedTextField: View { + @Environment(\.materialTheme) private var theme + @Environment(\.isEnabled) private var isEnabled + @FocusState private var isFocused: Bool + + public var label: String + @Binding public var text: String + public var isSecure: Bool + public var leadingIcon: Image? + public var trailingIcon: Image? + public var supportingText: String? + public var isError: Bool + + public init( + _ label: String, + text: Binding, + isSecure: Bool = false, + leadingIcon: Image? = nil, + trailingIcon: Image? = nil, + supportingText: String? = nil, + isError: Bool = false + ) { + self.label = label + self._text = text + self.isSecure = isSecure + self.leadingIcon = leadingIcon + self.trailingIcon = trailingIcon + self.supportingText = supportingText + self.isError = isError + } + + private var labelIsFloating: Bool { isFocused || !text.isEmpty } + + private var activeColor: Color { + if !isEnabled { return theme.colorScheme.onSurface.opacity(0.38) } + if isError { return theme.colorScheme.error } + if isFocused { return theme.colorScheme.primary } + return theme.colorScheme.onSurfaceVariant + } + + private var borderWidth: CGFloat { isFocused || isError ? 2 : 1 } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + ZStack(alignment: .leading) { + // Border + RoundedRectangle(cornerRadius: theme.shapes.extraSmall, style: .continuous) + .strokeBorder( + isEnabled + ? (labelIsFloating ? activeColor : theme.colorScheme.outline) + : theme.colorScheme.onSurface.opacity(0.12), + lineWidth: borderWidth + ) + + HStack(spacing: 16) { + if let leadingIcon = leadingIcon { + leadingIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(activeColor) + } + + ZStack(alignment: .leading) { + // Floating label + Text(label) + .font(labelIsFloating + ? theme.typography.bodySmall.font + : theme.typography.bodyLarge.font) + .foregroundColor(activeColor) + .background(theme.colorScheme.surface) + .offset(y: labelIsFloating ? -28 : 0) + .animation(.easeInOut(duration: 0.15), value: labelIsFloating) + + // Input field + if isSecure { + SecureField("", text: $text) + .focused($isFocused) + .font(theme.typography.bodyLarge.font) + .foregroundColor(isEnabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.opacity(0.38)) + } else { + TextField("", text: $text) + .focused($isFocused) + .font(theme.typography.bodyLarge.font) + .foregroundColor(isEnabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.opacity(0.38)) + } + } + + if let trailingIcon = trailingIcon { + trailingIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(activeColor) + } + } + .padding(.horizontal, 16) + } + .frame(height: 56) + .disabled(!isEnabled) + + // Supporting text + if let supportingText = supportingText { + Text(supportingText) + .font(theme.typography.bodySmall.font) + .foregroundColor(isError ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant) + .padding(.horizontal, 16) + } + } + } +} + +@available(iOS 15, macOS 12.0, *) +struct OutlinedTextField_Previews: PreviewProvider { + struct PreviewContainer: View { + @State private var username = "" + @State private var bio = "SwiftUI developer" + + var body: some View { + VStack(spacing: 16) { + OutlinedTextField("Username", text: $username, + leadingIcon: Image(systemName: "person"), + supportingText: "Required") + OutlinedTextField("Bio", text: $bio) + OutlinedTextField("Disabled field", text: .constant("")) + .disabled(true) + } + .padding() + .materialTheme(.light) + } + } + + static var previews: some View { + PreviewContainer() + .previewDisplayName("OutlinedTextField — Light") + } +} diff --git a/Sources/MattiUI/Theme/MaterialColorScheme.swift b/Sources/MattiUI/Theme/MaterialColorScheme.swift new file mode 100644 index 0000000..d8b5410 --- /dev/null +++ b/Sources/MattiUI/Theme/MaterialColorScheme.swift @@ -0,0 +1,204 @@ +// +// MaterialColorScheme.swift +// MattiUI +// +// Material Design 3 (Material You) Color Scheme +// Reference: https://m3.material.io/styles/color/the-color-system/color-roles + +import SwiftUI + +// MARK: - Hex Color Extension + +extension Color { + /// Initializes a Color from a hex string (e.g. "#6750A4" or "6750A4"). + public init(m3hex hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - MaterialColorScheme + +/// The Material Design 3 color scheme containing all color roles. +/// +/// Use `MaterialColorScheme.light` or `MaterialColorScheme.dark` for the baseline M3 purple scheme, +/// or create your own scheme with custom colors. +@available(iOS 15, macOS 12.0, *) +public struct MaterialColorScheme { + // Primary + public var primary: Color + public var onPrimary: Color + public var primaryContainer: Color + public var onPrimaryContainer: Color + + // Secondary + public var secondary: Color + public var onSecondary: Color + public var secondaryContainer: Color + public var onSecondaryContainer: Color + + // Tertiary + public var tertiary: Color + public var onTertiary: Color + public var tertiaryContainer: Color + public var onTertiaryContainer: Color + + // Error + public var error: Color + public var onError: Color + public var errorContainer: Color + public var onErrorContainer: Color + + // Background / Surface + public var background: Color + public var onBackground: Color + public var surface: Color + public var onSurface: Color + public var surfaceVariant: Color + public var onSurfaceVariant: Color + + // Outline + public var outline: Color + public var outlineVariant: Color + + // Inverse + public var inverseSurface: Color + public var inverseOnSurface: Color + public var inversePrimary: Color + + // Misc + public var scrim: Color + public var surfaceTint: Color + + public init( + primary: Color, onPrimary: Color, primaryContainer: Color, onPrimaryContainer: Color, + secondary: Color, onSecondary: Color, secondaryContainer: Color, onSecondaryContainer: Color, + tertiary: Color, onTertiary: Color, tertiaryContainer: Color, onTertiaryContainer: Color, + error: Color, onError: Color, errorContainer: Color, onErrorContainer: Color, + background: Color, onBackground: Color, + surface: Color, onSurface: Color, + surfaceVariant: Color, onSurfaceVariant: Color, + outline: Color, outlineVariant: Color, + inverseSurface: Color, inverseOnSurface: Color, inversePrimary: Color, + scrim: Color, surfaceTint: Color + ) { + self.primary = primary + self.onPrimary = onPrimary + self.primaryContainer = primaryContainer + self.onPrimaryContainer = onPrimaryContainer + self.secondary = secondary + self.onSecondary = onSecondary + self.secondaryContainer = secondaryContainer + self.onSecondaryContainer = onSecondaryContainer + self.tertiary = tertiary + self.onTertiary = onTertiary + self.tertiaryContainer = tertiaryContainer + self.onTertiaryContainer = onTertiaryContainer + self.error = error + self.onError = onError + self.errorContainer = errorContainer + self.onErrorContainer = onErrorContainer + self.background = background + self.onBackground = onBackground + self.surface = surface + self.onSurface = onSurface + self.surfaceVariant = surfaceVariant + self.onSurfaceVariant = onSurfaceVariant + self.outline = outline + self.outlineVariant = outlineVariant + self.inverseSurface = inverseSurface + self.inverseOnSurface = inverseOnSurface + self.inversePrimary = inversePrimary + self.scrim = scrim + self.surfaceTint = surfaceTint + } + + // MARK: - Baseline M3 Light Scheme (Purple) + + /// The baseline Material Design 3 light color scheme using the M3 purple palette. + public static let light = MaterialColorScheme( + primary: Color(m3hex: "#6750A4"), + onPrimary: Color(m3hex: "#FFFFFF"), + primaryContainer: Color(m3hex: "#EADDFF"), + onPrimaryContainer: Color(m3hex: "#21005D"), + secondary: Color(m3hex: "#625B71"), + onSecondary: Color(m3hex: "#FFFFFF"), + secondaryContainer: Color(m3hex: "#E8DEF8"), + onSecondaryContainer: Color(m3hex: "#1D192B"), + tertiary: Color(m3hex: "#7D5260"), + onTertiary: Color(m3hex: "#FFFFFF"), + tertiaryContainer: Color(m3hex: "#FFD8E4"), + onTertiaryContainer: Color(m3hex: "#31111D"), + error: Color(m3hex: "#B3261E"), + onError: Color(m3hex: "#FFFFFF"), + errorContainer: Color(m3hex: "#F9DEDC"), + onErrorContainer: Color(m3hex: "#410E0B"), + background: Color(m3hex: "#FFFBFE"), + onBackground: Color(m3hex: "#1C1B1F"), + surface: Color(m3hex: "#FFFBFE"), + onSurface: Color(m3hex: "#1C1B1F"), + surfaceVariant: Color(m3hex: "#E7E0EC"), + onSurfaceVariant: Color(m3hex: "#49454F"), + outline: Color(m3hex: "#79747E"), + outlineVariant: Color(m3hex: "#CAC4D0"), + inverseSurface: Color(m3hex: "#313033"), + inverseOnSurface: Color(m3hex: "#F4EFF4"), + inversePrimary: Color(m3hex: "#D0BCFF"), + scrim: Color(m3hex: "#000000"), + surfaceTint: Color(m3hex: "#6750A4") + ) + + // MARK: - Baseline M3 Dark Scheme (Purple) + + /// The baseline Material Design 3 dark color scheme using the M3 purple palette. + public static let dark = MaterialColorScheme( + primary: Color(m3hex: "#D0BCFF"), + onPrimary: Color(m3hex: "#381E72"), + primaryContainer: Color(m3hex: "#4F378B"), + onPrimaryContainer: Color(m3hex: "#EADDFF"), + secondary: Color(m3hex: "#CCC2DC"), + onSecondary: Color(m3hex: "#332D41"), + secondaryContainer: Color(m3hex: "#4A4458"), + onSecondaryContainer: Color(m3hex: "#E8DEF8"), + tertiary: Color(m3hex: "#EFB8C8"), + onTertiary: Color(m3hex: "#492532"), + tertiaryContainer: Color(m3hex: "#633B48"), + onTertiaryContainer: Color(m3hex: "#FFD8E4"), + error: Color(m3hex: "#F2B8B5"), + onError: Color(m3hex: "#601410"), + errorContainer: Color(m3hex: "#8C1D18"), + onErrorContainer: Color(m3hex: "#F9DEDC"), + background: Color(m3hex: "#1C1B1F"), + onBackground: Color(m3hex: "#E6E1E5"), + surface: Color(m3hex: "#1C1B1F"), + onSurface: Color(m3hex: "#E6E1E5"), + surfaceVariant: Color(m3hex: "#49454F"), + onSurfaceVariant: Color(m3hex: "#CAC4D0"), + outline: Color(m3hex: "#938F99"), + outlineVariant: Color(m3hex: "#49454F"), + inverseSurface: Color(m3hex: "#E6E1E5"), + inverseOnSurface: Color(m3hex: "#313033"), + inversePrimary: Color(m3hex: "#6750A4"), + scrim: Color(m3hex: "#000000"), + surfaceTint: Color(m3hex: "#D0BCFF") + ) +} diff --git a/Sources/MattiUI/Theme/MaterialShapes.swift b/Sources/MattiUI/Theme/MaterialShapes.swift new file mode 100644 index 0000000..7f6ff29 --- /dev/null +++ b/Sources/MattiUI/Theme/MaterialShapes.swift @@ -0,0 +1,48 @@ +// +// MaterialShapes.swift +// MattiUI +// +// Material Design 3 Shape Scale +// Reference: https://m3.material.io/styles/shape/shape-scale-tokens + +import SwiftUI + +// MARK: - MaterialShapes + +/// The Material Design 3 shape scale defining corner radii for components. +/// +/// - SeeAlso: https://m3.material.io/styles/shape/shape-scale-tokens +@available(iOS 15, macOS 12.0, *) +public struct MaterialShapes { + /// Extra-small components: 4 pt (e.g. chips, small text fields) + public var extraSmall: CGFloat + /// Small components: 8 pt (e.g. menu, snackbar) + public var small: CGFloat + /// Medium components: 12 pt (e.g. cards, medium dialogs) + public var medium: CGFloat + /// Large components: 16 pt (e.g. navigation drawer, large FAB) + public var large: CGFloat + /// Extra-large components: 28 pt (e.g. large dialogs, bottom sheets) + public var extraLarge: CGFloat + /// Full rounding: 50 pt (e.g. buttons, FAB) + public var full: CGFloat + + public init( + extraSmall: CGFloat = 4, + small: CGFloat = 8, + medium: CGFloat = 12, + large: CGFloat = 16, + extraLarge: CGFloat = 28, + full: CGFloat = 50 + ) { + self.extraSmall = extraSmall + self.small = small + self.medium = medium + self.large = large + self.extraLarge = extraLarge + self.full = full + } + + /// The default Material Design 3 shape scale. + public static let `default` = MaterialShapes() +} diff --git a/Sources/MattiUI/Theme/MaterialTheme.swift b/Sources/MattiUI/Theme/MaterialTheme.swift new file mode 100644 index 0000000..ec95210 --- /dev/null +++ b/Sources/MattiUI/Theme/MaterialTheme.swift @@ -0,0 +1,70 @@ +// +// MaterialTheme.swift +// MattiUI +// +// Material Design 3 Theme — environment-based theming for SwiftUI. +// Reference: https://m3.material.io/foundations/design-tokens/overview + +import SwiftUI + +// MARK: - MaterialTheme + +/// The top-level Material Design 3 theme object. +/// +/// Inject it into the environment using `.materialTheme(...)` and +/// read it with `@Environment(\.materialTheme)`. +/// +/// ```swift +/// MyRootView() +/// .materialTheme(.init(colorScheme: .light)) +/// ``` +@available(iOS 15, macOS 12.0, *) +public struct MaterialTheme { + public var colorScheme: MaterialColorScheme + public var typography: MaterialTypography + public var shapes: MaterialShapes + + public init( + colorScheme: MaterialColorScheme = .light, + typography: MaterialTypography = .default, + shapes: MaterialShapes = .default + ) { + self.colorScheme = colorScheme + self.typography = typography + self.shapes = shapes + } + + /// A light theme using the baseline M3 purple palette. + public static let light = MaterialTheme(colorScheme: .light) + + /// A dark theme using the baseline M3 purple palette. + public static let dark = MaterialTheme(colorScheme: .dark) +} + +// MARK: - Environment Key + +@available(iOS 15, macOS 12.0, *) +private struct MaterialThemeKey: EnvironmentKey { + static let defaultValue: MaterialTheme = .light +} + +@available(iOS 15, macOS 12.0, *) +extension EnvironmentValues { + /// The current Material Design 3 theme. + public var materialTheme: MaterialTheme { + get { self[MaterialThemeKey.self] } + set { self[MaterialThemeKey.self] = newValue } + } +} + +// MARK: - View Extension + +@available(iOS 15, macOS 12.0, *) +extension View { + /// Applies a Material Design 3 theme to this view and all of its children. + /// + /// - Parameter theme: The `MaterialTheme` to inject into the environment. + public func materialTheme(_ theme: MaterialTheme) -> some View { + self.environment(\.materialTheme, theme) + } +} diff --git a/Sources/MattiUI/Theme/MaterialTypography.swift b/Sources/MattiUI/Theme/MaterialTypography.swift new file mode 100644 index 0000000..0cebca5 --- /dev/null +++ b/Sources/MattiUI/Theme/MaterialTypography.swift @@ -0,0 +1,114 @@ +// +// MaterialTypography.swift +// MattiUI +// +// Material Design 3 Type Scale +// Reference: https://m3.material.io/styles/typography/type-scale-tokens + +import SwiftUI + +// MARK: - MaterialTextStyle + +/// Represents a single typographic style with font, line height, and letter spacing. +@available(iOS 15, macOS 12.0, *) +public struct MaterialTextStyle { + public var font: Font + public var lineHeight: CGFloat + public var letterSpacing: CGFloat + + public init(font: Font, lineHeight: CGFloat, letterSpacing: CGFloat) { + self.font = font + self.lineHeight = lineHeight + self.letterSpacing = letterSpacing + } +} + +// MARK: - MaterialTypography + +/// The Material Design 3 type scale containing all typographic roles. +/// +/// - SeeAlso: https://m3.material.io/styles/typography/type-scale-tokens +@available(iOS 15, macOS 12.0, *) +public struct MaterialTypography { + // Display + public var displayLarge: MaterialTextStyle + public var displayMedium: MaterialTextStyle + public var displaySmall: MaterialTextStyle + + // Headline + public var headlineLarge: MaterialTextStyle + public var headlineMedium: MaterialTextStyle + public var headlineSmall: MaterialTextStyle + + // Title + public var titleLarge: MaterialTextStyle + public var titleMedium: MaterialTextStyle + public var titleSmall: MaterialTextStyle + + // Body + public var bodyLarge: MaterialTextStyle + public var bodyMedium: MaterialTextStyle + public var bodySmall: MaterialTextStyle + + // Label + public var labelLarge: MaterialTextStyle + public var labelMedium: MaterialTextStyle + public var labelSmall: MaterialTextStyle + + public init( + displayLarge: MaterialTextStyle, displayMedium: MaterialTextStyle, displaySmall: MaterialTextStyle, + headlineLarge: MaterialTextStyle, headlineMedium: MaterialTextStyle, headlineSmall: MaterialTextStyle, + titleLarge: MaterialTextStyle, titleMedium: MaterialTextStyle, titleSmall: MaterialTextStyle, + bodyLarge: MaterialTextStyle, bodyMedium: MaterialTextStyle, bodySmall: MaterialTextStyle, + labelLarge: MaterialTextStyle, labelMedium: MaterialTextStyle, labelSmall: MaterialTextStyle + ) { + self.displayLarge = displayLarge + self.displayMedium = displayMedium + self.displaySmall = displaySmall + self.headlineLarge = headlineLarge + self.headlineMedium = headlineMedium + self.headlineSmall = headlineSmall + self.titleLarge = titleLarge + self.titleMedium = titleMedium + self.titleSmall = titleSmall + self.bodyLarge = bodyLarge + self.bodyMedium = bodyMedium + self.bodySmall = bodySmall + self.labelLarge = labelLarge + self.labelMedium = labelMedium + self.labelSmall = labelSmall + } + + // MARK: - Default M3 Type Scale + + /// The default Material Design 3 type scale. + public static let `default` = MaterialTypography( + displayLarge: MaterialTextStyle(font: .system(size: 57, weight: .regular), lineHeight: 64, letterSpacing: -0.25), + displayMedium: MaterialTextStyle(font: .system(size: 45, weight: .regular), lineHeight: 52, letterSpacing: 0), + displaySmall: MaterialTextStyle(font: .system(size: 36, weight: .regular), lineHeight: 44, letterSpacing: 0), + headlineLarge: MaterialTextStyle(font: .system(size: 32, weight: .regular), lineHeight: 40, letterSpacing: 0), + headlineMedium: MaterialTextStyle(font: .system(size: 28, weight: .regular), lineHeight: 36, letterSpacing: 0), + headlineSmall: MaterialTextStyle(font: .system(size: 24, weight: .regular), lineHeight: 32, letterSpacing: 0), + titleLarge: MaterialTextStyle(font: .system(size: 22, weight: .regular), lineHeight: 28, letterSpacing: 0), + titleMedium: MaterialTextStyle(font: .system(size: 16, weight: .medium), lineHeight: 24, letterSpacing: 0.15), + titleSmall: MaterialTextStyle(font: .system(size: 14, weight: .medium), lineHeight: 20, letterSpacing: 0.1), + bodyLarge: MaterialTextStyle(font: .system(size: 16, weight: .regular), lineHeight: 24, letterSpacing: 0.5), + bodyMedium: MaterialTextStyle(font: .system(size: 14, weight: .regular), lineHeight: 20, letterSpacing: 0.25), + bodySmall: MaterialTextStyle(font: .system(size: 12, weight: .regular), lineHeight: 16, letterSpacing: 0.4), + labelLarge: MaterialTextStyle(font: .system(size: 14, weight: .medium), lineHeight: 20, letterSpacing: 0.1), + labelMedium: MaterialTextStyle(font: .system(size: 12, weight: .medium), lineHeight: 16, letterSpacing: 0.5), + labelSmall: MaterialTextStyle(font: .system(size: 11, weight: .medium), lineHeight: 16, letterSpacing: 0.5) + ) +} + +// MARK: - View Modifier for M3 Text Styles + +@available(iOS 15, macOS 12.0, *) +extension View { + /// Applies a Material Design 3 text style to the view. + public func m3TextStyle(_ style: MaterialTextStyle) -> some View { + self + .font(style.font) + .kerning(style.letterSpacing) + } +}