Compare commits

..

15 Commits

Author SHA1 Message Date
girinb
a926d9c7bc 플랜 프토토타입 완료 2025-07-16 00:23:54 +09:00
girinb
5f79863698 플랜 태그 변경 2025-07-15 22:23:43 +09:00
girinb
bdcfd8497d 전체적인 시스템 추가.2 2025-07-15 21:19:11 +09:00
girinb
d42fcb7102 전체적인 시스템 추가. 2025-07-15 21:18:57 +09:00
girinb
a75ba845e4 하단 무한 조닝 추가., 2025-07-15 01:45:19 +09:00
girinb
5fbabe9238 하단 광고 추가 2025-07-15 01:17:16 +09:00
girinb
deda09ff84 대학연결 2025-07-15 00:55:21 +09:00
girinb
2209ec64e3 테마 기능 분리
구글 가변 폰트 추가
업커밍 존 카드 추출
2025-07-14 21:20:10 +09:00
girinb
2e825bbae2 스크롤 초기화 기능 추가 2025-07-14 16:28:11 +09:00
girinb
05f56e91cc 릴리즈 1차 2025-07-14 15:34:37 +09:00
girinb
af89539293 임시 빌드 2025-07-11 17:34:29 +09:00
girinb
7e56c68827 일단 잡까지 되는것 저장 2025-07-11 16:46:23 +09:00
girinb
42453abe41 2025-07-11
작업 시작전 저장
2025-07-11 13:49:46 +09:00
girinb
2ad2af76e0 리스트뷰 가로로 수정 2025-07-10 18:45:20 +09:00
girinb
ae99bd661d 한번 좆되서 다시 돌림 2025-07-10 18:31:19 +09:00
74 changed files with 1939 additions and 784 deletions

View File

@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="csp2"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

BIN
assets/icon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/splash/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="1500" height="500"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@@ -45,5 +45,7 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

