/Loki

サ活記録アプリ for iOS

Primary LanguageSwift

Loki

Release Platform Twitter

Logo

Loki(ロキ)は、サ活の記録に特化したアプリです。

Download on the App Store

目次

スクリーンショット

スクリーンショット

ライト

サ活一覧 サ活登録 設定

ダーク

サ活一覧 サ活登録 設定

開発

誰でもこのプロジェクトを開発できます。

必要条件

  • macOS 13.5+
  • Xcode 15.2 (Swift 5.9.2)
  • Make
  • Mint

構成

  • UIの実装: SwiftUI
  • アーキテクチャ: MVVM
  • ブランチモデル: GitHub flow

セットアップ

  1. このプロジェクトをクローンします。

    $ git clone https://github.com/uhooi/Loki.git
    $ cd Loki
  2. Swiftプロジェクトの高速ビルドを有効にします。(任意)

    $ defaults write com.apple.dt.XCBuild EnableSwiftBuildSystemIntegration 1
  3. make setup を実行します。
    セットアップが完了すると、自動的にXcodeでワークスペースが開きます。

モジュール分割

モジュール分割

全体

Module diagram

https://www.figma.com/file/T6xPOXP9b1nzRey05q7ZL9/Loki_module_diagram?node-id=0%3A1&t=ucgi2aKXvCYXOjRD-1

Apps

  • アプリのエントリポイントで、ルートナビゲーションロジックを格納する
  • 基本的にすべてのFeatureモジュールに依存する
  • Dataモジュールに依存してはいけない
  • Coreモジュールに依存していい

参考: https://developer.android.com/topic/modularization/patterns#app-modules

Features

  • 各機能のビューやビューモデルを格納する
  • Appモジュールに依存してはいけない
  • ほかのFeatureモジュールに依存してはいけない
  • DataやCoreモジュールに依存していい

参考: https://developer.android.com/topic/modularization/patterns#feature-modules

Data

  • リポジトリやモデルを格納する
  • AppやFeatureモジュールに依存してはいけない
  • できる限りほかのDataモジュールに依存しない
  • Coreモジュールに依存していい

参考: https://developer.android.com/topic/modularization/patterns#data-modules

Core

  • 複数のモジュールが共通で使う処理を格納する
  • AppやFeature、Dataモジュールに依存してはいけない
  • ほかのCoreモジュールに依存していい

参考: https://developer.android.com/topic/modularization/patterns#common-modules

コーディングルール

コーディングルール

全体

ビュー

共通
  • ビューは単体テストを書かない
    • UIは手動でテストすることが多く、費用対効果に合わないため
  • できる限り分岐( ifswitch )を入れない
    • 単体テストを書かないため
  • できる限り Task { ... } をビューに書く
  • 状態はビューモデルの uiState に集約し、ビューでは保持しない
    • つまり @State を使わず、 @StateObject はビューモデルのみに付ける
    • @Published もビューモデルの uiState のみに付ける
  • できる限り @AppStorage を使わず、UserDefaultsへは Data 層でアクセスする
親ビュー
  • 画面全体のビュー(ここでは「親ビュー」と呼ぶ)を {画面名}Screen と命名する
  • 以下の処理を親ビューに書く
    • ビューモデルの保持
    • ナビゲーションロジック
    • ツールバー、シートやアラートなど、画面全体に関わる表示
      • .toolbar { ... }.sheet().alert() など
      • 例:
        .sakatsuListScreenToolbar(
        onAddButtonClick: { viewModel.onAddButtonClick() }
        )
        .sakatsuInputSheet(
        shouldShowSheet: viewModel.uiState.shouldShowInputSheet,
        selectedSakatsu: viewModel.uiState.selectedSakatsu,
        onDismiss: { viewModel.onInputSheetDismiss() },
        onSakatsuSave: { viewModel.onSakatsuSave() }
        )
        .copyingSakatsuTextAlert(
        sakatsuText: viewModel.uiState.sakatsuText,
        onDismiss: { viewModel.onCopyingSakatsuTextAlertDismiss() }
        )
        .errorAlert(
        error: viewModel.uiState.sakatsuListError,
        onDismiss: { viewModel.onErrorAlertDismiss() }
        )

        private extension View {
        func sakatsuListScreenToolbar(
        onAddButtonClick: @escaping () -> Void
        ) -> some View {
        toolbar {
        ToolbarItem(placement: .navigationBarTrailing) {
        Button {
        onAddButtonClick()
        } label: {
        Image(systemName: "plus")
        }
        }
        ToolbarItem(placement: .navigationBarLeading) {
        EditButton()
        }
        }
        }
        func sakatsuInputSheet(
        shouldShowSheet: Bool,
        selectedSakatsu: Sakatsu?,
        onDismiss: @escaping () -> Void,
        onSakatsuSave: @escaping () -> Void
        ) -> some View {
        sheet(isPresented: .init(get: {
        shouldShowSheet
        }, set: { _ in
        onDismiss()
        })) {
        NavigationView {
        SakatsuInputScreen(
        sakatsu: selectedSakatsu,
        onSakatsuSave: onSakatsuSave
        )
        }
        }
        }
        func copyingSakatsuTextAlert(
        sakatsuText: String?,
        onDismiss: @escaping () -> Void
        ) -> some View {
        alert(
        "コピー",
        isPresented: .init(get: {
        sakatsuText != nil
        }, set: { _ in
        onDismiss()
        }),
        presenting: sakatsuText
        ) { _ in
        } message: { sakatsuText in
        Text("サ活のテキストをコピーしました。")
        .onAppear {
        UIPasteboard.general.string = sakatsuText
        }
        }
        }
        }
  • 親ビューは最低限の処理のみ書き、ほかは直下の子ビューに書く
    • {画面名}View と命名する
    • 例:
      SakatsuListView(
      sakatsus: viewModel.uiState.sakatsus,
      onCopySakatsuTextButtonClick: { sakatsuIndex in
      viewModel.onCopySakatsuTextButtonClick(sakatsuIndex: sakatsuIndex)
      }, onEditButtonClick: { sakatsuIndex in
      viewModel.onEditButtonClick(sakatsuIndex: sakatsuIndex)
      }, onDelete: { offsets in
      viewModel.onDelete(at: offsets)
      }
      )
