Task Manager
개인 프로젝트로 개발한 주단위 테스크 관리 앱을 소개합니다.
이 앱은 PC와 모바일에서 모두 사용할 수 있으며 , 주 단위로 일정을 관리하고 실시간으로 데이터를 동기화할 수 있는 기능을 갖추고 있습니다.
1. 프로젝트 개요
이번 프로젝트의 목표는 단순하지만 효율적인 개인 일정 관리 도구를 만드는 것이었습니다.
매주 해야 할 일을 추가하고 , 색깔별로 실패 , 완료 , 진행중인 테스크를 하며 , 필요에 따라 시간과 날짜를 자유롭게 수정할 수 있습니다.
1-1. PC
1-2. 모바일
2. 주요 기능 소개
2-1. 기본 기능
앱의 핵심 기능은 테스크 관리입니다.
-
테스크 추가: 새로운 일을 손쉽게 등록
-
테스크 삭제: 필요 없는 항목 제거
-
테스크 완료: 완료된 일을 체크하여 관리
-
시간과 날짜 수정: 계획 변경에 유연하게 대응
2-2. 실시간 데이터 연동
이 앱은 Firebase를 활용하여 PC와 모바일 간 데이터를 실시간으로 동기화합니다.
즉 , 한 기기에서 테스크를 추가하거나 수정하면 , 다른 기기에서도 즉시 반영됩니다.
2-3. 재사용 가능한 UI 컴포넌트
개발 과정에서 반복되는 기능을 재사용 가능한 컴포넌트로 제작했습니다.
-
CalendarView: 날짜를 선택할 수 있는 달력 컴포넌트
-
Time Selector: 시간을 선택할 수 있는 컴포넌트
이 컴포넌트들은 다른 프로젝트에서도 재사용할 수 있도록 설계했습니다.
3. 소스 코드
3-1. CalendarView.cs
using System;using System.Collections.Generic;using PB.ATTRIBUTE;using PB.BUTTON;using TMPro;using UnityEngine;
namespace PB.UI{ public class PCalendarView : MonoBehaviour { [SerializeField] List<PCalendarDate> dates = new(); [SerializeField] TextMeshProUGUI tmpDisplayDate; [SerializeField] TextMeshProUGUI tmpSelectedDate; [SerializeField] PButton btnPrevMonth; [SerializeField] PButton btnNextMonth;
[PAttribute_ShowOnly] public bool Interactable = true;
// Current Selected public (int year, int month, int day) SelectedDate => selectedDate; private (int year, int month, int day) selectedDate; public int SelectedYear => selectedDate.year; public int SelectedMonth => selectedDate.month; public int SelectedDay => selectedDate.day;
// Current Displayed private (int year, int month) displayDate; private int displayYear { get => displayDate.year; set => displayDate.year = value; } private int displayMonth { get => displayDate.month; set => displayDate.month = value; }
private void Awake() { btnPrevMonth.onClick.AddListener(OnPrevMonth); btnNextMonth.onClick.AddListener(OnNextMonth); }
public void Init(int year, int month, int day) { displayDate = (year, month); selectedDate = (year, month, day); SetupCalendar(year, month); }
private void SetupCalendar(int year, int month) { var firstDayOfMonth = new DateTime(year, month, 1); var dayOfWeek = (int)firstDayOfMonth.DayOfWeek; var startIndex = dayOfWeek == 0 ? 6 : dayOfWeek - 1; var daysInMonth = DateTime.DaysInMonth(year, month); var totalDates = dates.Count; var dateIndex = 0;
for (; dateIndex < startIndex && dateIndex < totalDates; dateIndex++) { dates[dateIndex].SetEmpty(); }
for (int day = 1; day <= daysInMonth && dateIndex < totalDates; day++, dateIndex++) { var date = (year, month, day); dates[dateIndex].Init(date, OnDateSelected); }
var sameYear = selectedDate.year == year; var sameMonth = selectedDate.month == month;
if (sameYear && sameMonth) { dates.Find(d => d.Date == selectedDate)?.SetSelected(true); }
for (; dateIndex < totalDates; dateIndex++) { dates[dateIndex].SetEmpty(); }
tmpDisplayDate.text = $"{year}년 {month:D2}월"; tmpSelectedDate.text = $"{selectedDate.year} / {selectedDate.month:D2} / {selectedDate.day:D2}"; }
private void OnDateSelected((int year, int month, int day) date) { if (selectedDate == date) return; if (Interactable == false) return;
dates.Find(d => d.Date == selectedDate)?.SetSelected(false); dates.Find(d => d.Date == date)?.SetSelected(true);
selectedDate = date; tmpSelectedDate.text = $"{selectedDate.year} / {selectedDate.month:D2} / {selectedDate.day:D2}"; }
public void OnPrevMonth() { if (Interactable == false) return;
if (displayMonth == 1) { displayYear--; displayMonth = 12; } else displayMonth--;
SetupCalendar(displayYear, displayMonth); }
public void OnNextMonth() { if (Interactable == false) return; if (displayMonth == 12) { displayYear++; displayMonth = 1; } else displayMonth++;
SetupCalendar(displayYear, displayMonth); } }}
using System;
using System.Collections.Generic;
using PB.ATTRIBUTE;
using PB.BUTTON;
using TMPro;
using UnityEngine;
namespace PB.UI
{
public class PCalendarView : MonoBehaviour
{
[SerializeField] List<PCalendarDate> dates = new();
[SerializeField] TextMeshProUGUI tmpDisplayDate;
[SerializeField] TextMeshProUGUI tmpSelectedDate;
[SerializeField] PButton btnPrevMonth;
[SerializeField] PButton btnNextMonth;
[PAttribute_ShowOnly] public bool Interactable = true;
// Current Selected
public (int year, int month, int day) SelectedDate => selectedDate;
private (int year, int month, int day) selectedDate;
public int SelectedYear => selectedDate.year;
public int SelectedMonth => selectedDate.month;
public int SelectedDay => selectedDate.day;
// Current Displayed
private (int year, int month) displayDate;
private int displayYear { get => displayDate.year; set => displayDate.year = value; }
private int displayMonth { get => displayDate.month; set => displayDate.month = value; }
private void Awake()
{
btnPrevMonth.onClick.AddListener(OnPrevMonth);
btnNextMonth.onClick.AddListener(OnNextMonth);
}
public void Init(int year, int month, int day)
{
displayDate = (year, month);
selectedDate = (year, month, day);
SetupCalendar(year, month);
}
private void SetupCalendar(int year, int month)
{
var firstDayOfMonth = new DateTime(year, month, 1);
var dayOfWeek = (int)firstDayOfMonth.DayOfWeek;
var startIndex = dayOfWeek == 0 ? 6 : dayOfWeek - 1;
var daysInMonth = DateTime.DaysInMonth(year, month);
var totalDates = dates.Count;
var dateIndex = 0;
for (; dateIndex < startIndex && dateIndex < totalDates; dateIndex++)
{
dates[dateIndex].SetEmpty();
}
for (int day = 1; day <= daysInMonth && dateIndex < totalDates; day++, dateIndex++)
{
var date = (year, month, day);
dates[dateIndex].Init(date, OnDateSelected);
}
var sameYear = selectedDate.year == year;
var sameMonth = selectedDate.month == month;
if (sameYear && sameMonth)
{
dates.Find(d => d.Date == selectedDate)?.SetSelected(true);
}
for (; dateIndex < totalDates; dateIndex++)
{
dates[dateIndex].SetEmpty();
}
tmpDisplayDate.text = $"{year}년 {month:D2}월";
tmpSelectedDate.text = $"{selectedDate.year} / {selectedDate.month:D2} / {selectedDate.day:D2}";
}
private void OnDateSelected((int year, int month, int day) date)
{
if (selectedDate == date) return;
if (Interactable == false) return;
dates.Find(d => d.Date == selectedDate)?.SetSelected(false);
dates.Find(d => d.Date == date)?.SetSelected(true);
selectedDate = date;
tmpSelectedDate.text = $"{selectedDate.year} / {selectedDate.month:D2} / {selectedDate.day:D2}";
}
public void OnPrevMonth()
{
if (Interactable == false) return;
if (displayMonth == 1)
{
displayYear--;
displayMonth = 12;
}
else displayMonth--;
SetupCalendar(displayYear, displayMonth);
}
public void OnNextMonth()
{
if (Interactable == false) return;
if (displayMonth == 12)
{
displayYear++;
displayMonth = 1;
}
else displayMonth++;
SetupCalendar(displayYear, displayMonth);
}
}
}
3-2. TimeSelector.cs
using PB.ATTRIBUTE;using PB.BUTTON;using TMPro;using UnityEngine;
namespace PB.UI{ public class PTimeSelector : MonoBehaviour { [PAttribute_Header("Button")] [SerializeField] PPressButton increaseHour; [SerializeField] PPressButton decreaseHour; [SerializeField] PPressButton increaseMinute; [SerializeField] PPressButton decreaseMinute;
[PAttribute_Header("Text")] [SerializeField] TextMeshProUGUI tmpHour; [SerializeField] TextMeshProUGUI tmpMinute;
[PAttribute_ShowOnly] public bool Interactable = true;
public (int hour, int minute) SelectedTime => (selectedHour, selectedMinute); public int SelectedHour => selectedHour; public int SelectedMinute => selectedMinute; private int selectedHour; private int selectedMinute;
private const int MaxHours = 24; private const int MaxMinutes = 60;
private void Awake() { increaseHour.onPress.AddListener(OnIncreaseHour); decreaseHour.onPress.AddListener(OnDecreaseHour); increaseMinute.onPress.AddListener(OnIncreaseMinute); decreaseMinute.onPress.AddListener(OnDecreaseMinute);
increaseHour.onClick.AddListener(OnIncreaseHour); decreaseHour.onClick.AddListener(OnDecreaseHour); increaseMinute.onClick.AddListener(OnIncreaseMinute); decreaseMinute.onClick.AddListener(OnDecreaseMinute); }
public void Init(int hour, int minute) { selectedHour = Mathf.Clamp(hour, 0, MaxHours - 1); selectedMinute = Mathf.Clamp(minute, 0, MaxMinutes - 1); UpdateHourText(); UpdateMinuteText(); }
private void OnIncreaseHour() { if (Interactable == false) return; selectedHour = (selectedHour + 1) % MaxHours; UpdateHourText(); }
private void OnDecreaseHour() { if (Interactable == false) return; selectedHour = (selectedHour - 1 + MaxHours) % MaxHours; UpdateHourText(); }
private void OnIncreaseMinute() { if (Interactable == false) return; selectedMinute = (selectedMinute + 1) % MaxMinutes; UpdateMinuteText(); }
private void OnDecreaseMinute() { if (Interactable == false) return; selectedMinute = (selectedMinute - 1 + MaxMinutes) % MaxMinutes; UpdateMinuteText(); }
private void UpdateHourText() { tmpHour.text = selectedHour.ToString("D2"); }
private void UpdateMinuteText() { tmpMinute.text = selectedMinute.ToString("D2"); } }}
using PB.ATTRIBUTE;
using PB.BUTTON;
using TMPro;
using UnityEngine;
namespace PB.UI
{
public class PTimeSelector : MonoBehaviour
{
[PAttribute_Header("Button")]
[SerializeField] PPressButton increaseHour;
[SerializeField] PPressButton decreaseHour;
[SerializeField] PPressButton increaseMinute;
[SerializeField] PPressButton decreaseMinute;
[PAttribute_Header("Text")]
[SerializeField] TextMeshProUGUI tmpHour;
[SerializeField] TextMeshProUGUI tmpMinute;
[PAttribute_ShowOnly] public bool Interactable = true;
public (int hour, int minute) SelectedTime => (selectedHour, selectedMinute);
public int SelectedHour => selectedHour;
public int SelectedMinute => selectedMinute;
private int selectedHour;
private int selectedMinute;
private const int MaxHours = 24;
private const int MaxMinutes = 60;
private void Awake()
{
increaseHour.onPress.AddListener(OnIncreaseHour);
decreaseHour.onPress.AddListener(OnDecreaseHour);
increaseMinute.onPress.AddListener(OnIncreaseMinute);
decreaseMinute.onPress.AddListener(OnDecreaseMinute);
increaseHour.onClick.AddListener(OnIncreaseHour);
decreaseHour.onClick.AddListener(OnDecreaseHour);
increaseMinute.onClick.AddListener(OnIncreaseMinute);
decreaseMinute.onClick.AddListener(OnDecreaseMinute);
}
public void Init(int hour, int minute)
{
selectedHour = Mathf.Clamp(hour, 0, MaxHours - 1);
selectedMinute = Mathf.Clamp(minute, 0, MaxMinutes - 1);
UpdateHourText();
UpdateMinuteText();
}
private void OnIncreaseHour()
{
if (Interactable == false) return;
selectedHour = (selectedHour + 1) % MaxHours;
UpdateHourText();
}
private void OnDecreaseHour()
{
if (Interactable == false) return;
selectedHour = (selectedHour - 1 + MaxHours) % MaxHours;
UpdateHourText();
}
private void OnIncreaseMinute()
{
if (Interactable == false) return;
selectedMinute = (selectedMinute + 1) % MaxMinutes;
UpdateMinuteText();
}
private void OnDecreaseMinute()
{
if (Interactable == false) return;
selectedMinute = (selectedMinute - 1 + MaxMinutes) % MaxMinutes;
UpdateMinuteText();
}
private void UpdateHourText()
{
tmpHour.text = selectedHour.ToString("D2");
}
private void UpdateMinuteText()
{
tmpMinute.text = selectedMinute.ToString("D2");
}
}
}