4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【運動が続かない怠け癖をなくす】今までにない新しいフィットネスアプリを作ってみた

Last updated at Posted at 2020-09-14

#はじめに
初めまして, iOSエンジニアのakaakozこと小澤です。

今回は先月(2020年8月)リリースした周りのユーザーと監視しながら一緒に頑張るフィットネスiOSアプリ『モニトレ』を紹介したいと思います。

今年6月の中旬から開発をスタートして、9月15日にApp Storeにリリースしました。

この記事ではどんなサービスなのか開発に至った背景サービスの仕様と使用したコードについて書いていきます。

#モニトレとは?
monitore-top-4-3@1x.png
『モニトレ』は週に設定した筋トレやエクササイズのノルマを達成しながら、周りのユーザーと一緒に頑張るフィットネスアプリです。
(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)

無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)

例えば週に3回のノルマを設定した場合、週に3回自撮りしたエクササイズ動画をアップロードしないとアプリの"ナマケモノ"の欄に記載されてしまう、**"辱め"**という新しい形のモチベーションでユーザーの運動を継続させるサービスです。

以下の方にオススメのサービスです。
・運動の継続が難しい方、モチベーションが上がらない方
・値段が高いダイエットプログラムに加入せずに痩せたい方
・コロナ渦でジムに行けず、お家でトレーニングをしたい方

ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/

#サービスを作った背景
私は前サービス『WalCal』(訪日外国人向け体験型サービス)を含めサービス開発をスタートしてから2年間、ほぼ休みなしでプログラミングをしていました。(週7日、起きている時間はほぼプログラミング)

そんな多忙な日々を送っていたある日、ストレートネックを発症し首を傷めてしまい、1週間ベットから起き上がれない状態になってしまいました。

あまりの痛みで自力で起き上がれなくなってしまったため、28歳にして親から介護を受ける事になってしまいました。(親には感謝です。ありがとう)

それから1週間後、自力で起き上がれる様になりましたが、まだ残っていた首の痛みとめまいからデスクに座って作業が出来ない状況がさらに1週間続きました。

以下の写真がその時に撮ったレントゲンです。(2020年6月3日)

xray_neck.png
医者の診断は日頃の運動・柔軟不足が原因でした。

運動の大切さを分かっていながらも忙しいことを理由に怠け癖がついていました。

あと"1時間後にやる"が"1日後にやる"になり気付いたらそれが1週間続き、運動&ストレッチを全くしない期間がザラにありました。

1日10分すら自らの健康の時間を取れずにストレートネックを発症し、2週間何も作業が出来ず大切な時間を失ってしまいました。

この私が経験した様な、運動が継続的に出来ない怠け癖を解決出来ないかと考え開発に至りました。

#仕様
###1.周りのユーザーを評価
モニトレ説明2.png
モニトレは週に設定したノルマを達成しないと、アプリの"ナマケモノ"の欄に記載されてしまいます。(達成出来れば"ヤルキモノ"になります。)
1人ではどうしても怠けが出てしまいますので、周りのユーザーと励まし合い、時には喝を入れながら切磋琢磨出来る機能です。

###2.ノルマの達成方法は自撮りのエクササイズ動画をアップロード
モニトレ説明1.png

まずはモニトレのエクササイズ動画(スクワット、腕立て、腹筋、ストレッチなどのメニュー)を選んで一緒にエクササイズします。それと同時に自撮りのレコーディングも行っているので、終わったらそのままアップロード出来る仕様となっています。(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)

実際に私がモニトレで筋トレをしている様子です(YouTube)

モニトレのエクササイズ動画ですがYouTubeにアップロードしたものをYouTube Data Apiで引っ張っています。

###3.スケジュール機能でエクササイズ管理
モニトレ説明3.png

モニトレはスケジュール機能がついており、日々のエクササイズを管理できます。
メモ帳も使わずに過去のエクササイズ動画や自らのフォームもチェックできるため便利な機能かと思います。