子ビュー

ビューモデル

  • 1画面1ビューモデルとする
  • {画面名}ViewModel と命名する
  • UIKitSwiftUI などのUIフレームワークをインポートしない
    • ビューモデルにUIを持ち込みたくないため
  • @MainActor を付けた final class とし、 ObservableObject に準拠する
  • 状態を uiState で一元管理し、 private(set) にしてビューから状態を変更させない
  • エラーは画面ごとに1つの列挙型へまとめ、 uiState で1つのみ保持する
  • ビューのイベントをハンドリングする
    • 基本的にはメソッド名をそのまま採用する
    • 例:
      // MARK: - Event handler
      extension SakatsuListViewModel {
      func onSakatsuSave() {
      uiState.shouldShowInputSheet = false
      refreshSakatsus()
      }
      func onAddButtonClick() {
      uiState.selectedSakatsu = nil
      uiState.shouldShowInputSheet = true
      }
      func onEditButtonClick(sakatsuIndex: Int) {
      uiState.selectedSakatsu = uiState.sakatsus[sakatsuIndex]
      uiState.shouldShowInputSheet = true
      }
      func onCopySakatsuTextButtonClick(sakatsuIndex: Int) {
      uiState.sakatsuText = sakatsuText(sakatsu: uiState.sakatsus[sakatsuIndex])
      }
      func onInputSheetDismiss() {
      uiState.shouldShowInputSheet = false
      uiState.selectedSakatsu = nil
      }
      func onCopyingSakatsuTextAlertDismiss() {
      uiState.sakatsuText = nil
      }
      func onDelete(at offsets: IndexSet) {
      let oldValue = uiState.sakatsus
      uiState.sakatsus.remove(atOffsets: offsets)
      do {
      try repository.saveSakatsus(uiState.sakatsus)
      } catch {
      uiState.sakatsuListError = .sakatsuDeleteFailed(localizedDescription: error.localizedDescription)
      uiState.sakatsus = oldValue
      }
      }
      func onErrorAlertDismiss() {
      uiState.sakatsuListError = nil
      }
      private func sakatsuText(sakatsu: Sakatsu) -> String {
      var text = ""
      if let foreword = sakatsu.foreword {
      text += "\(foreword)\n\n"
      }
      text += "\(sakatsu.saunaSets.count)セット行いました。"
      for saunaSet in sakatsu.saunaSets {
      var saunaSetItemTexts: [String] = []
      saunaSetItemText(saunaSetItem: saunaSet.sauna).map { saunaSetItemTexts.append($0) }
      saunaSetItemText(saunaSetItem: saunaSet.coolBath).map { saunaSetItemTexts.append($0) }
      saunaSetItemText(saunaSetItem: saunaSet.relaxation).map { saunaSetItemTexts.append($0) }
      if !saunaSetItemTexts.isEmpty {
      text += "\n"
      text += saunaSetItemTexts.joined(separator: "")
      }
      }
      if let afterword = sakatsu.afterword {
      text += "\n\n\(afterword)"
      }
      return text
      }
      private func saunaSetItemText(saunaSetItem: any SaunaSetItemProtocol) -> String? {
      guard !(saunaSetItem.title.isEmpty && saunaSetItem.time == nil) else {
      return nil
      }
      var text = "\(saunaSetItem.emoji)\(saunaSetItem.title)"
      if let time = saunaSetItem.time {
      text += "\(time.formatted())\(saunaSetItem.unit)"
      }
      return text
      }
      }

パッケージ管理

パッケージ管理

ライブラリ

Swift製
  • Package.swift のみで管理する
その他
  • できる限り使わない
  • どうしても使う場合、適切に管理する

CLIツール

Swift製
  • Build Tool PluginまたはCommand Pluginで管理する
    • 用意されていない場合は自作してOSSにPRを送る
    • マージされない場合、本リポジトリまたはプラグイン用のリポジトリを作成してコミットする
  • どうしてもPluginを用意できない場合、Mintで管理する
Ruby製
  • できる限り使わない
  • どうしても使う場合、Bundlerで管理する
その他
  • できる限り使わない
  • どうしても使う場合、適切に管理する

貢献

貢献をお待ちしています ☺️