112
lib/career_page.dart Normal file
View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:csp2/common/widgets/job_card.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:csp2/common/data/job.dart';
class JobsPage extends StatefulWidget {
const JobsPage({super.key});
@override
State<JobsPage> createState() => _JobsPageState();
}
class _JobsPageState extends State<JobsPage> {
String? _selectedDropdownValue; // 드롭다운 선택 값 저장 변수
String _selectedJobTag = 'Hair'; // 초기 탭 선택 값
List<Job>? _jobData; // Job 모델 객체 리스트로 변경
bool _isLoading = true;
final List<String> _jobTags = ['Hair', 'Skincare', 'Bodycare', 'Service', 'IT', 'Education'];
@override
void initState() {
super.initState();
_selectedDropdownValue = 'Indonesia'; // 초기값 설정
_fetchJobData();
}
Future<void> _fetchJobData() async {
setState(() {
_isLoading = true;
});
try {
final response = await http.get(Uri.parse(
'https://helloworld4-ad2uqhckxq-uc.a.run.app/?country=$_selectedDropdownValue&jobtag=$_selectedJobTag'));
if (response.statusCode == 200) {
setState(() {
_jobData = (json.decode(response.body) as List)
.map((item) => Job.fromJson(item))
.toList();
});
} else {
_jobData = null; // Clear data on error
}
} catch (e) {
_jobData = null; // Clear data on error
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: _jobTags.length,
child: Scaffold(
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: DropdownButton<String>(
value: _selectedDropdownValue,
onChanged: (String? newValue) {
setState(() {
_selectedDropdownValue = newValue;
});
_fetchJobData(); // Fetch data when dropdown changes
},
items: <String>['Indonesia', 'Hongkong', 'Singapore', 'South Korea', 'Japan']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
),
),
TabBar(
isScrollable: true,
tabs: _jobTags.map((tag) => Tab(text: tag)).toList(),
onTap: (index) {
setState(() {
_selectedJobTag = _jobTags[index];
});
_fetchJobData();
},
),
Expanded(
child: TabBarView(
children: _jobTags.map((tag) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: _jobData == null || _jobData!.isEmpty
? Center(child: Text('No jobs found for $tag.'))
: ListView.builder(
itemCount: _jobData!.length,
itemBuilder: (context, index) {
final job = _jobData![index];
return JobCard(job: job);
},
);
}).toList(),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,23 @@
class CaseStudyPlan {
final String planId;
final String planTitle;
final String planTeacher;
final String thumbnail;
CaseStudyPlan({
required this.planId,
required this.planTitle,
required this.planTeacher,
required this.thumbnail,
});
factory CaseStudyPlan.fromJson(Map<String, dynamic> json) {
return CaseStudyPlan(
planId: json['casestudy lesson id'] ?? '아이디 없음',
planTitle: json['course_name'] ?? '제목 없음',
planTeacher: json['course_description'] ?? '',
thumbnail: json['course_thumbnail'] ?? '',
);
}
}

View File

@@ -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<String, dynamic> 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,
);
}
}

41
lib/common/data/job.dart Normal file
View File

@@ -0,0 +1,41 @@
class Job {
final String jobName;
final String jobJobtag;
final String jobDescription;
final double jobIncome;
final String jobIncomeType;
final String jobLocationCountry;
final String jobLocationCity;
final String jobRequirement;
final String jobTag;
final String jobEndpoint;
Job({
required this.jobName,
required this.jobJobtag,
required this.jobDescription,
required this.jobIncome,
required this.jobIncomeType,
required this.jobLocationCountry,
required this.jobLocationCity,
required this.jobRequirement,
required this.jobTag,
required this.jobEndpoint,
});
factory Job.fromJson(Map<String, dynamic> json) {
return Job(
jobName: json['Job_name'] as String,
jobJobtag: json['job_jobtag'] as String,
jobDescription: json['job_decriptopn'] as String,
jobIncome: double.tryParse(json['job_Income'].toString()) ?? 0.0,
jobIncomeType: json['job_income_type'] as String,
jobLocationCountry: json['job_location_country'] as String,
jobLocationCity: json['job_location_city'] as String,
jobRequirement: json['job_requirment'] as String,
jobTag: json['job_tag'] as String,
jobEndpoint: json['job_endpoint'] as String,
);
}
}

View File

@@ -0,0 +1,22 @@
class NewStudy {
final String planId;
final String planTitle;
final String planTeacher;
final String thumbnail;
NewStudy({
required this.planId,
required this.planTitle,
required this.planTeacher,
required this.thumbnail,
});
factory NewStudy.fromJson(Map<String, dynamic> json) {
return NewStudy(
planId: json['casestudy lesson id'] ?? '아이디 없음',
planTitle: json['course_name'] ?? '제목 없음',
planTeacher: json['course_description'] ?? '선생님',
thumbnail: json['course_thumbnail'] ?? '',
);
}
}

View File

@@ -0,0 +1,29 @@
class PlanDetailItem {
final String lessonId;
final String lessonTag;
final String lessonUrl;
final String thumbnail;
final String lessonName;
final String lessonDescription;
PlanDetailItem({
required this.lessonId,
required this.lessonTag,
required this.lessonUrl,
required this.thumbnail,
required this.lessonName,
required this.lessonDescription,
});
factory PlanDetailItem.fromJson(Map<String, dynamic> json) {
return PlanDetailItem(
lessonId: json['casestudy lesson id'] ?? 'ID 없음',
lessonTag: json['lesson tag'] ?? '태그 없음',
lessonUrl: json['lesson url'] ?? 'URL 없음',
thumbnail: json['thumbnail'] ?? '',
lessonName: json['lesson_name'] ?? '이름 없음',
lessonDescription: json['lesson_description'] ?? '설명 없음',
);
}
}

View File

@@ -0,0 +1,23 @@
class UpcomingStudy {
final String planId;
final String planTitle;
final String planTeacher;
final String thumbnail;
UpcomingStudy({
required this.planId,
required this.planTitle,
required this.planTeacher,
required this.thumbnail,
});
factory UpcomingStudy.fromJson(Map<String, dynamic> json) {
return UpcomingStudy(
planId: json['casestudy lesson id'] ?? '아이디 없음',
planTitle: json['course_name'] ?? '제목 없음',
planTeacher: json['course_description'] ?? '선생님',
thumbnail: json['course_thumbnail'] ?? '',
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
static final ThemeData lightTheme = ThemeData(
useMaterial3: true,
// ColorScheme을 사용하여 전체적인 색상 톤을 설정합니다.
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue, // 버튼 등 강조 색상은 파란색 계열로 유지합니다.
brightness: Brightness.light, // 밝은 테마로 설정합니다.
background: Colors.white, // 앱의 전체 배경을 흰색으로 지정합니다.
surface: Colors.white, // 카드 등 UI 요소의 표면 색을 흰색으로 지정합니다.
),
// Scaffold의 기본 배경색을 흰색으로 명확하게 지정합니다.
scaffoldBackgroundColor: Colors.white,
// 폰트 테마를 설정하고, 기본 텍스트 색상을 검은색으로 지정합니다.
textTheme: GoogleFonts.hedvigLettersSansTextTheme(
ThemeData.light().textTheme,
).apply(
bodyColor: Colors.black, // 일반 텍스트 색상
displayColor: Colors.black, // 제목 등 큰 텍스트 색상
),
// 앱 바 테마를 흰색 배경, 검은색 아이콘/글씨로 설정합니다.
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0, // 그림자 제거
),
);
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:csp2/common/data/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,
),
],
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
class CustomBottomNavBar extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
const CustomBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
const double customBottomNavHeight = 94.0;
const double customBottomNavIconSize = 22.0;
const double customBottomNavSelectedFontSize = 10.0;
const double customBottomNavUnselectedFontSize = 8.0;
return SizedBox(
height: customBottomNavHeight,
child: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home_filled),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
label: 'Plan',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.work_outline),
label: 'Career',
),
BottomNavigationBarItem(
icon: Icon(Icons.more_horiz_outlined),
label: 'More',
),
],
currentIndex: currentIndex,
unselectedItemColor: Colors.blue,
onTap: onTap,
type: BottomNavigationBarType.fixed,
iconSize: customBottomNavIconSize,
selectedFontSize: customBottomNavSelectedFontSize,
unselectedFontSize: customBottomNavUnselectedFontSize,
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:csp2/common/data/job.dart';
class JobCard extends StatelessWidget {
final Job job;
const JobCard({super.key, required this.job});
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 3,
child: SizedBox(
height: 200,
child: Row(
children: [
// 왼쪽 영역
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.jobName,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Expanded(
child: Text(
job.jobDescription,
overflow: TextOverflow.ellipsis,
maxLines: 4,
),
),
const SizedBox(height: 6),
Text(
"Show More", // You might want to make this clickable to show full description
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
const SizedBox(height: 10),
Text(
job.jobJobtag,
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black54),
),
],
),
),
),
// 오른쪽 영역
Expanded(
flex: 1,
child: Container(
decoration: const BoxDecoration(
color: Color(0xFFEFF1FB),
borderRadius: BorderRadius.only(
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 수입
Text(
job.jobIncome is num ? "\$ ${job.jobIncome}" : job.jobIncome.toString(),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
maxLines: 1,
softWrap: false,
),
// 수입 타입
Text(
job.jobIncomeType,
style: const TextStyle(color: Colors.grey),
maxLines: 1,
softWrap: false,
),
const SizedBox(height: 5),
// 도시
Text(
job.jobLocationCity,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
softWrap: false,
),
// 국가
Text(
job.jobLocationCountry,
style: const TextStyle(color: Colors.grey),
maxLines: 1,
softWrap: false,
),
const Spacer(),
// 지원 버튼
SizedBox(
height: 44,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xB91459DB),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
child: const Text("Apply", style: TextStyle(color: Colors.white)),
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../data/new_study.dart'; // NewStudy 모델을 import 합니다.
class StudyClassCard extends StatelessWidget {
final NewStudy plan;
final VoidCallback onTap;
const StudyClassCard({
super.key,
required this.plan,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
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(5.0)),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (plan.thumbnail.isNotEmpty)
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
plan.thumbnail,
width: double.infinity,
fit: BoxFit.contain,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
return const Center(child: Icon(Icons.broken_image, color: Colors.grey, size: 50));
},
),
),
)
else
Expanded(
child: Container(
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,
),
],
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../data/upcoming_study.dart'; // UpcomingStudy 모델을 import 합니다.
class UpcomingClassCard extends StatelessWidget {
final UpcomingStudy plan;
final VoidCallback onTap;
const UpcomingClassCard({
super.key,
required this.plan,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
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(5.0)),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (plan.thumbnail.isNotEmpty)
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
plan.thumbnail,
width: double.infinity,
fit: BoxFit.contain,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
return const Center(child: Icon(Icons.broken_image, color: Colors.grey, size: 50));
},
),
),
)
else
Expanded(
child: Container(
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,
),
],
),
),
],
),
),
),
),
);
}
}

View File