後ほどこの機能の実装方法を紹介します。

#スケジュール機能のコードを紹介
開発言語はSwiftとデータベースはFirebaseを使用しています。(Android版も開発予定)
モニトレのスケジュール機能はサードパーティーライブラリのFSCalendarを使用して実装しています。
FSCalendar: https://github.com/WenchaoD/FSCalendar
カレンダー系アプリなどを作る時にものすごい便利なのでオススメです。

Viewの構造は以下の様になっています。
calenderView.png

###Step1: FSCalendarをインストール
Cocoapods/CathageでXcodeプロジェクトにFSCalendarをインストール
Podfileを開きFSCalendarのpodを以下の様に入力

use_frameworks!
target '<Your Target Name>' do
    pod 'FSCalendar'
end

Podをインストール

$ pod install

###Step2:FSCalendarを実装

//Model
import Foundation
import UIKit
import AVFoundation

struct UploadedVideoInfo {
    let videoUrl: String
    let uploadDate: Date
    let min: Double
    let weekth: Int
    let dateString: String
    let thumbnailImageUrl: String
    
    init(dictionary: [String: Any]) {
        self.videoUrl = dictionary["videoUrl"] as? String ?? ""
        let secondsFrom1970 = dictionary["uploadDate"] as? Double ?? 0.0
        self.uploadDate = Date(timeIntervalSince1970: secondsFrom1970)
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let strDate = dateFormatter.string(from: uploadDate)
        self.dateString = strDate
        self.min = dictionary["min"] as? Double ?? 0.0
        self.weekth = dictionary["weekth"] as? Int ?? 0
        self.thumbnailImageUrl = dictionary["thumbnailImageUrl"] as? String ?? ""
    }
    
    func dateFromComponent(date: Date) -> Int {
        let year = Calendar.current.component(.year, from: date)
        let month = Calendar.current.component(.month, from: date)
        let day = Calendar.current.component(.day, from: date)
        return year + month + day
    }

}

import UIKit
import FSCalendar
import Firebase
import AVFoundation
import Photos

class CalendarController: UICollectionViewController, UICollectionViewDelegateFlowLayout, FSCalendarDelegate, FSCalendarDataSource, FSCalendarDelegateAppearance {
    
    var allUploadVideos = [UploadedVideoInfo]()
    var todayUploadVideos = [UploadedVideoInfo]()
    
