열정 많은 개발자
, push 해야되는데 잊어버린 마감 급한 개발자 등등을 위한 1일 1커밋 강요앱
저희 CYC(Check your commit)는 크게 commit 알림 기능, 간단한 todo list를 갖고 있습니다.
강치우 | 김명현 | 이민영 | 최동호 | 황민채 | 황성진 |
---|---|---|---|---|---|
- 깃허브 OAuth를 통한 로그인 연동
- OAuth AcessToken을 바탕으로 유저 정보를 활용
- 유저 정보를 custom ProgressView, GrassView 등 활용
- 목표달성을 도와주는 챌린지 설정
- MainView에서 D-Day를 제공함으로, 목표를 가시적으로 확인
- 오늘 커밋을 위해 할일을 기록하는 TodoList
- 알람을 통해 일정시간마다 커밋 체크
앱 화면 |
---|
라이트 모드 | 다크 모드 |
---|---|
Step 1 타임라인
- 23.12.5 ~ 23.12.6
- 팀빌딩
- 아이디어 토의
- 아이디어 구현 방안 토의
Step 2 타임라인
- 23.12.06 ~ 23.12.07
- Figma를 기본 디자인 프로토타입 제작
- 각 기능별 구현 방안 토의
- 각 파트별 역할 분배
- 프로젝트 개발 시작
- 23.12.12 ~ 23.12.13
- 앱 아이콘 제작
Step 3 타임라인
- 23.12.06
- 기본 앱 구조 제작
- 커스텀 폰트, 컬러 Aseet 적용
- 23.12.07 ~ 23.12.11
- 깃허브 OAuth 로그인 구현
- OAuth 데이터를 통해 유저 정보 받아오는 부분 구현
- 23.12.07 ~ 23.12.14
- 알림기능 구현
- Todo List 구현
- 23.12.11 ~ 23.12.14
- 깃허브 API를 이용한 GrassView 구현
- 깃허브 API로 받아온 커밋일수로 D-day 계산기 구현
- 23.12.14
- 라이트 모드, 다크모드 변환 버튼 구현
API를 통해 JSON 유저 데이터가 정상적으로 불러와지지 않음
Git API
를 통해 유저 데이터가 JSON 형식으로 불러와지지 않는 문제
func getUser() {
let accessToken = KeychainSwift().get("accessToken") ?? ""
let headers: HTTPHeaders = ["Accept": "application/vnd.github.v3+json",
"Authorization": "token \(accessToken)"]
AF.request(githubApiURL+ApiPath.USER.rawValue,
method: .get,
parameters: [:],
headers: headers).responseJSON(completionHandler: { (response) in
switch response.result {
case .success(let json):
print(json as! [String: Any])
case .failure:
print("")
}
})
}
- 깃허브 유저 API 공식문서 해당 문서의 형태로 curl 을 사용하면 정상적으로 JSON 형태의 데이터가 받아와 지는 것을 확인
- API를 받아오는 과정에서 responseJSON 의 형태가 아니라 responseString 혹은 responseDecodable 으로 사용하면 정상적으로 데이터가 받아와 지는 것을 확인
- struct를 통해 User를 선언하고 responseDecodable 로 해당 데이터를 할당시키는 방법으로 활용
struct User: Decodable {
let login: String
let name: String
}
func getUser() {
let headers: HTTPHeaders = ["Accept": "application/vnd.github+json",
"Authorization": "Bearer \(access_token!)"]
AF.request("https://api.github.com/user",
method: .get, parameters: [:],
headers: headers).responseDecodable(of: User.self) { response in
switch response.result {
case .success(let user):
self.userLogin = user.login
self.userName = user.name
self.getCommitData()
case .failure(let error):
print("Error: \(error.localizedDescription)")
}
}
}
- REST API의 주소가 명확한지 확인하기위해 curl의 활용법을 알게됨.
GitHub contribution graph에 SwiftSoup 사용한 이유
let parsedHtml = try SwiftSoup.parse(htmlURL)
let dailyContribution = try parsedHtml.select("td")
let validCommits = dailyContribution.compactMap { element -> (String, String)? in
guard
let dateString = try? element.attr("data-date"),
let levelString = try? element.attr("data-level"),
!dateString.isEmpty
else { return nil }
return (dateString, levelString)
}
- Github profile에 있는 깃헙 잔디에 대한 데이터를 api로 제공해주지 않음
- commits history만 제공하지만 각 repo별로 history로 제공하거나, user events로 전체 commit을 복잡한 구조로 제공
- 하지만 제일 중요한건 무엇보다 api의 업데이트가 느려서 commit을 한 후 최대 8시간 후에 반영 됨
- 그러므로 가능한 빨리 반영되는 메인의 contribution graph를 통해 받아오기 위해 웹 크롤링 라이브러리를 사용하여 data를 받음
UserDefaults의 사용
API
를 활용하기 위해서는 액세스토큰 값이 절대적으로 필요, 앱을 종료 시켜도 해당 값은 유효해야 됨- AppStorage를 사용하려 했지만 다른 뷰에서도 사용하고 참조해야 되기 때문에 사용이 어려움
class LoginModel: ObservableObject {
static let shared = LoginModel()
@Published var code: String?
@Published var access_token: String?
@Published var userLogin: String?
- UserDefaults 로 해당 변수들을 선언하고 extension을 통해 set, get 부분을 적용
- init() 부분을 통해 선언된 변수를 초기화
@Published var access_token: String? {
didSet {
UserDefaults.standard.setAccessToken(access_token ?? "")
}
}
@Published var userName: String? {
didSet {
UserDefaults.standard.setUserName(userName ?? "")
}
}
@Published var userLogin: String? {
didSet {
UserDefaults.standard.setUserLogin(userLogin ?? "")
}
}
var results: [(String, String)] = []
@Published var testCase:[String:Int] = [:]
// UserDefaults로 선언된 변수를 사용하기 위한 init 부분
init() {
self.userLogin = UserDefaults.standard.getUserLogin()
self.access_token = UserDefaults.standard.getAccessToken()
self.userName = UserDefaults.standard.getUserName()
}
// UserDefaults의 extension 부분
extension UserDefaults {
private static let userLoginKey = "userLoginKey"
func setUserLogin(_ login: String) {
set(login, forKey: UserDefaults.userLoginKey)
}
func getUserLogin() -> String? {
return string(forKey: UserDefaults.userLoginKey)
}
}
extension UserDefaults {
private static let userAcessToken = "acessToken"
func setAccessToken(_ token: String) {
set(token, forKey: UserDefaults.userAcessToken)
}
func getAccessToken() -> String? {
return string(forKey: UserDefaults.userAcessToken)
}
}
extension UserDefaults {
private static let userNickname = "userNickname"
func setUserName(_ name: String) {
set(name, forKey: UserDefaults.userNickname)
}
func getUserName() -> String? {
return string(forKey: UserDefaults.userNickname)
}
}
FCM에서 userNotifications로 전환한 이유
처음 구현하고자 했던 기능의 순서는 다음과 같았다.
APNs
에 디바이스토큰
을 요청APNs
에서 받은 디바이스토큰
을Push server
에 넘김APNs
에 푸쉬알림을 보낼 데이터를 전달APNs
에 있는 데이터를 받아서 유저의 폰에서 알림 전달
import SwiftUI
import FirebaseCore
import FirebaseMessaging
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
// 원격 알림 등록
if #available(iOS 10.0, *) {
// For iOS 10 display notification (sent via APNS)
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { _, _ in }
)
} else {
let settings: UIUserNotificationSettings =
UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
application.registerUserNotificationSettings(settings)
}
application.registerForRemoteNotifications()
// Firebase 가 푸시 메시지를 대신 전송할 수 있도록 대리자를 설정하는 과정 (MessagingDelegate)
Messaging.messaging().delegate = self
// 푸시 포그라운드 설정
UNUserNotificationCenter.current().delegate = self
return true
//Messaging에 등록된 토큰은 messaging:didReceiveRegistrationToken 프로토콜 메서드를 1회 호출함 - 새로 등록된 토큰이라면 애플리케이션 서버로 전송/ 아니라면 등록된 토큰을 구독 처리해줌
}
// fcm 토큰이 등록 되었을 때
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
}
@main
struct CYCApp: App {
struct YourApp: App {
// register app delegate for Firebase setup
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
AboutCYC()
}
}
}
extension AppDelegate : MessagingDelegate {
// fcm 등록 토큰을 받았을 때
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
print("Firebase registration token: \(String(describing: fcmToken))")
let dataDict: [String: String] = ["token": fcmToken ?? ""]
NotificationCenter.default.post(
name: Notification.Name("FCMToken"),
object: nil,
userInfo: dataDict
)
}
}
extension AppDelegate : UNUserNotificationCenterDelegate {
// 푸시메세지가 앱이 켜져 있을때 나올때
// completionHandler로 "UNNotificationPresentationOptions"를 반환함
// 사용자가 머무르고 있는 화면에 따라 포그라운드 상태에서의 푸시를 보여줄지 아닐지에 대한 분기처리가 가능(ex.카톡채팅방에서 푸시를 띄우지 않는 등)
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
print("willPresent: userInfo: ", userInfo)
completionHandler([.banner, .sound, .badge])
// Notification 분기처리
if userInfo[AnyHashable("Check Your Commit")] as? String == "project" {
print("CYC project")
}else {
print("NOTHING")
}
}
// 푸시메세지를 받았을 때
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
print("didReceive: userInfo: ", userInfo)
completionHandler()
}
}
위 코드로 토큰을 받아 수동으로 Firebase messiging 서버에 직접 등록하고 앱에 알림을 받는데에 성공했다.하지만 문제는 다수 유저의 토큰을 어떻게 받아서 메시징 서버에 올려주느냐였다. 서버없이 FCM만 사용하여 다음 두 조건을 동시에 만족하는 유저에게만 알림을 줄 수 있는 방법을 생각하여야 했다.
- 사용자가 일정 시간에 커밋하였는가
- 사용자가 알림 설정 토글을 on 하였는가
사용자의 정보를 서버가 저장하고 있어야 위 두 조건을 만족하는 기능을 구현할 수 있다고 결론을 내렸고, 이번 개발 기간에는 사용자가 알림 설정 토글을 on 하였을 때
7시 이후 매 시간마다 알림을 주는 기능만을 구현하기로 하였다. 이 기능을 구현하는데에 FCM을 굳이 사용하지 않고 내부 라이브러리인 userNotifications 을 사용하였다.
AppDelegate.swift
import SwiftUI
import UserNotifications
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// 앱 실행 시 사용자에게 알림 허용 권한을 받음
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] // 필요한 알림 권한을 설정
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { _, _ in }
)
return true
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
// Foreground(앱 켜진 상태)에서도 알림 오는 설정
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.list, .banner])
}
}
앱델리게이트에서 알림권한을 설정해주었다.
NotificationHelper.swift
import Foundation
import UIKit
import UserNotifications
//
// - Note: 싱글턴으로 구현 `LocalNotificationHelper.shared`를 통해 접근
class LocalNotificationHelper {
static let shared = LocalNotificationHelper()
private init() {}
///Push Notification에 대한 인증 설정 함수
func setAuthorization() {
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] // 필요한 알림 권한을 설정
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { _, _ in }
)
}
// 하루를 주기로 특정 시간에 Notification을 보내는 코드
func pushScheduledNotification(title: String, body: String, hour: Int, identifier: String) {
assert(hour >= 0 || hour <= 24, "시간은 0이상 24이하로 입력해주세요.")
let notificationContent = UNMutableNotificationContent()
notificationContent.title = title
notificationContent.body = body
var dateComponents = DateComponents()
dateComponents.hour = hour // 알림을 보낼 시간 (24시간 형식)
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(identifier: identifier,
content: notificationContent,
trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Notification Error: ", error)
}
}
}
/// 대기중인 Push Notification을 출력
func printPendingNotification() {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
for request in requests {
print("Identifier: \(request.identifier)")
print("Title: \(request.content.title)")
print("Body: \(request.content.body)")
print("Trigger: \(String(describing: request.trigger))")
print("---")
}
}
}
//알림 전체삭제
func removeAllNotifications() {
UNUserNotificationCenter
.current().removeAllDeliveredNotifications()
UNUserNotificationCenter
.current().removeAllPendingNotificationRequests()
}
}
NotificationHelper 클래스에서 알림에 필요한 함수를 구현하였다.
NotificationView
class NotificationSettings: ObservableObject {
@Published var isOnNotification: Bool {
didSet {
UserDefaults.standard.set(isOnNotification, forKey: "isOnNotification")
}
}
init() {
self.isOnNotification = UserDefaults.standard.bool(forKey: "isOnNotification")
}
}
.
.
VStack(alignment: .leading) {
Toggle(isOn: $isOnNotification, label: {
// MARK: - 알림 설정 토글
Text("알림 설정")
.font(.pretendardBold_25)
}).onChange(of: isOnNotification, initial: false, techNotification)
.
.
func techNotification() {
if isOnNotification {
LocalNotificationHelper.shared.printPendingNotification()
LocalNotificationHelper
.shared
.pushScheduledNotification(title: "Check Your Commit",
body: "커밋해줘여..🫶",
hour: 18,
identifier: "SCHEDULED_NOTI18")
} else if {
LocalNotificationHelper.shared.removeAllNotifications()
}
}
.
.
알림 설정뷰에서 토글값이 on일 때 알림이 알림센터에 올라가도록 구현하고, off 시엔 알림센터의 알림을 모두 삭제하도록 구현하였다.
OnTapGesture사용
- TodoList 사용 시 빈 화면 터치 했을때, 텍스트필드를 생성하려했지만 리스트 스와이프 삭제 할 때도 텍스트필드가 생성됨.
@State var isTextFieldShown = false
.onTapGesture {
if !isTextFieldShown {
isTextFieldShown.toggle()
}
}
- TodoList 사용 시 텍스트필드에 텍스트를 입력하고 빈 화면을 터치하면 텍스트 저장을 구현하려 했지만, 리스트 스와이프 삭제 할 때도 함수가 작동.
func addTodo() {
withAnimation {
let newTodo = TodoModel(title: textFieldText)
if !newTodo.title.isEmpty {
modelContext.insert(newTodo)
isTextFieldShown.toggle()
}
}
}
.onTapGesture {
withAnimation{
addTodo() // 텍스트 추가 함수
textFieldText = "" // 추가 후 텍스트필드 비워주기
}
}
DispatchQueue.main.async로 UIView 속도 향상
// 준비되면 바로 연속일수 뿌리기, 공룡 움직이기 -> MainView에서 바로 처리
DispatchQueue.main.async {
if self.dataToDictionary(validCommits){
self.commitDay = self.findConsecutiveDates(withData: self.testCase)
ModalView().moveDinosaur() // 프로그래스바의 공룡이 움직이는 함수
}
}
- onAppear에 UIView의 업데이트 함수를 넣었지만, 커밋 연속 일수와 프로그래스바가 다른 뷰에 들어갔다가 나와야지만 제대로 나오는 문제가 있었음
- Alamofire로 api 요청 함수는 자동으로 비동기 처리되므로 main thread에서 데이터를 가져오지 않았고
- UIView가 onAppear되는 시점과 데이터가 들어오는 시점 차이가 생기면서 다른 뷰에 들어갔다가 UIview를 다시 표시할때 제대로 생기는 것이 발견됨
- UIView를 업데이트하는 데이터 함수는 DispatchQueue.main.async로 빼서 사용해주고 await 사용이 미숙해 if문으로 데이터가 들어왔는지 판별함
SwiftUI
Xcode 15.1
iOS 17.1
Language - Swift 5.5.3
알람 - UserNotification
API - Alamofire
Todo - SwiftData
GrassView - SwiftSoup
📦CYC
┣ 📂 Main
┃ ┗ 📜 MainView.swift
┣ 📂 Login
┃ ┃ ┣ 📂 extension
┃ ┃ ┗ 📜 extensionOfUserDefaults.ttf
┃ ┣ 📜 OnboardingTabView.swift
┃ ┣ 📜 LoginView.swift
┃ ┗ 📜 LoginModel.swift
┃ ┃ ┣ 📂 Font
┣ 📂 Setting
┃ ┣ 📂 PersonProfile
┃ ┃ ┣ 📂 View
┃ ┃ ┃ ┣ 📜 PersonGridView.swift
┃ ┃ ┃ ┗ 📜 AboutCYC.swift
┃ ┃ ┣ 📂 Model
┃ ┃ ┃ ┗ 📜 PersonModel.swift
┃ ┣ 📂 ViewModel
┃ ┃ ┣ 📜 LicenseViewModel.swift
┃ ┃ ┗ 📜 SettingViewModel.swift
┃ ┣ 📂 View
┃ ┃ ┣ 📜 LicenseView.swift
┃ ┃ ┣ 📜 NotificationView.swift
┃ ┃ ┗ 📜 SettingView.swift
┃ ┣ 📂 Model
┃ ┃ ┣ 📜 LicenseModel.swift
┃ ┃ ┗ 📜 SettingModel.swift
┣ 📂 Grass
┃ ┣ 📂 View
┃ ┃ ┗ 📜 CommitView.swift
┣ 📂 Todo
┃ ┣ 📂 View
┃ ┃ ┣ 📜 TodoView.swift
┃ ┃ ┗ 📜 TodoPreView.swift
┃ ┣ 📂 Model
┃ ┃ ┗ 📜 TodoModel.swift
┣ 📂 Progress
┃ ┣ 📂 View
┃ ┃ ┣ 📜 ProgressView.swift
┃ ┃ ┣ 📜 ModalView.swift
┃ ┃ ┣ 📜 ProgressBarView.swift
┃ ┃ ┣ 📜 DdayButtonView.swift
┃ ┃ ┗ 📜 ProgressTextView.swift
┣ 📂 Helper
┃ ┣ 📂 NotificationHelper
┃ ┃ ┗ 📜 LocalNotificationHelper.swift
┃ ┣ 📂 DarkLightMode
┃ ┃ ┣ 📜 DLMode.swift
┃ ┃ ┗ 📜 UIButton.swift
┃ ┣ 📂 Extensions
┃ ┃ ┣ 📜 fontExtension.swift
┃ ┃ ┣ 📜 CustomSpacing.swift
┃ ┃ ┣ 📜 colorExtension.swift
┃ ┃ ┗ 📜 DismissGesture.swift
┃ ┣ 📂 Fonts
┃ ┃ ┣ 📜 Pretendard-Black.otf
┃ ┃ ┣ 📜 Pretendard-Bold.otf
┃ ┃ ┣ 📜 Pretendard-ExtraBold.otf
┃ ┃ ┣ 📜 Pretendard-ExtraLight.otf
┃ ┃ ┣ 📜 Pretendard-Light.otf
┃ ┃ ┣ 📜 Pretendard-Medium.otf
┃ ┃ ┣ 📜 Pretendard-Regular.otf
┃ ┃ ┣ 📜 Pretendard-SemiBold.otf
┃ ┃ ┗ 📜 Pretendard-Thin.otf.swift
┣ 📜 CYCAPP.swift
┣ 📜 AppDelegate.swift
┣ 📜 StartView.swift
┗ 🖼️ Assets