2
6

More than 1 year has passed since last update.

Swift Playground4「予定表」Appプロジェクトを解説する

Last updated at Posted at 2022-01-03

この投稿はなに?

iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「予定表」Appプロジェクトを学ぶための解説です。

「予定表」Appプロジェクト

このプロジェクトでは、イベントを日付ごとに整理してスケジュールを立てる「DatePlanner」アプリを開発します。
このアプリでは、リストビューとオブザーバ・データモデルを使って、イベントやタスクを動的に生成するリストを構築します。
そのために、アプリのビュー階層全体から利用できる「一貫性のあるデータオブジェクト」を作成します。

IMG_0525.jpeg

リソースファイル

プロジェクトに含まれるファイルは、機能的な視点から「データモデル(イベントとタスク)、ビュー、シンボルその他」に分けて捉えることができます。

データモデル

  • DatePlannerApp
  • EventData
  • Event
  • EventTask

ビュー

  • EventList
  • EventRow
  • EventDetail
  • EventEditor
  • TaskRow

シンボルその他

  • SymbolPicker
  • EventSymbols
  • SFSymbolStyling
  • ColorOption

DatePlannerApp.swift

import SwiftUI

@main
struct DatePlannerApp: App {
    @StateObject private var eventData = EventData()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                EventList()
                Text("Select an Event")
                    .foregroundStyle(.secondary)
            }
            .environmentObject(eventData)

        }
    }
}

アプリ内のビュー間を往来できるようにするため、ビュー階層の最上位にナビゲーションコンテナを配置します。
このアプリを起動すると、最初にEventListビューがナビゲーションの起点として表示されます。

アプリに対して画面幅が十分に確保されている場合、EventListビューはサイドバー形式で表示されます。
IMG_0524.jpeg

ユーザがサイドバーのイベント行事を選択するまでは、Textビューが表示されるようにしています。
ユーザがイベント行事を選択すると、そのEventDetailビューがプライマリパネルに表示されます。

イベント行事のデータは、eventDataプロパティに保持されます。
このEventData型プロパティは@StateObject属性でマークされるので、オブザーバブル・オブジェクトのインスタンスを作成します。
したがって、この値の変化はSwiftUIフレームワークによって追跡され、依存しているビューの表示を自動的に更新できます。