    private let fsCalendarCellId = "fsCalendarCellId"
    //FSCalenderを定義
    lazy var fsCalendar: FSCalendar = {
        let calender = FSCalendar()
        calender.backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1)
        calender.layer.cornerRadius = 5
        return calender
    }()
    
    //VideoCollectionControllerはFSCalenarの日付をタップした時に過去のエクササイズを表示させるためのViewController
    lazy var videoCollectionController: VideoCollectionController = {
        let viewController = VideoCollectionController(collectionViewLayout: UICollectionViewFlowLayout())
        return viewController
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.backgroundColor = .black
        
        navigationItem.title = "スケジュール"
         navigationController?.navigationBar.prefersLargeTitles = true
         navigationController?.navigationBar.backgroundColor = .black
         navigationController?.navigationBar.barTintColor = .black
        navigationController?.navigationBar.barTintColor = .black
        navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
        //FSCalendarの設定
        fsCalendar.register(FSCalendarCell.self, forCellReuseIdentifier: fsCalendarCellId)
        fsCalendar.dataSource = self
        fsCalendar.delegate = self
        fsCalendar.appearance.headerTitleColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1)
        fsCalendar.appearance.weekdayTextColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1)
    //以下のコードで日本語記載にします
        fsCalendar.locale = Locale(identifier: "ja_JP")
        
        fetchVideoInfo()
        setupFSCalendarView()
        
    }
    //過去のアップロードを引っ張ってくる関数
    fileprivate func fetchVideoInfo() {
        
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let today = dateFormatter.string(from: Date())
        //let uploadDate = dateFormatter.string(from: vi)
        print("today", today)
        
        guard let uid = Auth.auth().currentUser?.uid else {return}
        
          Firestore.firestore().collection("exercise_uploads").document(uid).collection("exercise_uploadId").getDocuments { (snapshot, err) in
              if let err = err {
                  print("failed to fetch video data", err.localizedDescription)
                  return
              }
              
              snapshot?.documents.forEach({ (document) in
                  let dict = document.data()
                  let uploadedVideoInfo = UploadedVideoInfo(dictionary: dict)
                  
                //Firestoreのデータベースから取ってきた
                self.allUploadVideos.append(uploadedVideoInfo)
                
                //ここで本日付の動画をフィルターします
                self.todayUploadVideos = self.allUploadVideos.filter({$0.dateString == today})
                
                DispatchQueue.main.async {
                    print("checking uploadvideos array", self.allUploadVideos)
                    self.videoCollectionController.todayUploadVideos = self.todayUploadVideos
                    self.fsCalendar.reloadData()
                }
               
              })
            
          }
    }
    //ビューの配置と制約
    fileprivate func setupFSCalendarView() {
        view.addSubview(fsCalendar)
        fsCalendar.anchor(top: view.safeAreaLayoutGuide.topAnchor, left: view.leftAnchor, bottom: nil, right: view.rightAnchor, paddingTop: 5, paddingLeft: 5, paddingBottom: 0, paddingRight: 5, height: view.frame.height / 2, width: 0)
        
        view.addSubview(videoCollectionController.view)
        videoCollectionController.view.anchor(top: fsCalendar.bottomAnchor, left: view.leftAnchor, bottom: view.safeAreaLayoutGuide.bottomAnchor, right: view.rightAnchor, paddingTop: 5, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)
    }
    
    //FSCalendarのnumberofEventsForメソッドで日付ごとのエクササイズアップロード数を日付ごとの表示
    func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let calendarDay = dateFormatter.string(from: date)
        print("calendarDay", calendarDay)
        let todayUploadVideos = self.allUploadVideos.filter({$0.dateString == calendarDay})
        return todayUploadVideos.count

    }
    
    //日付をタップした際に、VideoCollectionController内のCellへ過去のアップロード動画を表示させる関数
    func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) {
        
        self.todayUploadVideos.removeAll()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let calendarDay = dateFormatter.string(from: date)

        self.todayUploadVideos = allUploadVideos.filter({$0.dateString == calendarDay})
        
        DispatchQueue.main.async {
            print("checking uploadvideos array", self.todayUploadVideos)
            //self.videoCollectionController.uploadVideos = self.uploadVideos
            self.videoCollectionController.todayUploadVideos = self.todayUploadVideos
            self.collectionView.reloadData()
        }
    }
    
}

class VideoCollectionController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
    
    private let videoCollectionCellId = "videoCollectionCellId"
    private let videoCollectionFooterId = "videoCollectionFooterId"
    
    var todayUploadVideos: [UploadedVideoInfo]? {
        didSet {
            print("todayUploadVideos", todayUploadVideos)
            
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
        }
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.register(VideoCollectionCell.self, forCellWithReuseIdentifier: videoCollectionCellId)
        collectionView.register(VideoCollectionFooter.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: videoCollectionFooterId)
        
        collectionView.backgroundColor = .black
    }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return todayUploadVideos?.count ?? 0
    }
       
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: videoCollectionCellId, for: indexPath) as! VideoCollectionCell
        let thumbnailImageUrl = todayUploadVideos?[indexPath.item].thumbnailImageUrl
        cell.thumbnailCustomImageView.loadImage(urlString: thumbnailImageUrl ?? "")
        return cell
    }
       
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: view.frame.width / 3, height: view.frame.height)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }
    
    override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: videoCollectionFooterId, for: indexPath) as! VideoCollectionFooter
        footer.label.text = "アップロードした動画はありません"
        return footer
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        if todayUploadVideos?.count == 0 {
            return CGSize(width: view.frame.width, height: view.frame.height / 8)
        } else {
            return CGSize(width: 0, height: 0)
        }
        
    }
    
    override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("tapping cell")
        let playVideoController = PlayVideoController(collectionViewLayout: UICollectionViewFlowLayout())
        playVideoController.videoUrl = todayUploadVideos?[indexPath.item].videoUrl
        self.view.window?.rootViewController?.present(playVideoController, animated: true, completion: nil)
    }
    
}


