feat(app): 홈 화면 일정 API를 새 응답 형식에 맞게 업데이트

- Schedule 모델: category/source 중첩 객체, members 배열 파싱
- 일정 서비스: { schedules: [] } 래핑된 응답 처리
- 홈/웹 일정 카드: sourceName을 별도 줄로 분리하여 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-27 18:15:19 +09:00
parent 9d18449d3a
commit 0ddde32bed
4 changed files with 56 additions and 38 deletions

View file

@ -116,52 +116,54 @@ class Schedule {
final String title;
final String date;
final String? time;
final String? endDate;
final String? endTime;
final String? description;
final int? categoryId;
final String? categoryName;
final String? categoryColor;
final String? memberNames;
final String? sourceUrl;
final List<String> members;
final String? sourceName;
final String? sourceUrl;
Schedule({
required this.id,
required this.title,
required this.date,
this.time,
this.endDate,
this.endTime,
this.description,
this.categoryId,
this.categoryName,
this.categoryColor,
this.memberNames,
this.sourceUrl,
this.members = const [],
this.sourceName,
this.sourceUrl,
});
factory Schedule.fromJson(Map<String, dynamic> json) {
// category
final category = json['category'] as Map<String, dynamic>?;
// members
final membersList = (json['members'] as List<dynamic>?)
?.map((m) => m is String ? m : m.toString())
.toList() ?? [];
// source
final source = json['source'] as Map<String, dynamic>?;
return Schedule(
id: json['id'] as int,
id: json['id'] is String ? int.parse(json['id']) : json['id'] as int,
title: json['title'] as String,
date: json['date'] as String,
time: json['time'] as String?,
endDate: json['end_date'] as String?,
endTime: json['end_time'] as String?,
description: json['description'] as String?,
categoryName: json['category_name'] as String?,
categoryColor: json['category_color'] as String?,
memberNames: json['member_names'] as String?,
sourceUrl: json['source_url'] as String?,
sourceName: json['source_name'] as String?,
categoryId: category?['id'] as int?,
categoryName: category?['name'] as String?,
categoryColor: category?['color'] as String?,
members: membersList,
sourceName: source?['name'] as String?,
sourceUrl: source?['url'] as String?,
);
}
///
List<String> get memberList {
if (memberNames == null || memberNames!.isEmpty) return [];
return memberNames!.split(',').map((n) => n.trim()).where((n) => n.isNotEmpty).toList();
}
List<String> get memberList => members;
/// (HH:mm)
String? get formattedTime {

View file

@ -17,8 +17,9 @@ Future<List<Schedule>> getSchedules(int year, int month) async {
'year': year.toString(),
'month': month.toString(),
});
final List<dynamic> data = response.data;
return data.map((json) => Schedule.fromJson(json)).toList();
final Map<String, dynamic> data = response.data;
final List<dynamic> schedulesJson = data['schedules'] ?? [];
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
}
/// N개 ( ) -
@ -28,8 +29,9 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
'startDate': todayStr,
'limit': limit.toString(),
});
final List<dynamic> data = response.data;
return data.map((json) => Schedule.fromJson(json)).toList();
final Map<String, dynamic> data = response.data;
final List<dynamic> schedulesJson = data['schedules'] ?? [];
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
}
///

View file

@ -637,13 +637,11 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// mt-2(8px) text-xs(12px) text-gray-400
// mt-2(8px) text-xs(12px) text-gray-400 - +
const SizedBox(height: 8),
Row(
children: [
// gap-3(12px)
if (schedule.formattedTime != null) ...[
// gap-1(4px)
_buildIcon('clock', 12, Colors.grey[400]!),
const SizedBox(width: 4),
Text(
@ -662,6 +660,20 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
],
],
),
// ( )
if (schedule.sourceName != null && schedule.sourceName!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
_buildIcon('link', 12, Colors.grey[400]!),
const SizedBox(width: 4),
Text(
schedule.sourceName!,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
],
),
],
// mt-2(8px)
if (memberList.isNotEmpty) ...[
const SizedBox(height: 8),
@ -704,6 +716,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
const icons = {
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
};

View file

@ -58,8 +58,8 @@ const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className =
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
{decodeHtmlEntities(schedule.title)}
</p>
{/* 시간 + 카테고리 + 소스 */}
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
{/* 시간 + 카테고리 */}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={12} />
@ -72,13 +72,14 @@ const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className =
{categoryInfo.name}
</span>
)}
</div>
{/* 소스 */}
{sourceName && (
<span className="flex items-center gap-1">
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
<Link2 size={12} />
{sourceName}
</span>
)}
</div>
)}
{/* 멤버 */}
{displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">