@@ -3,32 +3,13 @@ import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:async'; // Timer를 사용하기 위해 추가
import 'package:intl/intl.dart';
// import 'package:flutter_native_timezone/flutter_native_timezone.dart'; // 주석 처리된 상태 유지
import '../plan_page.dart'; // PlanPage import
// CaseStudyPlan 클래스 (변경 없음)
class CaseStudyPlan {
final String planId;
final String planTitle;
final String planTeacher;
final String thumbnail;
CaseStudyPlan({
required this.planId,
required this.planTitle,
required this.planTeacher,
required this.thumbnail,
});
factory CaseStudyPlan.fromJson(Map<String, dynamic> json) {
return CaseStudyPlan(
planId: json['planId'] ?? '아이디 없음',
planTitle: json['planTitle'] ?? '제목 없음',
planTeacher: json['planTeacher'] ?? '선생님 정보 없음',
thumbnail: json['thumbnail'] ?? '',
);
}
}
import 'common/widgets/upcoming_class_card.dart';
import 'common/widgets/course_card.dart';
import 'common/data/course.dart'; // Course 클래스 import
import 'plan_page.dart'; // PlanPage import
import 'common/widgets/now_study_class_card.dart';
import 'common/data/upcoming_study.dart';
import 'common/data/new_study.dart';
// 새로운 추천 플랜 모델
class RecommendPlan {
@@ -67,13 +48,13 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
late Future<List<CaseStudyPlan>> _caseStudyPlans;
late Future<List<UpcomingStudy>> _upcomingStudies;
late Future<List<NewStudy>> _newStudies;
late Future<List<Course>> _newCoursesFuture; // New: Future for new courses
DateTime _currentTime = DateTime.now();
String _currentTimeZone = 'Loading timezone...';
late Stream<DateTime> _clockStream;
int _selectedIndex = 0;
// --- 추천 클래스 관련 상태 변수 ---
List<RecommendPlan> _recommendPlans = [];
int _currentRecommendIndex = 0;
@@ -93,7 +74,9 @@ class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
_caseStudyPlans = _fetchCaseStudyPlans();
_upcomingStudies = _fetchUpcomingStudies();
_newStudies = _fetchNewStudies();
_newCoursesFuture = _fetchNewCourses(); // Initialize new courses future
_fetchTimezone();
_clockStream = Stream.periodic(const Duration(seconds: 1), (_) {
return DateTime.now();
@@ -111,7 +94,28 @@ class _HomePageState extends State<HomePage> {
super.dispose();
}
Future<List<CaseStudyPlan>> _fetchCaseStudyPlans() async {
Future<List<UpcomingStudy>> _fetchUpcomingStudies() async {
final response = await http
.get(Uri.parse('https://helloworld6-ad2uqhckxq-uc.a.run.app'));
if (response.statusCode == 200) {
final Map<String, dynamic> decodedJson = json.decode(response.body);
if (decodedJson.containsKey('data') && decodedJson['data'] is List) {
final List<dynamic> plansJson = decodedJson['data'];
return plansJson
.map((jsonItem) =>
UpcomingStudy.fromJson(jsonItem as Map<String, dynamic>))
.toList();
} else {
throw Exception(
'Invalid data format: "data" field is missing or not a list.');
}
} else {
throw Exception(
'Failed to load upcoming studies. Status Code: ${response.statusCode}');
}
}
Future<List<NewStudy>> _fetchNewStudies() async {
final response = await http
.get(Uri.parse('https://helloworld2-ad2uqhckxq-uc.a.run.app'));
if (response.statusCode == 200) {
@@ -120,7 +124,7 @@ class _HomePageState extends State<HomePage> {
final List<dynamic> plansJson = decodedJson['data'];
return plansJson
.map((jsonItem) =>
CaseStudyPlan.fromJson(jsonItem as Map<String, dynamic>))
NewStudy.fromJson(jsonItem as Map<String, dynamic>))
.toList();
} else {
throw Exception(
@@ -128,7 +132,26 @@ class _HomePageState extends State<HomePage> {
}
} else {
throw Exception(
'Failed to load case study plans. Status Code: ${response.statusCode}');
'Failed to load new studies. Status Code: ${response.statusCode}');
}
}
// New: Fetch new courses from API
Future<List<Course>> _fetchNewCourses() async {
final response = await http.get(Uri.parse('https://helloworld5-ad2uqhckxq-uc.a.run.app'));
if (response.statusCode == 200) {
final Map<String, dynamic> decodedJson = json.decode(response.body);
if (decodedJson.containsKey('data') && decodedJson['data'] is List) {
final List<dynamic> coursesJson = decodedJson['data'];
return coursesJson
.map((jsonItem) => Course.fromJson(jsonItem as Map<String, dynamic>))
.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}');
}
}
@@ -146,7 +169,6 @@ class _HomePageState extends State<HomePage> {
_currentTimeZone = 'Failed to get timezone.';
});
}
print('Could not get timezone: $e');
}
}
@@ -186,7 +208,6 @@ class _HomePageState extends State<HomePage> {
'Failed to load recommend plans. Status Code: ${response.statusCode}');
}
} catch (e) {
print('Error fetching recommend plans: $e');
if (mounted) {
setState(() {
_isLoadingRecommends = false;
@@ -224,17 +245,20 @@ class _HomePageState extends State<HomePage> {
final String formattedDate = DateFormat.yMMMMd().format(displayTime);
final String formattedTime = DateFormat.jms().format(displayTime);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
color: Theme.of(context).primaryColor.withOpacity(0.1),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(25),
borderRadius: BorderRadius.circular(8.0),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center, // 세로 중앙 정렬
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your Local Time', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
const SizedBox(height: 6.0),
Text('$formattedDate, $formattedTime', style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500)),
const SizedBox(height: 4.0),
Text('Timezone: $_currentTimeZone', style: TextStyle(fontSize: 13.0, color: Colors.grey[700])),
Text('$formattedTime', style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500)),
// const SizedBox(height: 4.0),
// Text('Timezone: $_currentTimeZone', style: TextStyle(fontSize: 13.0, color: Colors.grey[700])),
],
),
);
@@ -242,7 +266,215 @@ class _HomePageState extends State<HomePage> {
);
}
// --- 추천 클래스 섹션 위젯 ---
Widget _buildButtons() {
return Column(
mainAxisAlignment: MainAxisAlignment.center, // 세로 중앙 정렬
crossAxisAlignment: CrossAxisAlignment.stretch, // 버튼이 가로로 꽉 차게 설정
children: [
Expanded( // 버튼이 할당된 세로 공간을 모두 차지하도록 설정
child: ElevatedButton.icon(
icon: const Icon(Icons.add_circle_outline, size: 20),
label: const Text('Book Class', style: TextStyle(fontSize: 20)),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Book Class 버튼 기능 구현 예정')),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, // 배경색을 파란색으로 설정
foregroundColor: Colors.white, // 글자색을 흰색으로 설정
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
// const SizedBox(height: 8.0),
// ElevatedButton.icon(
// icon: const Icon(Icons.schedule, size: 20),
// label: const Text('Schedule', style: TextStyle(fontSize: 14)),
// onPressed: () {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text('Schedule 버튼 기능 구현 예정')),
// );
// },
// style: ElevatedButton.styleFrom(
// padding: const EdgeInsets.symmetric(vertical: 12.0),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8.0),
// ),
// ),
// ),
],
);
}
Widget _buildHomeContent() {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 1,
child: _buildLocalTimeBar(),
),
const SizedBox(width: 12.0),
Expanded(
flex: 1,
child: _buildButtons(),
),
],
),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), // vertical을 위아래 다르게 조정
child: Text('Upcoming Study', style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)),
),
SizedBox(
height: 200, // 가로 리스트의 높이를 줄입니다.
child: FutureBuilder<List<UpcomingStudy>>(
future: _upcomingStudies,
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 upcoming classes: ${snapshot.error}', textAlign: TextAlign.center),
));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('No upcoming classes available.'));
} else {
final plans = snapshot.data!;
return ListView.builder(
scrollDirection: Axis.horizontal, // 가로 스크롤로 변경
padding: const EdgeInsets.only(left: 10.0, right: 5.0, top: 8.0, bottom: 5.0),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
return Align(
alignment: Alignment.topCenter, // 항목을 상단 중앙에 정렬
child: UpcomingClassCard(
plan: plan,
onTap: () {
widget.onNavigateToPlanTab(1);
},
),
);
},
);
}
},
),
),
// --- ▼▼▼ 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: 180,
child: FutureBuilder<List<Course>>(
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: 10.0, right: 10.0, top: 5.0, bottom: 5.0),
itemCount: courses.length,
itemBuilder: (context, index) {
final course = courses[index];
return CourseCard(course: course);
},
);
}
},
),
),
// --- ▲▲▲ Find Your New Course 섹션 끝 ▲▲▲ ---
// --- ▼▼▼ 추천 클래스 섹션 호출 ▼▼▼ ---
_buildRecommendSection(),
// --- ▲▲▲ 추천 클래스 섹션 호출 끝 ▲▲▲ ---
const Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
child: Text('Trending On New Study', style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)),
),
FutureBuilder<List<NewStudy>>(
future: _newStudies,
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 studies: ${snapshot.error}', textAlign: TextAlign.center),
));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('No new studies available.'));
} else {
final plans = snapshot.data!;
return GridView.builder(
shrinkWrap: true, // Column 안에 GridView를 넣을 때 필요
physics: const NeverScrollableScrollPhysics(), // 부모 SingleChildScrollView와 스크롤 충돌 방지
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8.0),
itemCount: plans.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 한 줄에 2개씩
crossAxisSpacing: 10.0, // 가로 간격
mainAxisSpacing: 10.0, // 세로 간격
childAspectRatio: 1.0, // 아이템의 가로세로 비율 (조정 필요 시 변경)
),
itemBuilder: (context, index) {
final plan = plans[index];
return StudyClassCard(
plan: plan,
onTap: () {
// widget.onNavigateToPlanTab(1);
},
);
},
);
}
},
),
],
),
);
}
@override
Widget build(BuildContext context) {
final List<Widget> pageContents = _buildPageContents();
return Scaffold(
// appBar: AppBar( // AppBar 주석 처리됨 (기존 코드에서)
// title: Text(_selectedIndex == 0 ? 'Home' : 'Plan Page'),
// ),
body: IndexedStack( // AppBar가 없으므로 SafeArea로 감싸는 것을 고려해볼 수 있습니다.
index: 0,
children: pageContents,
)
);
}
Widget _buildRecommendSection() {
if (_isLoadingRecommends) {
return const Padding(
@@ -287,8 +519,8 @@ class _HomePageState extends State<HomePage> {
final currentPlan = _recommendPlans[_currentRecommendIndex];
return Container(
margin: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 16.0), // 하단 마진 추가
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 0.0), // 하단 마진 추가
// padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor, // 카드 색상 사용
borderRadius: BorderRadius.circular(12.0),
@@ -304,26 +536,9 @@ class _HomePageState extends State<HomePage> {
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,
aspectRatio: 6 / 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: currentPlan.thumbnail.isNotEmpty
? Image.network(
currentPlan.thumbnail,
@@ -357,187 +572,4 @@ class _HomePageState extends State<HomePage> {
),
);
}
// --- 추천 클래스 섹션 위젯 끝 ---
Widget _buildHomeContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildLocalTimeBar(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.add_circle_outline, size: 20),
label: const Text('Book Class', style: TextStyle(fontSize: 14)),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Book Class 버튼 기능 구현 예정')),
);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
const SizedBox(width: 12.0),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.schedule, size: 20),
label: const Text('Schedule', style: TextStyle(fontSize: 14)),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Schedule 버튼 기능 구현 예정')),
);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
],
),
),
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)),
),
Expanded(
child: FutureBuilder<List<CaseStudyPlan>>(
future: _caseStudyPlans,
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 upcoming classes: ${snapshot.error}', textAlign: TextAlign.center),
));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('No upcoming classes available.'));
} else {
final plans = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.only(top: 8.0),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
return InkWell(
onTap: () {
// *** 부모 위젯(MyHomePage)에게 Plan 탭으로 이동하라고 알림 ***
// *** PlanPage가 _widgetOptions 리스트에서 두 번째(인덱스 1)라고 가정 ***
widget.onNavigateToPlanTab(1); // *** 전달받은 콜백 호출 ***
},
child: Card(
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
elevation: 2.0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
plan.thumbnail,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Container(
height: 150,
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(height: 150, color: Colors.grey[200], child: const Center(child: Icon(Icons.broken_image, color: Colors.grey, size: 50)));
},
),
)
else
Container(height: 150, color: Colors.grey[200], child: const Center(child: Text('No Image', style: TextStyle(color: Colors.grey)))),
],
),
),
),
);
},
);
}
},
),
),
// --- ▼▼▼ 추천 클래스 섹션 호출 ▼▼▼ ---
_buildRecommendSection(),
// --- ▲▲▲ 추천 클래스 섹션 호출 끝 ▲▲▲ ---
],
);
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
// 홈 탭(인덱스 0)으로 돌아올 때 추천 타이머를 다시 시작할 수 있도록 고려
if (index == 0 && _recommendPlans.isNotEmpty && (_recommendTimer == null || !_recommendTimer!.isActive)) {
_startRecommendTimer();
} else if (index != 0) {
_recommendTimer?.cancel(); // 다른 탭으로 이동 시 타이머 일시 중지
}
});
}
@override
Widget build(BuildContext context) {
final List<Widget> pageContents = _buildPageContents();
return Scaffold(
// appBar: AppBar( // AppBar 주석 처리됨 (기존 코드에서)
// title: Text(_selectedIndex == 0 ? 'Home' : 'Plan Page'),
// ),
body: IndexedStack( // AppBar가 없으므로 SafeArea로 감싸는 것을 고려해볼 수 있습니다.
index: _selectedIndex,
children: pageContents,
)
);
}
}

