diff --git a/lib/common/widgets/course_card.dart b/lib/common/widgets/course_card.dart new file mode 100644 index 0000000..d69f4bc --- /dev/null +++ b/lib/common/widgets/course_card.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:csp2/course.dart'; +class CourseCard extends StatelessWidget { + final Course course; + + const CourseCard({super.key, required this.course}); + + @override + Widget build(BuildContext context) { + return Container( + width: 150, // 각 항목의 너비 + margin: const EdgeInsets.only(right: 12.0), + padding: const EdgeInsets.all(8.0), // 내부 패딩 추가 + decoration: BoxDecoration( + color: Theme.of(context).cardColor, // 카드 배경색 + borderRadius: BorderRadius.circular(12.0), // 둥근 모서리 + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), // 그림자 색상 + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 2), // 그림자 위치 + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + + child: Image.network( + course.affiliateOrgIcon, + width: double.infinity, // 가로 사이즈에 비례하여 채우도록 변경 + height: 100, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 8.0), + Text( + course.affiliateName, + style: const TextStyle(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + course.affiliateDescription, + style: const TextStyle(color: Colors.grey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/common/widgets/upcoming_class_card.dart b/lib/common/widgets/upcoming_class_card.dart index 753a116..1ec521c 100644 --- a/lib/common/widgets/upcoming_class_card.dart +++ b/lib/common/widgets/upcoming_class_card.dart @@ -14,39 +14,18 @@ class UpcomingClassCard extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 300, // 각 항목의 너비를 지정합니다. + width: MediaQuery.of(context).size.width / 2, // 화면 너비의 절반으로 설정 child: InkWell( onTap: onTap, child: Card( margin: const EdgeInsets.only(right: 12.0), // 항목 간의 간격 조정 elevation: 2.0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5.0)), child: Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(5.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - plan.planTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 2, - ), - ), - const SizedBox(width: 8.0), - Text( - // plan.planTeacher, - "", - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), - ), - ], - ), - const SizedBox(height: 8.0), if (plan.thumbnail.isNotEmpty) Expanded( child: ClipRRect( @@ -54,7 +33,7 @@ class UpcomingClassCard extends StatelessWidget { child: Image.network( plan.thumbnail, width: double.infinity, - fit: BoxFit.cover, + fit: BoxFit.contain, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { if (loadingProgress == null) return child; return Center( @@ -74,10 +53,34 @@ class UpcomingClassCard extends StatelessWidget { else Expanded( child: Container( - color: Colors.grey[200], - child: const Center(child: Text('No Image', style: TextStyle(color: Colors.grey))) + color: Colors.grey[200], + child: const Center(child: Text('No Image', style: TextStyle(color: Colors.grey))) ), ), + // const SizedBox(height: 5.0), + SizedBox( + height: 70, // 텍스트 영역의 고정 높이 설정 (두 줄 텍스트를 위한 충분한 공간) + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 48.0, // plan.planTitle이 항상 두 줄 공간을 차지하도록 고정 + child: Text( + plan.planTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + const SizedBox(height: 4.0), + Text( + plan.planTeacher, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + maxLines: 1, + ), + ], + ), + ), ], ), ), diff --git a/lib/course.dart b/lib/course.dart new file mode 100644 index 0000000..1a3d3be --- /dev/null +++ b/lib/course.dart @@ -0,0 +1,32 @@ +class Course { + final String affiliateNumber; + final String affiliateName; + final String affiliateType; + final String affiliateResult; + final String affiliateDescription; + final String affiliateOrg; + final String affiliateOrgIcon; + + Course({ + required this.affiliateNumber, + required this.affiliateName, + required this.affiliateType, + required this.affiliateResult, + required this.affiliateDescription, + required this.affiliateOrg, + required this.affiliateOrgIcon, + }); + + factory Course.fromJson(Map json) { + return Course( + affiliateNumber: json['affliate_number'] as String, + affiliateName: json['affliate_name'] as String, + affiliateType: json['affliate_type'] as String, + affiliateResult: json['affliate_result'] as String, + affiliateDescription: json['affliate_discription'] as String, + affiliateOrg: json['affliate_org'] as String, + affiliateOrgIcon: json['affliate_org_icon'] as String, + ); + } +} + diff --git a/lib/home_page.dart b/lib/home_page.dart index 59033a6..1bb9874 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'dart:async'; // Timer를 사용하기 위해 추가 import 'package:intl/intl.dart'; import 'package:csp2/common/widgets/upcoming_class_card.dart'; +import 'package:csp2/common/widgets/course_card.dart'; +import 'package:csp2/course.dart'; // Course 클래스 import // import 'package:flutter_native_timezone/flutter_native_timezone.dart'; // 주석 처리된 상태 유지 import '../plan_page.dart'; // PlanPage import @@ -25,7 +27,7 @@ class CaseStudyPlan { return CaseStudyPlan( planId: json['casestudy lesson id'] ?? '아이디 없음', planTitle: json['course_name'] ?? '제목 없음', - planTeacher: json['planTeacher'] ?? '선생님', + planTeacher: json['course_description'] ?? '선생님', thumbnail: json['course_thumbnail'] ?? '', ); } @@ -69,6 +71,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { late Future> _caseStudyPlans; + late Future> _newCoursesFuture; // New: Future for new courses DateTime _currentTime = DateTime.now(); String _currentTimeZone = 'Loading timezone...'; late Stream _clockStream; @@ -93,6 +96,7 @@ class _HomePageState extends State { void initState() { super.initState(); _caseStudyPlans = _fetchCaseStudyPlans(); + _newCoursesFuture = _fetchNewCourses(); // Initialize new courses future _fetchTimezone(); _clockStream = Stream.periodic(const Duration(seconds: 1), (_) { return DateTime.now(); @@ -131,6 +135,24 @@ class _HomePageState extends State { } } + // New: Fetch new courses from API + Future> _fetchNewCourses() async { + final response = await http.get(Uri.parse('https://helloworld5-ad2uqhckxq-uc.a.run.app/')); + if (response.statusCode == 200) { + final Map decodedJson = json.decode(response.body); + if (decodedJson.containsKey('data') && decodedJson['data'] is List) { + final List coursesJson = decodedJson['data']; + return coursesJson + .map((jsonItem) => Course.fromJson(jsonItem as Map)) + .toList(); + } else { + throw Exception('Invalid data format for new courses: "data" field is missing or not a list.'); + } + } else { + throw Exception('Failed to load new courses. Status Code: ${response.statusCode}'); + } + } + Future _fetchTimezone() async { try { final String timeZone = DateTime.now().timeZoneName; @@ -288,6 +310,7 @@ class _HomePageState extends State { Widget _buildHomeContent() { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( @@ -311,10 +334,10 @@ class _HomePageState extends State { ), const Padding( padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), // vertical을 위아래 다르게 조정 - child: Text('Upcoming Classes', style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)), + child: Text('Upcoming Study', style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)), ), SizedBox( - height: 250, // 가로 리스트의 높이를 지정합니다. + height: 200, // 가로 리스트의 높이를 줄입니다. child: FutureBuilder>( future: _caseStudyPlans, builder: (context, snapshot) { @@ -335,11 +358,14 @@ class _HomePageState extends State { itemCount: plans.length, itemBuilder: (context, index) { final plan = plans[index]; - return UpcomingClassCard( - plan: plan, - onTap: () { - widget.onNavigateToPlanTab(1); - }, + return Align( + alignment: Alignment.topCenter, // 항목을 상단 중앙에 정렬 + child: UpcomingClassCard( + plan: plan, + onTap: () { + widget.onNavigateToPlanTab(1); + }, + ), ); }, ); @@ -347,10 +373,45 @@ class _HomePageState extends State { }, ), ), - + // --- ▼▼▼ Find Your New Course 섹션 ▼▼▼ --- + const Padding( + padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + child: Text('Find Your New Course', style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)), + ), + SizedBox( + height: 175, // 가로 리스트의 높이 + child: FutureBuilder>( + future: _newCoursesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text('Error loading new courses: ${snapshot.error}', textAlign: TextAlign.center), + )); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No new courses available.')); + } else { + final courses = snapshot.data!; + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0), + itemCount: courses.length, + itemBuilder: (context, index) { + final course = courses[index]; + return CourseCard(course: course,); + }, + ); + } + }, + ), + ), + // --- ▲▲▲ Find Your New Course 섹션 끝 ▲▲▲ --- // --- ▼▼▼ 추천 클래스 섹션 호출 ▼▼▼ --- // _buildRecommendSection(), // --- ▲▲▲ 추천 클래스 섹션 호출 끝 ▲▲▲ --- + ], ); } @@ -369,4 +430,118 @@ class _HomePageState extends State { ) ); } -} \ No newline at end of file + Widget _buildRecommendSection() { + if (_isLoadingRecommends) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20.0), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (_hasRecommendError) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 40), + const SizedBox(height: 8), + Text( + 'Failed to load recommendations.\n$_recommendErrorText', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.redAccent), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + onPressed: _fetchRecommendPlans, + ) + ], + ), + ), + ); + } + + if (_recommendPlans.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20.0), + child: Center(child: Text('No recommendations available at the moment.')), + ); + } + + final currentPlan = _recommendPlans[_currentRecommendIndex]; + + return Container( + margin: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 16.0), // 하단 마진 추가 + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, // 카드 색상 사용 + borderRadius: BorderRadius.circular(12.0), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '✨ Recommend Classes', // 이모지 추가 + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + + const SizedBox(height: 12.0), + + Text( + currentPlan.planName, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // Test: Plan ID 표시 (디버깅용) + // Text('ID: ${currentPlan.planId}', style: TextStyle(fontSize: 10, color: Colors.grey)), + + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: currentPlan.thumbnail.isNotEmpty + ? Image.network( + currentPlan.thumbnail, + fit: BoxFit.cover, + loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Container( // 로딩 중 배경색 및 인디케이터 중앙 정렬 개선 + color: Colors.grey[200], + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { + return Container( + color: Colors.grey[200], + child: const Center(child: Icon(Icons.broken_image, color: Colors.grey, size: 50)), + ); + }, + ) + : Container( + color: Colors.grey[200], + child: const Center(child: Text('No Image Available', style: TextStyle(color: Colors.grey))), + ), + ), + )], + ), + ); + } +}