ここでは、ビュー階層全体でeventDataプロパティの値を利用するために、.environmentObjectモディファイアを使って最上位階層のビューにeventData`インスタンスを渡します。

Event.swift

このアプリでは、旅行やパーティーなどのイベント行事をEvent型オブジェクトのコレクションとして整理します。
各イベントには「シンボル、色、タイトル、タスク(やること)のリスト、日付」などの情報が含まれます。

Event.swift
import SwiftUI

struct Event: Identifiable, Hashable {
    var id = UUID()
    var symbol: String = EventSymbols.randomName()
    var color: Color = ColorOptions.random()
    var title = ""
    var tasks = [EventTask(text: "")]
    var date = Date()

    var remainingTaskCount: Int {
        tasks.filter { !$0.isCompleted }.count
    }

    var isComplete: Bool {
        tasks.allSatisfy { $0.isCompleted }
    }

    var isPast: Bool {
        date < Date.now
    }

    var isWithinSevenDays: Bool {
        !isPast && date < Date.now.sevenDaysOut
    }

    var isWithinSevenToThirtyDays: Bool {
        !isPast && !isWithinSevenDays && date < Date.now.thirtyDaysOut
    }

    var isDistant: Bool {
        date >= Date().thirtyDaysOut
    }

    static var example = Event(
        symbol: "case.fill",
        title: "Sayulita Trip",
        tasks: [
            EventTask(text: "Buy plane tickets"),
            EventTask(text: "Get a new bathing suit"),
            EventTask(text: "Find an airbnb"),
        ],
        date: Date(timeIntervalSinceNow: 60 * 60 * 24 * 365 * 1.5))
}

// 日付操作を行う便宜上のメソッド
extension Date {
    var sevenDaysOut: Date {
        Calendar.autoupdatingCurrent.date(byAdding: .day, value: 7, to: self) ?? self
    }

    var thirtyDaysOut: Date {
        Calendar.autoupdatingCurrent.date(byAdding: .day, value: 30, to: self) ?? self
    }
}

Event構造体

Event型をIdentifiableプロトコルに準拠させることで、イベントのリストを作成する際に各行事が識別できるようになります。

リストの各セクションに表示される項目は、計算プロパティが算出した値に基づいて動的に変化します。
isPast計算プロパティは「イベントの日付が過去のものかどうか」を算出します。
remainingTaskCount計算プロパティは、イベントに含まれている「未完了なタスク」の数です。
isCompleteプロパティは、イベントに含まれるすべてのタスクが「完了済みかどうか」の真偽値です。

Calendar構造体

日付の計算や比較のための機能を提供するデータ型です。
Calendar型は「時間にまつわる計算をどのように行うか」をカプセル化しています。
カレンダーに関する情報を提供したり、与えられたカレンダー単位の範囲を決定したり、与えられた絶対時間に単位を追加するなどの機能を提供します。

autoupdatingCurrentプロパティ(型プロパティ)

ユーザーの希望するカレンダーの変更を追跡するカレンダーです。
変異した場合、このカレンダーはユーザーの希望するカレンダーを追跡しなくなる。
自動更新されるカレンダーは、他の自動更新されるカレンダーと比較されるだけです。

date(byAdding:value:to:wrappingComponents:)メソッド

指定された日付に「指定された成分の量」を加算して、計算された新しい日付のDate型インスタンスを返します。

パラメータ

  • components
    日付に追加する値のセットです。

  • value
    指定したコンポーネントに加算する量です。

  • date
    開始日です。

  • wrappingComponents
    trueの場合、コンポーネントは増分され、オーバーフロー時に0/1にラップアラウンドし、より高次のコンポーネントが増分される原因にならないようにします。デフォルトはfalseなので、メソッド呼び出し時に省略できますす。

返り値
与えられた入力で日付が計算できなかった場合はnilを返します。

EventTask.swift

各イベント行事には、「タスク(やること)のリスト」があります。
各タスクは、EventTask構造体として定義されます。

import Foundation

struct EventTask: Identifiable, Hashable {
    var id = UUID()
    var text: String
    var isCompleted = false
    var isNew = false
}

Event型と同様に、EventTask型もリスト内で識別できるようにするため、Identifiableプロトコルに準拠します。
タスクには、「説明文、完了フラグ、新規フラグ」を示す情報があります。
Event型インスタンスは「タスクの完了フラグ」を数えて、未完了タスクの数を追跡します。

EventData.swift

EventData型は、DatePlannerアプリのモデルデータとなるオブジェクトであり、すべてのイベントを格納および変更します。
そのために、EventData型はObservableObjectプロトコルに準拠しています。
また、eventsプロパティは@Published属性でマークされており、この公開値に依存するSwiftUIビューは自動的に表示が更新されます。

import SwiftUI

class EventData: ObservableObject {
    @Published var events: [Event] = [
        Event(symbol: "gift.fill",
              color: .red,
              title: "Maya's Birthday",
              tasks: [EventTask(text: "Guava kombucha"),
                      EventTask(text: "Paper cups and plates"),
                      EventTask(text: "Cheese plate"),
                      EventTask(text: "Party poppers"),
                     ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 30)),
        Event(symbol: "theatermasks.fill",
              color: .yellow,
              title: "Pagliacci",
              tasks: [EventTask(text: "Buy new tux"),
                      EventTask(text: "Get tickets"),
                      EventTask(text: "Pick up Carmen at the airport and bring her to the show"),
                     ],
              date: Date.roundedHoursFromNow(60 * 60 * 22)),
        Event(symbol: "facemask.fill",
              color: .indigo,
              title: "Doctor's Appointment",
              tasks: [EventTask(text: "Bring medical ID"),
                      EventTask(text: "Record heart rate data"),
                     ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 4)),
        Event(symbol: "leaf.fill",
              color: .green,
              title: "Camping Trip",
              tasks: [EventTask(text: "Find a sleeping bag"),
                      EventTask(text: "Bug spray"),
                      EventTask(text: "Paper towels"),
                      EventTask(text: "Food for 4 meals"),
                      EventTask(text: "Straw hat"),
                     ],
              date: Date.roundedHoursFromNow(60 * 60 * 36)),
        Event(symbol: "gamecontroller.fill",
              color: .cyan,
              title: "Game Night",
              tasks: [EventTask(text: "Find a board game to bring"),
                      EventTask(text: "Bring a desert to share"),
                     ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 2)),
        Event(symbol: "graduationcap.fill",
              color: .primary,
              title: "First Day of School",
              tasks: [
                  EventTask(text: "Notebooks"),
                  EventTask(text: "Pencils"),
                  EventTask(text: "Binder"),
                  EventTask(text: "First day of school outfit"),
              ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 365)),
        Event(symbol: "book.fill",
              color: .purple,
              title: "Book Launch",
              tasks: [
                  EventTask(text: "Finish first draft"),
                  EventTask(text: "Send draft to editor"),
                  EventTask(text: "Final read-through"),
              ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 365 * 2)),
        Event(symbol: "globe.americas.fill",
              color: .gray,
              title: "WWDC",
              tasks: [
                  EventTask(text: "Watch Keynote"),
                  EventTask(text: "Watch What's new in SwiftUI"),
                  EventTask(text: "Go to DT developer labs"),
                  EventTask(text: "Learn about Create ML"),
              ],
              date: Date.from(month: 6, day: 7, year: 2021)),
        Event(symbol: "case.fill",
              color: .orange,
              title: "Sayulita Trip",
              tasks: [
                  EventTask(text: "Buy plane tickets"),
                  EventTask(text: "Get a new bathing suit"),
                  EventTask(text: "Find a hotel room"),
              ],
              date: Date.roundedHoursFromNow(60 * 60 * 24 * 19)),
    ]

    func delete(_ event: Event) {
        events.removeAll { $0.id == event.id }
    }

    func add(_ event: Event) {
        events.append(event)
    }

    func exists(_ event: Event) -> Bool {
        events.contains(event)
    }

    func sortedEvents(period: Period) -> Binding<[Event]> {
        Binding<[Event]>(
            get: {
                self.events
                    .filter {
                        switch period {
                        case .nextSevenDays:
                            return $0.isWithinSevenDays
                        case .nextThirtyDays:
                            return $0.isWithinSevenToThirtyDays
                        case .future:
                            return $0.isDistant
                        case .past:
                            return $0.isPast
                        }
                    }
                    .sorted { $0.date < $1.date }
            },
            set: { events in
                for event in events {
                    if let index = self.events.firstIndex(where: { $0.id == event.id }) {
                        self.events[index] = event
                    }
                }
            }
        )
    }
}

enum Period: String, CaseIterable, Identifiable {
    case nextSevenDays = "Next 7 Days"
    case nextThirtyDays = "Next 30 Days"
    case future = "Future"
    case past = "Past"

    var id: String { self.rawValue }
    var name: String { self.rawValue }
}

extension Date {
    static func from(month: Int, day: Int, year: Int) -> Date {
        var dateComponents = DateComponents()
        dateComponents.year = year
        dateComponents.month = month
        dateComponents.day = day

        let calendar = Calendar(identifier: .gregorian)
        if let date = calendar.date(from: dateComponents) {
            return date
        } else {
            return Date()
        }
    }

    static func roundedHoursFromNow(_ hours: Double) -> Date {
        let exactDate = Date(timeIntervalSinceNow: hours)
        guard let hourRange = Calendar.current.dateInterval(of: .hour, for: exactDate) else {
            return exactDate
        }
        return hourRange.end
    }
}

EventData構造体

eventsプロパティは「Event型インスタンスの配列」の変数です。
この変数はオブザーバブル・オブジェクトの公開値なので、配列の要素を操作すると、それに応じてUIが更新されます。

delete(_:)メソッド、add(_:)メソッド、exists(_:)メソッドは、イベントを追加・削除するための機能を提供します。
また、sortedEvents(period:)メソッドは、「指定された期間」で並べ替えたイベント配列を返します。(例えば、「今日から7日間分のイベント」など)

Period列挙型

イベントのリストを並べ替えるために使用する「期間のカテゴリ」です。

EventList.swift

イベントをリスト形式で表示するためのビューであり、ナビゲーションの起点になります。
このListビューでは、イベントはセクションによってグループごとに分けて表示されます。

import SwiftUI

struct EventList: View {
    @EnvironmentObject var eventData: EventData
    @State private var isAddingNewEvent = false
    @State private var newEvent = Event()

    var body: some View {

        List {
            ForEach(Period.allCases) { period in
                if !eventData.sortedEvents(period: period).isEmpty {
                    Section(content: {
                        ForEach(eventData.sortedEvents(period: period)) { $event in
                            NavigationLink {
                                EventEditor(event: $event)
                            } label: {
                                EventRow(event: event)
                            }
                            .swipeActions {
                                Button(role: .destructive) {
                                    eventData.delete(event)
                                } label: {
                                    Label("Delete", systemImage: "trash")
                                }
                            }
                        }
                    }, header: {
                        Text(period.name)
                            .font(.callout)
                            .foregroundColor(.secondary)
                            .fontWeight(.bold)
                    })
                }
            }
        }
        .navigationTitle("Date Planner")
        .toolbar {
            ToolbarItem {
                Button {
                    newEvent = Event()
                    isAddingNewEvent = true
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
        .sheet(isPresented: $isAddingNewEvent) {
            NavigationView {
                EventEditor(event: $newEvent, isNew: true)
            }
        }
    }
}

struct EventList_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            EventList().environmentObject(EventData())

        }
    }
}

このリストビューはDatePlannerApp型の定義において、ナビゲーションの最上位ビューとして.environmentObjectモディファイアを使ってEventData型インスタンスを受け取っています。
したがって、このリストビューではEnvironmentObject属性のEventData型プロパティとして変数を宣言することで、「一貫性のあるモデルデータ」にアクセスできるようになります。

このビューのリストでは、ForEachコンテナに「すべての期間のイベント」を対象に反復処理を実行しています。
そして、「対象の期間」ごとにセクションを設けてイベントを表示します。その期間にイベントが存在しなければ、セクションは作成されません。
リスト内の各イベント行事は、それぞれがEventRowビューによって定義されています。

ユーザーがイベントをタップすると、ナビゲーションは画面をEventEditorビューに遷移します。
タップによる画面の遷移はNavigationLinkを使用しています。

リストのスワイプ操作によるイベント削除を実装するために、swipeActionsモディファイアを使用しています。
この時、EventData型のdelete()メソッドが呼び出されます。

EventRow.swift

import SwiftUI

struct EventRow: View {
    let event: Event

    var body: some View {
        HStack {
            Image(systemName: event.symbol)
                .sfSymbolStyling()
                .foregroundStyle(event.color)

            VStack(alignment: .leading, spacing: 5) {
                Text(event.title)
                    .fontWeight(.bold)

                Text(event.date.formatted(date: .abbreviated, time: .shortened))
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }

            if event.isComplete {
                Spacer()
                Image(systemName: "checkmark")
                    .foregroundStyle(.secondary)
            }

        }
        .badge(event.remainingTaskCount)
    }
}

struct EventRow_Previews: PreviewProvider {
    static var previews: some View {
        EventRow(event: Event.example)
    }
}

EventDetail.swift

import SwiftUI

struct EventDetail: View {
    @Binding var event: Event
    let isEditing: Bool

    @State private var isPickingSymbol = false

    var body: some View {
        List {

            HStack {
                Button {
                    isPickingSymbol.toggle()
                } label: {
                    Image(systemName: event.symbol)
                        .sfSymbolStyling()
                        .foregroundColor(event.color)
                        .opacity(isEditing ? 0.3 : 1)
                }
                .buttonStyle(.plain)
                .padding(.horizontal, 5)

                if isEditing {
                    TextField("New Event", text: $event.title)
                        .font(.title2)
                } else {
                    Text(event.title)
                        .font(.title2)
                        .fontWeight(.semibold)
                }
            }

            if isEditing {
                DatePicker("Date", selection: $event.date)
                    .labelsHidden()
                    .listRowSeparator(.hidden)

            } else {
                HStack {
                    Text(event.date, style: .date)
                    Text(event.date, style: .time)
                }
                .listRowSeparator(.hidden)
            }

            Text("Tasks")
                .fontWeight(.bold)

            ForEach($event.tasks) { $item in
                TaskRow(task: $item, isEditing: isEditing)
            }
            .onDelete(perform: { indexSet in
                event.tasks.remove(atOffsets: indexSet)
            })

            Button {
                event.tasks.append(EventTask(text: "", isNew: true))
            } label: {
                HStack {
                    Image(systemName: "plus")
                    Text("Add Task")
                }
            }
            .buttonStyle(.borderless)
        }
        #if os(iOS)
        .navigationBarTitleDisplayMode(.inline)
        #endif
        .sheet(isPresented: $isPickingSymbol) {
            SymbolPicker(event: $event)
        }
    }
}

struct EventDetail_Previews: PreviewProvider {
    static var previews: some View {
        EventDetail(event: .constant(Event.example), isEditing: true)
    }
}

EventEditor.swift

import SwiftUI

struct EventEditor: View {
    @Binding var event: Event
    var isNew = false

    @State private var isDeleted = false
    @EnvironmentObject var eventData: EventData
    @Environment(\.dismiss) private var dismiss

    // Keep a local copy in case we make edits, so we don't disrupt the list of events.
    // This is important for when the date changes and puts the event in a different section.
    @State private var eventCopy = Event()
    @State private var isEditing = false

    private var isEventDeleted: Bool {
        !eventData.exists(event) && !isNew
    }

    var body: some View {
        VStack {
            EventDetail(event: $eventCopy, isEditing: isNew ? true : isEditing)
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        if isNew {
                            Button("Cancel") {
                                dismiss()
                            }
                        }
                    }
                    ToolbarItem {
                        Button {
                            if isNew {
                                eventData.events.append(eventCopy)
                                dismiss()
                            } else {
                                if isEditing && !isDeleted {
                                    print("Done, saving any changes to \(event.title).")
                                    withAnimation {
                                        event = eventCopy // Put edits (if any) back in the store.
                                    }
                                }
                                isEditing.toggle()
                            }
                        } label: {
                            Text(isNew ? "Add" : (isEditing ? "Done" : "Edit"))
                        }
                    }
                }
                .onAppear {
                    eventCopy = event // Grab a copy in case we decide to make edits.
                }
                .disabled(isEventDeleted)

            if isEditing && !isNew {

                Button(role: .destructive, action: {
                    isDeleted = true
                    dismiss()
                    eventData.delete(event)
                }, label: {
                    Label("Delete Event", systemImage: "trash.circle.fill")
                        .font(.title2)
                        .foregroundColor(.red)
                })
                    .padding()
            }
        }
        .overlay(alignment: .center) {
            if isEventDeleted {
                Color(UIColor.systemBackground)
                Text("Event Deleted. Select an Event.")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct EventEditor_Previews: PreviewProvider {
    static var previews: some View {
        EventEditor(event: .constant(Event()))
    }
}

EventSymbols.swift

import Foundation

struct EventSymbols {
    static func randomName() -> String {
        if let random = symbolNames.randomElement() {
            return random
        } else {
            return ""
        }
    }

    static func randomNames(_ number: Int) -> [String] {
        var names: [String] = []
        for _ in 0..<number {
            names.append(randomName())
        }
        return names
    }

    static var symbolNames: [String] = [
        "house.fill",
        "ticket.fill",
        "gamecontroller.fill",
        "theatermasks.fill",
        "ladybug.fill",
        "books.vertical.fill",
        "moon.zzz.fill",
        "umbrella.fill",
        "paintbrush.pointed.fill",
        "leaf.fill",
        "globe.americas.fill",
        "clock.fill",
        "building.2.fill",
        "gift.fill",
        "graduationcap.fill",
        "heart.rectangle.fill",
        "phone.bubble.left.fill",
        "cloud.rain.fill",
        "building.columns.fill",
        "mic.circle.fill",
        "comb.fill",
        "person.3.fill",
        "bell.fill",
        "hammer.fill",
        "star.fill",
        "crown.fill",
        "briefcase.fill",
        "speaker.wave.3.fill",
        "tshirt.fill",
        "tag.fill",
        "airplane",
        "pawprint.fill",
        "case.fill",
        "creditcard.fill",
        "infinity.circle.fill",
        "dice.fill",
        "heart.fill",
        "camera.fill",
        "bicycle",
        "radio.fill",
        "car.fill",
        "flag.fill",
        "map.fill",
        "figure.wave",
        "mappin.and.ellipse",
        "facemask.fill",
        "eyeglasses",
        "tram.fill",
    ]
}

SFSymbolStyling.swift

import SwiftUI

struct SFSymbolStyling: ViewModifier {
    func body(content: Content) -> some View {
        content
            .imageScale(.large)
            .symbolRenderingMode(.monochrome)
    }
}

extension View {
    func sfSymbolStyling() -> some View {
        modifier(SFSymbolStyling())
    }
}

SymbolPicker.swift

import SwiftUI

struct SymbolPicker: View {
    @Binding var event: Event
    @State private var selectedColor: Color = ColorOptions.default
    @Environment(\.dismiss) private var dismiss
    @State private var symbolNames = EventSymbols.symbolNames
    @State private var searchInput = ""

    var columns = Array(repeating: GridItem(.flexible()), count: 6)

    var body: some View {
        VStack {
            HStack {
                Spacer()
                Button {
                    dismiss()
                } label: {
                    Text("Done")
                }
                .padding()
            }
            HStack {
                Image(systemName: event.symbol)
                    .font(.title)
                    .imageScale(.large)
                    .foregroundColor(selectedColor)

            }
            .padding()

            HStack {
                ForEach(ColorOptions.all, id: \.self) { color in
                    Button {
                        selectedColor = color
                        event.color = color
                    } label: {
                        Circle()
                            .foregroundColor(color)
                    }
                }
            }
            .padding(.horizontal)
            .frame(height: 40)

            Divider()

            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(symbolNames, id: \.self) { symbolItem in
                        Button {
                            event.symbol = symbolItem
                        } label: {
                            Image(systemName: symbolItem)
                                .sfSymbolStyling()
                                .foregroundColor(selectedColor)
                                .padding(5)
                        }
                        .buttonStyle(.plain)
                    }
                }
                .drawingGroup()
            }
        }
        .onAppear {
            selectedColor = event.color
        }
    }
}

struct SFSymbolBrowser_Previews: PreviewProvider {
    static var previews: some View {
        SymbolPicker(event: .constant(Event.example))
    }
}

TaskRow.swift

import SwiftUI

struct TaskRow: View {
    @Binding var task: EventTask
    var isEditing: Bool
    @FocusState private var isFocused: Bool

    var body: some View {
        HStack {
            Button {
                task.isCompleted.toggle()
            } label: {
                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
            }
            .buttonStyle(.plain)

            if isEditing || task.isNew {
                TextField("Task description", text: $task.text)
                    .focused($isFocused)
                    .onChange(of: isFocused) { newValue in
                        if newValue == false {
                            task.isNew = false
                        }
                    }

            } else {
                Text(task.text)
            }

            Spacer()
        }
        .padding(.vertical, 10)
        .task {
            if task.isNew {
                isFocused = true
            }
        }
    }

}

struct TaskRow_Previews: PreviewProvider {
    static var previews: some View {
        TaskRow(task: .constant(EventTask(text: "Do something!")), isEditing: false)
    }
}

ColorOption.swift

import SwiftUI

struct ColorOptions {
    static var all: [Color] = [
        .primary,
        .gray,
        .red,
        .orange,
        .yellow,
        .green,
        .mint,
        .cyan,
        .indigo,
        .purple,
    ]

    static var `default` : Color = Color.primary

    static func random() -> Color {
        if let element = ColorOptions.all.randomElement() {
            return element
        } else {
            return .primary
        }

    }
}

2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6