View File

@@ -1,12 +1,15 @@
// main.dart
import 'package:csp2/common/theme/app_theme.dart';
import 'package:flutter/material.dart';
// 새로 만든 페이지 파일들을 import 합니다.
import 'home_page.dart'; // HomePage에 콜백을 전달해야 하므로 import 경로 확인
import 'plan_page.dart';
import 'statistics_page.dart';
import 'career_page.dart';
import 'more_page.dart';
import 'common/widgets/custom_bottom_nav_bar.dart';
void main() {
runApp(const MyApp());
@@ -19,24 +22,22 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Case Study',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
theme: AppTheme.lightTheme,
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
final int initialIndex;
const MyHomePage({super.key, this.initialIndex = 0});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
late int _selectedIndex;
// 각 탭에 연결될 페이지 위젯 리스트
// HomePage는 StatefulWidget이므로 const를 붙이지 않습니다.
@@ -46,11 +47,13 @@ class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
_selectedIndex = widget.initialIndex;
// *** 수정: HomePage 생성 시 onNavigateToPlanTab 콜백 전달 ***
_widgetOptions = <Widget>[
HomePage(onNavigateToPlanTab: _onItemTapped), // 콜백 함수 전달
const PlanPage(),
const StatisticsPage(),
const JobsPage(),
const MorePage(),
];
}
@@ -75,26 +78,24 @@ class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
// AppBar의 제목을 현재 탭에 따라 동적으로 변경
String appBarTitle = 'Home'; // 기본값
if (_selectedIndex == 1) {
appBarTitle = 'Plan';
} else if (_selectedIndex == 2) {
appBarTitle = 'Statistics';
} else if (_selectedIndex == 3) {
appBarTitle = 'More';
}
// --- BottomNavigationBar 크기 및 스타일 설정 ---
const double customBottomNavHeight = 75.0; // 원하는 BottomNavigationBar 높이
const double customBottomNavIconSize = 22.0; // 내부 아이콘 크기 (선택적 조절)
const double customBottomNavSelectedFontSize = 12.0; // 선택된 레이블 폰트 크기 (선택적 조절)
const double customBottomNavUnselectedFontSize = 10.0; // 미선택 레이블 폰트 크기 (선택적 조절)
// String appBarTitle = 'Home'; // 기본값
// if (_selectedIndex == 1) {
// appBarTitle = 'Plan';
// }
// else if (_selectedIndex == 2) {
// appBarTitle = 'Statistics';
// }
// else if (_selectedIndex == 3) {
// appBarTitle = 'Career';
// }
// else if (_selectedIndex == 4) {
// appBarTitle = 'More';
// }
return Scaffold(
appBar: AppBar(
toolbarHeight: 40,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(appBarTitle), // 동적으로 변경된 AppBar 제목
title: Text("Case Study"), // 동적으로 변경된 AppBar 제목\
actions: <Widget>[
IconButton(
icon: const Icon(Icons.notifications),
@@ -111,11 +112,7 @@ class _MyHomePageState extends State<MyHomePage> {
onTap: _navigateToProfileTab,
customBorder: const CircleBorder(),
child: const CircleAvatar(
backgroundColor: Colors.grey,
child: Icon(
Icons.person,
color: Colors.white,
),
backgroundImage: NetworkImage('https://manostmboy.github.io/temp/dumass.png'),
),
),
),
@@ -125,36 +122,9 @@ class _MyHomePageState extends State<MyHomePage> {
index: _selectedIndex,
children: _widgetOptions,
),
bottomNavigationBar: SizedBox(
height: customBottomNavHeight,
child: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home_filled),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
label: 'Plan',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.more_horiz_outlined),
label: 'More',
),
],
bottomNavigationBar: CustomBottomNavBar(
currentIndex: _selectedIndex,
selectedItemColor: Colors.amber[800],
unselectedItemColor: Colors.blue,
onTap: _onItemTapped,
type: BottomNavigationBarType.fixed,
iconSize: customBottomNavIconSize,
selectedFontSize: customBottomNavSelectedFontSize,
unselectedFontSize: customBottomNavUnselectedFontSize,
),
),
);
}