class VideoCollectionCell: UICollectionViewCell {
    
    let thumbnailCustomImageView: CustomImageView = {
        let iv = CustomImageView()
        iv.backgroundColor = .black
        iv.contentMode = .scaleAspectFit
        iv.layer.masksToBounds = true
        iv.layer.borderWidth = 0.5
        iv.layer.borderColor = UIColor.white.cgColor
        iv.clipsToBounds = true
        return iv
    }()
    
    lazy var playButton: UIButton = {
        let button = UIButton(type: .system)
        button.setImage(UIImage(named: "play")?.withRenderingMode(.alwaysOriginal), for: .normal)
        //button.addTarget(self, action: #selector(handlePlay), for: .touchUpInside)
        return button
    }()
    
    let readyLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.text = "Ready?"
        label.font = .boldSystemFont(ofSize: 32)
        label.textColor = .white
        return label
    }()
    
    var playerLayer: AVPlayerLayer?
    var player: AVPlayer?
    
    @objc fileprivate func handlePlay() {
        print("handling play")

    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1)
        
        setupPlayButton()
    }
    
    fileprivate func setupPlayButton() {
        
        addSubview(thumbnailCustomImageView)
        thumbnailCustomImageView.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)
        
        thumbnailCustomImageView.addSubview(playButton)
        playButton.anchor(top: thumbnailCustomImageView.topAnchor, left: nil, bottom: nil, right: thumbnailCustomImageView.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: thumbnailCustomImageView.frame.height / 3, width: thumbnailCustomImageView.frame.width / 3)
       
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class VideoCollectionFooter: UICollectionViewCell {
    
    let label: UILabel = {
        let label = UILabel()
        label.font = UIFont.boldSystemFont(ofSize: 18)
        label.textColor = .white
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setupVideoCollectionFooterView()
    }
    
    fileprivate func setupVideoCollectionFooterView() {
        addSubview(label)
        label.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 10, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)
        label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        //label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

#メディア紹介
以下のメディア/サービスに『モニトレ』が紹介されました。

StartupTimes(スタートアップを取り上げるメディア):
https://startuptimes.jp/2020/08/19/211803/

Find Web(最新のウェブ/アプリを紹介するサイト):
https://findweb.jp/health/2481

NewLaun-ch(新サービスを紹介するサイト):
https://newlaun-ch.com/archives/products/bf77e713-418b-4337-a854-8fc8da38ab9b

#最後に

現在はモニトレを使って継続的に筋トレとストレッチも出来ていて、筋力もかなりついてきたと実感しています。

『モニトレ』で目指したいのは**"Endless Wellness=健康の永久化"**が出来る世界です。

自らの首を痛めてしまって2週間身動きが取れないのは肉体的にも精神的にもとても辛い経験でした。

まさに『体は資本』を身に感じた経験でもあり、この記事をご覧のエンジニアの方には体に支障が出る前に予防して欲しいと願っています。

運動の継続が難しい方、モチベーションが上がらない方、 是非モニトレを利用してみてください。

無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)

ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/
YouTubeチャンネル:https://www.youtube.com/channel/UCdhcHH9OjyOnireh5vTpkRQ/

『モニトレ』のサービス内容に関しての質問、サービスフィードバック、今回紹介したコードの質問があればコメント欄へどうぞ!

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?