캘린더 라이브러리 선택
Flutter에서 캘린더를 직접 구현할 수도 있지만, 바퀴를 재발명할 필요는 없다. Claude와 함께 라이브러리를 조사했다:
- table_calendar: 가장 인기 있고 커스터마이징이 자유로움
- flutter_calendar_carousel: 심플하지만 업데이트가 느림
- syncfusion_flutter_calendar: 기능은 많지만 무겁다
선택: table_calendar - 활발한 유지보수와 좋은 문서화
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
table_calendar: ^3.0.9
sqflite: ^2.3.0
intl: ^0.18.1
provider: ^6.1.1
로컬 데이터베이스 설계
백엔드 개발 경험이 여기서 빛을 발했다. 테이블 설계는 익숙한 영역:
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_all_day INTEGER DEFAULT 0,
color INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Flutter에서는 sqflite 패키지로 구현:
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('moagal.db');
return _database!;
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
);
}
Future _createDB(Database db, int version) async {
await db.execute('''
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_all_day INTEGER DEFAULT 0,
color INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''');
}
}
일정 CRUD 구현
모델 정의
class Event {
final int? id;
final String title;
final String? description;
final DateTime startTime;
final DateTime endTime;
final bool isAllDay;
final int color;
Event({
this.id,
required this.title,
this.description,
required this.startTime,
required this.endTime,
this.isAllDay = false,
this.color = 0xFF2196F3,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'description': description,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'is_all_day': isAllDay ? 1 : 0,
'color': color,
};
}
factory Event.fromMap(Map<String, dynamic> map) {
return Event(
id: map['id'],
title: map['title'],
description: map['description'],
startTime: DateTime.parse(map['start_time']),
endTime: DateTime.parse(map['end_time']),
isAllDay: map['is_all_day'] == 1,
color: map['color'],
);
}
}
데이터베이스 연산
class EventService {
Future<int> createEvent(Event event) async {
final db = await DatabaseHelper.instance.database;
return await db.insert('events', event.toMap());
}
Future<List<Event>> getEventsByDate(DateTime date) async {
final db = await DatabaseHelper.instance.database;
final startOfDay = DateTime(date.year, date.month, date.day);
final endOfDay = startOfDay.add(Duration(days: 1));
final results = await db.query(
'events',
where: 'start_time >= ? AND start_time < ?',
whereArgs: [startOfDay.toIso8601String(), endOfDay.toIso8601String()],
orderBy: 'start_time ASC',
);
return results.map((map) => Event.fromMap(map)).toList();
}
Future<int> updateEvent(Event event) async {
final db = await DatabaseHelper.instance.database;
return await db.update(
'events',
event.toMap(),
where: 'id = ?',
whereArgs: [event.id],
);
}
Future<int> deleteEvent(int id) async {
final db = await DatabaseHelper.instance.database;
return await db.delete('events', where: 'id = ?', whereArgs: [id]);
}
}
캘린더 UI 구현
class CalendarScreen extends StatefulWidget {
@override
_CalendarScreenState createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
DateTime _selectedDay = DateTime.now();
DateTime _focusedDay = DateTime.now();
Map<DateTime, List<Event>> _events = {};
@override
void initState() {
super.initState();
_loadEvents();
}
Future<void> _loadEvents() async {
// 한 달치 이벤트 로드
final events = await EventService().getEventsForMonth(_focusedDay);
setState(() {
_events = events;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('모아갤')),
body: Column(
children: [
TableCalendar(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
eventLoader: (day) => _events[day] ?? [],
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
),
Expanded(
child: _buildEventList(),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddEventDialog(),
child: Icon(Icons.add),
),
);
}
Widget _buildEventList() {
final events = _events[_selectedDay] ?? [];
if (events.isEmpty) {
return Center(child: Text('일정이 없습니다'));
}
return ListView.builder(
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Color(event.color),
),
title: Text(event.title),
subtitle: Text(
'${DateFormat('HH:mm').format(event.startTime)} - '
'${DateFormat('HH:mm').format(event.endTime)}'
),
onTap: () => _showEventDetails(event),
);
},
);
}
}
Claude와의 협업 포인트
이 과정에서 Claude가 특히 도움이 됐던 부분:
- 코드 구조 제안: “이렇게 분리하는 게 어때?” 하는 아키텍처 제안
- 에러 디버깅:
Unhandled Exception발생 시 원인 분석 - 코드 리팩토링: 중복 코드를 함수로 추출
하지만 모든 걸 Claude에게 맡기지는 않았다. DB 설계나 비즈니스 로직은 내가 주도하고, Claude는 Flutter 문법이나 위젯 사용법을 보완했다.
다음 단계
MVP의 핵심 기능은 완성했다. 이제:
- 위젯 구현 (안드로이드 홈 화면)
- Google Calendar 연동
- 앱 아이콘 및 스플래시 화면
다음 편에서는 앱을 실제 기기에 배포하고 테스트하는 과정을 다룬다.
“코드가 실행되는 순간의 희열. 개발자라서 행복하다.”
시리즈
- 모아갤 프로젝트를 시작하며
- 기술 스택 선정과 초기 계획
- 개발 환경 구축과 첫 번째 난관
- 핵심 기능 구현하기 (현재 글)
- 테스트와 배포 준비