View File

@@ -3,15 +3,40 @@ import 'package:flutter/material.dart';
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text(
'More Page',
style: TextStyle(fontSize: 24),
return Scaffold(
// appBar: AppBar(
// title: const Text('My Page'),
// ),
body: Column(
children: [
const ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage('https://manostmboy.github.io/temp/dumass.png'),
),
title: Text('김보통'),
subtitle: Text('normalkim@manos.kr'),
),
const Divider(),
// 메뉴 항목
_buildMenuItem(Icons.subscriptions, 'My Subscriptions'),
_buildMenuItem(Icons.bookmark, 'Saved Lessons'),
_buildMenuItem(Icons.card_membership, 'Certificates'),
_buildMenuItem(Icons.notifications, 'Notifications'),
],
),
);
}
Widget _buildMenuItem(IconData icon, String title) {
return ListTile(
leading: Icon(icon),
title: Text(title),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
// 탭 시 동작을 여기에 정의할 수 있어요.
},
);
}
}

View File

@@ -2,32 +2,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'plan_page_detail.dart'; // <<< plan_page_detail.dart 파일을 import 합니다.
// HomePage에서 사용하던 CaseStudyPlan 모델을 PlanPage에서도 사용하거나,
// PlanPage에 필요한 별도의 데이터 모델을 정의할 수 있습니다.
// 여기서는 동일한 모델을 재사용한다고 가정합니다.
class CaseStudyPlan {
final String planId;
final String planTitle;
final String planTeacher;
final String thumbnail;
CaseStudyPlan({
required this.planId,
required this.planTitle,
required this.planTeacher,
required this.thumbnail,
});
factory CaseStudyPlan.fromJson(Map<String, dynamic> json) {
return CaseStudyPlan(
planId: json['planId'] ?? 'ID 없음',
planTitle: json['planTitle'] ?? '제목 없음',
planTeacher: json['planTeacher'] ?? '선생님 정보 없음',
thumbnail: json['thumbnail'] ?? '',
);
}
}
import 'common/data/case_study_plan.dart';
class PlanPage extends StatefulWidget {
const PlanPage({super.key});
@@ -49,7 +24,7 @@ class _PlanPageState extends State<PlanPage> {
Future<List<CaseStudyPlan>> _fetchPlanData() async {
// HomePage와 동일한 API 주소를 사용합니다.
final response = await http
.get(Uri.parse('https://helloworld2-ad2uqhckxq-uc.a.run.app'));
.get(Uri.parse('https://helloworld6-ad2uqhckxq-uc.a.run.app'));
if (response.statusCode == 200) {
final Map<String, dynamic> decodedJson = json.decode(response.body);
@@ -80,8 +55,10 @@ class _PlanPageState extends State<PlanPage> {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Error loading PlanPage data: ${snapshot.error}', textAlign: TextAlign.center),
));
child: Text('Error loading PlanPage data: ${snapshot.error}', textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('No data available for PlanPage.'));
} else {
@@ -91,32 +68,10 @@ class _PlanPageState extends State<PlanPage> {
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
return InkWell( // <<< Card를 InkWell로 감싸서 탭 이벤트를 추가합니다.
onTap: () {
// plan_page_detail로 이동하면서 planId를 arguments로 전달
if (plan.planId == 'ID 없음' || plan.planId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('유효한 Plan ID가 없어 상세 페이지로 이동할 수 없습니다.')),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const plan_page_detail(), // plan_page_detail 위젯을 생성
settings: RouteSettings(
arguments: plan.planId, // 선택된 plan의 ID를 전달
),
),
);
// 만약 Named Route를 사용하고 main.dart에 '/plan_detail' 라우트가 정의되어 있다면:
// Navigator.pushNamed(
// context,
// '/plan_detail', // MaterialApp에 정의된 라우트 이름
// arguments: plan.planId,
// );
},
child: Card( // <<< 기존 Card 위젯
return InkWell(
// onTap: () {
// },
child: Card(
margin:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
elevation: 2.0,
@@ -125,34 +80,18 @@ class _PlanPageState extends State<PlanPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
plan.planTitle,
style: const TextStyle(
fontSize: 17.0, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8.0),
Text(
plan.planTeacher,
style: const TextStyle(
fontSize: 13.0, color: Colors.grey),
),
],
),
const SizedBox(height: 8.0),
if (plan.thumbnail.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
plan.thumbnail,
height: 140, // 약간 작은 이미지 크기
height: 200,
width: double.infinity,
fit: BoxFit.cover,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
@@ -184,9 +123,92 @@ class _PlanPageState extends State<PlanPage> {
child: const Center(
child: Text('No Image',
style: TextStyle(color: Colors.grey)))),
// const SizedBox(height: 8.0),
// // PlanPage용 ListView 아이템에 추가적인 정보를 표시하거나 다른 UI를 구성할 수 있습니다.
// Text('Plan ID: ${plan.planId}', style: TextStyle(fontSize: 12.0, color: Colors.blueGrey)),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
plan.planTitle,
style: const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
const SizedBox(height: 5.0),
// Text(
// plan.planTeacher,
// style: const TextStyle(
// fontSize: 15.0, color: Colors.black45),
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// ),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
"Progress",
style: const TextStyle(
fontSize: 15.0, color: Colors.black45),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 5.0),
Text(
"50%",
style: const TextStyle(
fontSize: 15.0, color: Colors.black45),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const SizedBox(height: 8.0),
LinearProgressIndicator(
value: 0.5, // TODO: Replace with actual progress value from plan object
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
const SizedBox(height: 16.0),
Column(
mainAxisAlignment: MainAxisAlignment.center, // 세로 중앙 정렬
crossAxisAlignment: CrossAxisAlignment.stretch, // 버튼이 가로로 꽉 차게 설정
children: [ElevatedButton(
onPressed: () {
// TODO: Implement navigation or action for Continue Learning
if (plan.planId == 'ID 없음' || plan.planId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('유효한 Plan ID가 없어 상세 페이지로 이동할 수 없습니다.')),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PlanPageDetail(),
settings: RouteSettings(
// <<< Map 형태로 planId와 planTitle을 전달 >>>
arguments: {
'planId': plan.planId,
'planTitle': plan.planTitle,
},
),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xB91459DB),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
child: const Text('Continue Learning',style: TextStyle(color: Colors.white)),
),],),
],
),
],
),
),

View File

@@ -1,88 +1,88 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'main.dart';
import 'youtube_player_page.dart'; // YoutubePlayerPage import
import 'common/widgets/custom_bottom_nav_bar.dart';
import 'common/data/plan_detail_item.dart';
// PlanDetailItem 클래스
class PlanDetailItem {
final String lessonId;
final String lessonTag;
final String lessonUrl;
final String thumbnail;
PlanDetailItem({
required this.lessonId,
required this.lessonTag,
required this.lessonUrl,
required this.thumbnail,
});
factory PlanDetailItem.fromJson(Map<String, dynamic> json) {
return PlanDetailItem(
lessonId: json['casestudy lesson id'] ?? 'ID 없음',
lessonTag: json['lesson tag'] ?? '태그 없음',
lessonUrl: json['lesson url'] ?? 'URL 없음',
thumbnail: json['thumbnail'] ?? '',
);
}
}
class plan_page_detail extends StatefulWidget {
// final int currentBottomNavIndex; // HomePage 등에서 전달받을 현재 탭 인덱스
const plan_page_detail({
class PlanPageDetail extends StatefulWidget {
const PlanPageDetail({
super.key,
// this.currentBottomNavIndex = 0, // 기본값
});
@override
State<plan_page_detail> createState() => _plan_page_detailState();
State<PlanPageDetail> createState() => _PlanPageDetailState();
}
class _plan_page_detailState extends State<plan_page_detail> {
class _PlanPageDetailState extends State<PlanPageDetail> {
String? _planId;
String? _planTitle; // <<< planTitle을 저장할 상태 변수 추가 >>>
Future<List<PlanDetailItem>>? _planDetails;
late int _currentBottomNavIndex; // 하단 네비게이션 바 상태
late int _currentBottomNavIndex;
String? _selectedYoutubeUrl;
PlanDetailItem? _selectedItem; // <<< 선택된 아이템을 저장할 변수 추가
final ScrollController _scrollController = ScrollController(); // 스크롤 컨트롤러 추가
@override
void initState() {
super.initState();
// 이전 페이지에서 전달받은 탭 인덱스로 초기화하거나 기본값 사용
// _currentBottomNavIndex = widget.currentBottomNavIndex;
_currentBottomNavIndex = 0; // 예시: '홈' 탭을 기본으로 설정
_currentBottomNavIndex = 1;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_planId == null) {
// 인자를 한 번만 처리하도록 조건 추가
if (_planId == null && _planTitle == null) {
final Object? arguments = ModalRoute.of(context)?.settings.arguments;
if (arguments is String) {
_planId = arguments;
if (arguments is Map<String, String>) { // <<< 전달받은 인자가 Map인지 확인 >>>
setState(() { // <<< setState로 상태 변수 업데이트 >>>
_planId = arguments['planId'];
_planTitle = arguments['planTitle'];
});
if (_planId != null) {
_planDetails = _fetchPlanDetails(_planId!);
} else {
// Map에는 있지만 planId 키가 없는 경우 (이론상 발생하기 어려움)
setState(() {
_planTitle = arguments['planTitle'] ?? 'Error'; // planTitle은 있을 수 있음
});
_planDetails =
Future.error(Exception("Plan ID가 Map에 포함되지 않았습니다."));
}
} else {
_planDetails = Future.error(Exception("Plan ID not provided or invalid."));
print("Error: Plan ID not provided or invalid.");
// 인자가 Map이 아니거나 null인 경우
setState(() {
_planTitle = 'Error'; // AppBar에 오류 표시
});
_planDetails =
Future.error(Exception("전달된 인자가 올바르지 않습니다. (Map<String, String> 형태여야 함)"));
}
}
}
Future<List<PlanDetailItem>> _fetchPlanDetails(String planId) async {
final response = await http
.get(Uri.parse('https://helloworld1-ad2uqhckxq-uc.a.run.app/?id=$planId'));
final response = await http.get(
Uri.parse('https://helloworld1-ad2uqhckxq-uc.a.run.app/?id=$planId'));
if (response.statusCode == 200) {
final Map<String, dynamic> decodedJson = json.decode(response.body);
if (decodedJson.containsKey('data') && decodedJson['data'] is List) {
final List<dynamic> detailsJson = decodedJson['data'];
return detailsJson.map((jsonItem) => PlanDetailItem.fromJson(jsonItem as Map<String, dynamic>)).toList();
return detailsJson
.map((jsonItem) =>
PlanDetailItem.fromJson(jsonItem as Map<String, dynamic>))
.toList();
} else {
throw Exception('Invalid API response format: "data" field is missing or not a list.');
throw Exception(
'Invalid API response format: "data" field is missing or not a list.');
}
} else {
throw Exception('Failed to load plan details. Status code: ${response.statusCode}');
throw Exception(
'Failed to load plan details. Status code: ${response.statusCode}');
}
}
@@ -90,41 +90,36 @@ class _plan_page_detailState extends State<plan_page_detail> {
setState(() {
_currentBottomNavIndex = index;
});
// 페이지 이동 로직 (이전 답변 참고)
if (index == 0) {
Navigator.of(context).popUntil((route) => route.isFirst);
} else if (index == 1) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Search (Not Implemented)')),
);
} else if (index == 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile (Not Implemented)')),
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => MyHomePage(initialIndex: index)),
(Route<dynamic> route) => false,
);
}
@override
void dispose() {
_scrollController.dispose(); // 스크롤 컨트롤러 해제
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 1. 상단 바 (AppBar)
appBar: AppBar(
title: Text(_planId != null ? 'Plan: $_planId' : 'Plan Details'),
// 필요하다면 leading에 뒤로가기 버튼 명시적 추가
leading: Navigator.canPop(context)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new), // 또는 Icons.arrow_back
tooltip: 'Back',
onPressed: () => Navigator.of(context).pop(),
)
: null,
toolbarHeight: 40,
title: Text(_planTitle.toString()),
),
body: _planId == null
? const Center(
child: Text(
'Plan ID가 전달되지 않았습니다.',
body: _planId == null && _planTitle == null && _planDetails == null
? Center( // 초기 로딩 상태 또는 인자 오류
child: _planTitle == 'Error'
? const Text(
'플랜 정보를 불러올 수 없습니다.',
style: TextStyle(fontSize: 18, color: Colors.red),
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
: const CircularProgressIndicator(),
)
: FutureBuilder<List<PlanDetailItem>>(
future: _planDetails,
@@ -132,88 +127,232 @@ class _plan_page_detailState extends State<plan_page_detail> {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Error loading details: ${snapshot.error}', textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('세부 계획 데이터가 없습니다.'));
} else {
final details = snapshot.data!;
return ListView.builder(
if (_selectedItem == null && details.isNotEmpty) {
_selectedItem = details.first;
}
// 첫 번째 비디오의 URL을 가져와 _selectedYoutubeUrl을 초기화합니다.
if (_selectedYoutubeUrl == null && details.isNotEmpty) {
_selectedYoutubeUrl = details.firstWhere(
(item) => item.lessonUrl.isNotEmpty &&
(item.lessonUrl.contains('youtube.com') ||
item.lessonUrl.contains('youtu.be')),
orElse: () => PlanDetailItem(lessonId: '', lessonTag: '', lessonUrl: '', thumbnail: '',lessonName: '', lessonDescription: ''),
).lessonUrl.isNotEmpty ? details.firstWhere(
(item) => item.lessonUrl.isNotEmpty &&
(item.lessonUrl.contains('youtube.com') ||
item.lessonUrl.contains('youtu.be')),
orElse: () => PlanDetailItem(lessonId: '', lessonTag: '', lessonUrl: '', thumbnail: '',lessonName: '', lessonDescription: ''),
).lessonUrl : null;
}
return Column( // ListView와 YoutubePlayerPage를 세로로 배치하기 위해 Column 사용
children: [
SizedBox(
height: 150, // 가로 스크롤 리스트의 높이
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
itemCount: details.length,
itemBuilder: (context, index) {
final item = details[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: ListTile(
leading: item.thumbnail.isNotEmpty
? SizedBox(
return GestureDetector(
onTap: () {
_scrollController.jumpTo(0); // 스크롤 맨 위로 이동
setState(() {
_selectedItem = item;
if (item.lessonUrl.isNotEmpty &&
(item.lessonUrl.contains('youtube.com') ||
item.lessonUrl.contains('youtu.be'))) {
_selectedYoutubeUrl = item.lessonUrl;
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'유효한 YouTube URL이 아닙니다: ${item.lessonUrl}')),
);
}
});
},
child: Container(
width: 110,
margin: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 8.0),
decoration: BoxDecoration(
border: _selectedItem == item
? Border.all(color: Colors.blueAccent, width: 3)
: null,
borderRadius: BorderRadius.circular(8.0),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 100,
height: 100,
child: Image.network(
child: ClipRRect(
borderRadius:
BorderRadius.circular(8.0),
child: item.thumbnail.isNotEmpty
? Image.network(
item.thumbnail,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.broken_image, size: 40, color: Colors.grey);
errorBuilder: (context, error,
stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(
Icons.broken_image,
size: 40,
color: Colors.grey),
);
},
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
loadingBuilder: (BuildContext
context,
Widget child,
ImageChunkEvent?
loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
child:
CircularProgressIndicator(
value: loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
strokeWidth: 2.0,
),
);
},
)
: Container(
color: Colors.grey[200],
child: const Icon(
Icons.image_not_supported,
size: 40,
color: Colors.grey),
),
),
),
const SizedBox(height: 6),
Text(
item.lessonName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
);
},
),
),
Expanded(
child: SingleChildScrollView(
controller: _scrollController, // 스크롤 컨트롤러 연결
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_selectedItem!.thumbnail.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.network(
_selectedItem!.thumbnail,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12.0),
),
child: const Icon(Icons.broken_image, size: 60, color: Colors.grey),
);
},
),
)
: Container(
width: 100,
height: 100,
color: Colors.grey[200],
child: const Icon(Icons.image_not_supported, size: 40, color: Colors.grey),
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12.0),
),
title: Text(item.lessonTag, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
child: const Icon(Icons.image_not_supported, size: 60, color: Colors.grey),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('ID: ${item.lessonId}', style: TextStyle(fontSize: 12, color: Colors.grey[700])),
const SizedBox(height: 2),
Text(
item.lessonUrl,
style: const TextStyle(fontSize: 12, color: Colors.blueAccent),
overflow: TextOverflow.ellipsis,
maxLines: 2,
Expanded(
child: Text(
_selectedItem!.lessonName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
],
),
isThreeLine: true,
onTap: () {
if (item.lessonUrl.isNotEmpty && (item.lessonUrl.contains('youtube.com') || item.lessonUrl.contains('youtu.be'))) {
IconButton(
icon: const Icon(Icons.play_circle_fill, size: 40, color: Colors.blue),
onPressed: () {
if (_selectedYoutubeUrl != null && _selectedYoutubeUrl!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => YoutubePlayerPage(
lessonUrl: item.lessonUrl,
// currentBottomNavIndex: _currentBottomNavIndex, // 현재 탭 인덱스 전달
),
builder: (context) => YoutubePlayerPage(lessonUrl: _selectedYoutubeUrl!),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('유효한 YouTube URL이 아닙니다: ${item.lessonUrl}')),
const SnackBar(content: Text('재생할 수 있는 영상이 없습니다.')),
);
}
},
),
);
},
],
),
const SizedBox(height: 12),
Text(
_selectedItem!.lessonDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
],
);
}
},
),
// 2. 하단 바 (BottomNavigationBar)
bottomNavigationBar: CustomBottomNavBar(
currentIndex: _currentBottomNavIndex,
onTap: _onBottomNavItemTapped,
),
);
}
}

View File

@@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // SystemChrome, DeviceOrientation 사용을 위해 import
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import 'common/widgets/custom_bottom_nav_bar.dart';
class YoutubePlayerPage extends StatefulWidget {
final String lessonUrl;
// final int currentBottomNavIndex;
final void Function(bool isFullScreen)? onFullScreenToggle;
const YoutubePlayerPage({
super.key,
required this.lessonUrl,
// this.currentBottomNavIndex = 0,
this.onFullScreenToggle,
});
@override
@@ -23,16 +24,11 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
String _videoTitle = 'YouTube Video';
final int _currentBottomNavIndex = 0;
bool _isSystemUiVisible = true;
bool _isFullScreen = false;
@override
void initState() {
super.initState();
// 페이지 진입 시 기본 화면 방향 설정 (선택적)
// SystemChrome.setPreferredOrientations([
// DeviceOrientation.portraitUp,
// DeviceOrientation.portraitDown,
// ]);
_videoId = YoutubePlayer.convertUrlToId(widget.lessonUrl);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -59,12 +55,9 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
flags: const YoutubePlayerFlags(
autoPlay: true,
mute: false,
// <<< 동영상 재생이 끝나면 컨트롤러를 자동으로 숨기지 않도록 설정 (선택적) >>>
// hideControls: false, // 기본값은 true
),
)..addListener(_playerListener);
} else {
print("Error: Could not extract video ID from URL: ${widget.lessonUrl}");
_videoTitle = 'Video Error';
}
}
@@ -72,48 +65,30 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
void _playerListener() {
if (_controller == null || !mounted) return;
// <<< 재생 상태 감지 >>>
if (_isFullScreen != _controller!.value.isFullScreen) {
setState(() {
_isFullScreen = _controller!.value.isFullScreen;
});
widget.onFullScreenToggle?.call(_isFullScreen);
if (_isFullScreen) {
_hideSystemUi();
} else {
_showSystemUi();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
}
if (_controller!.value.playerState == PlayerState.ended) {
// 동영상 재생이 완료되었을 때 처리할 로직
print("Video has ended.");
if (mounted) {
// 전체 화면 모드였다면 해제
if (_controller!.value.isFullScreen) {
_controller!.toggleFullScreenMode();
}
// 시스템 UI를 다시 보이도록 설정 (toggleFullScreenMode가 자동으로 처리할 수도 있음)
_showSystemUi();
// 세로 화면으로 복귀 (toggleFullScreenMode가 자동으로 처리할 수도 있음)
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// 필요하다면 페이지를 pop 하거나 다른 동작 수행
// 예: Navigator.of(context).pop();
// 또는 사용자에게 알림 표시
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text('동영상 재생이 완료되었습니다.')),
// );
}
return; // ended 상태 처리 후 리스너의 나머지 로직은 건너뛸 수 있음
}
if (_controller!.value.isFullScreen) {
_hideSystemUi();
// 플레이어가 전체 화면으로 진입하면 가로 방향으로 설정 (선택적, 플레이어가 자동으로 할 수 있음)
// SystemChrome.setPreferredOrientations([
// DeviceOrientation.landscapeLeft,
// DeviceOrientation.landscapeRight,
// ]);
} else {
_showSystemUi();
// <<< 전체 화면이 해제되면 화면 방향을 세로로 복구 >>>
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
return;
}
if (_isPlayerReady) {
@@ -148,18 +123,6 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
}
}
void _onNotificationTapped() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('알림 아이콘 클릭됨')),
);
}
void _onProfileTapped() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('프로필 아이콘 클릭됨')),
);
}
void _onBottomNavItemTapped(int index) {
if (_currentBottomNavIndex == index && index != 0) return;
if (index == 0) {
@@ -174,7 +137,10 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
tabName = 'Statistics';
break;
case 3:
tabName = 'More';
tabName = 'Career'; // New case for Jobs
break;
case 4:
tabName = 'More'; // Updated case for More
break;
}
ScaffoldMessenger.of(context).showSnackBar(
@@ -183,6 +149,23 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
}
}
@override
void didUpdateWidget(covariant YoutubePlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.lessonUrl != oldWidget.lessonUrl) {
_videoId = YoutubePlayer.convertUrlToId(widget.lessonUrl);
if (_videoId != null) {
_controller?.load(_videoId!); // 새 비디오 로드
} else {
if (mounted) {
setState(() {
_videoTitle = 'Video Error';
});
}
}
}
}
// <<< 뒤로가기 버튼 처리 로직 >>>
Future<bool> _onWillPop() async {
if (_controller != null && _controller!.value.isFullScreen) {
@@ -199,52 +182,25 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
Widget build(BuildContext context) {
final bool isFullScreen = _controller?.value.isFullScreen ?? false;
// <<< WillPopScope로 Scaffold를 감싸서 뒤로가기 이벤트 가로채기 >>>
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
extendBodyBehindAppBar: isFullScreen,
appBar: isFullScreen
? null
: AppBar(
leading: Navigator.canPop(context)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
// <<< AppBar의 뒤로가기 버튼도 _onWillPop 로직을 따르도록 수정 >>>
onPressed: () async {
if (await _onWillPop()) {
// true를 반환하면 (즉, 전체화면이 아니면) 페이지 pop
Navigator.pop(context);
// <<< PopScope로 Scaffold를 감싸서 뒤로가기 이벤트 가로채기 >>>
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) {
return;
}
final bool shouldPop = await _onWillPop();
if (shouldPop) {
Navigator.of(context).pop();
}
},
)
: null,
title: Text(_videoTitle),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.notifications_none),
tooltip: '알림',
onPressed: _onNotificationTapped,
),
Padding(
padding: const EdgeInsets.only(right: 16.0, left: 8.0),
child: InkWell(
onTap: _onProfileTapped,
customBorder: const CircleBorder(),
child: const CircleAvatar(
radius: 16,
backgroundColor: Colors.grey,
child: Icon(
Icons.person,
size: 20,
color: Colors.white,
),
),
),
),
],
),
body: Center(
child: Scaffold(
extendBodyBehindAppBar: isFullScreen,
body: Column( // <--- Wrap the body content in a Column
children: [
Expanded( // <--- Wrap the main content with Expanded
child: Center(
child: _controller == null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -258,6 +214,7 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
'비디오를 로드할 수 없습니다.\nURL: ${widget.lessonUrl}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
overflow: TextOverflow.ellipsis,
),
),
],
@@ -279,50 +236,18 @@ class _YoutubePlayerPageState extends State<YoutubePlayerPage> {
}
});
}
print('Player is ready.');
},
// <<< 필요하다면 onEnded 콜백 직접 사용 가능 (addListener와 중복될 수 있으니 주의) >>>
// onEnded: (metadata) {
// print("Video has ended (onEnded callback).");
// if (mounted) {
// if (_controller!.value.isFullScreen) {
// _controller!.toggleFullScreenMode();
// }
// _showSystemUi();
// SystemChrome.setPreferredOrientations([
// DeviceOrientation.portraitUp,
// DeviceOrientation.portraitDown,
// ]);
// }
// },
),
),
),
],
),
bottomNavigationBar: isFullScreen
? null
: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home_filled),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
label: 'Plan',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.more_horiz_outlined),
label: 'More',
),
],
: CustomBottomNavBar(
currentIndex: _currentBottomNavIndex,
selectedItemColor: Colors.amber[800],
unselectedItemColor: Colors.blue,
onTap: _onBottomNavItemTapped,
type: BottomNavigationBarType.fixed,
),
),
);

View File

@@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation
import flutter_inappwebview_macos
import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
}

View File

@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -25,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -41,6 +81,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -57,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
flutter:
dependency: "direct main"
description: flutter
@@ -126,6 +190,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -134,6 +206,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_native_splash:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc"
url: "https://pub.dev"
source: hosted
version: "2.4.6"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -144,6 +224,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
url: "https://pub.dev"
source: hosted
version: "6.2.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
@@ -160,6 +256,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl:
dependency: "direct main"
description:
@@ -168,6 +272,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -232,6 +344,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.17"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@@ -240,6 +416,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
sky_engine:
dependency: transitive
description: flutter
@@ -301,6 +485,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
vector_math:
dependency: transitive
description:
@@ -325,6 +517,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
youtube_player_flutter:
dependency: "direct main"
description:
@@ -335,4 +551,4 @@ packages:
version: "9.1.1"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.24.0"
flutter: ">=3.27.0"

View File

@@ -6,8 +6,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# followed by an optional build number separated by a +.# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
@@ -37,10 +36,13 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_native_splash: ^2.3.11 # 최신 버전으로 추가
flutter_launcher_icons: ^0.13.1 # 이 줄을 추가합니다.
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
@@ -61,9 +63,8 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/splash/ # 스플래시 이미지 폴더 추가
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
@@ -90,3 +91,16 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_native_splash:
color: "#ffffff"
image: assets/splash/splash.png
android: true
ios: true
web: false
flutter_launcher_icons:
android: "launcher_icon"
ios: true
image_path: "assets/icon/icon.png"
min_sdk_android: 21 # android min SDK