Flutter는 Google에서 개발한 UI 툴킷으로, 단일 코드베이스에서 iOS, Android, 웹 및 데스크톱용 애플리케이션을 개발할 수 있는 기능을 제공합니다. 이번 글에서는 Flutter로 개발한 진중문고 애플리케이션의 메인 화면 구성을 소개드리겠습니다.
MainScreen 클래스는 Flutter 애플리케이션에서 메인 화면을 담당하는 위젯입니다. 이 클래스는 StatefulWidget을 상속하여 상태를 가지며, 화면에 표시되는 내용이나 데이터를 업데이트할 수 있습니다. 이 클래스는 애플리케이션의 상단 바, 네비게이션 메뉴, 그리고 쿼터별 도서 목록과 개발자가 선택한 도서 목록 등을 표시합니다.
Widget build(BuildContext context) {
double deviceWidth = MediaQuery.of(context).size.width;
double deviceHeight = MediaQuery.of(context).size.height;
double widthRatio = deviceWidth / 375;
double heightRatio = deviceHeight / 812;
return Scaffold( // ... );
}
적응형 화면을 만들기 위해, BuildContext context
로부터 현재 device
의 너비와 높이정보를 가져왔습니다. Figma로 작업한 화면의 가로와 세로를 기준(가로:375, 세로:812)으로 위젯들의 높이, 너비, 간격들을 설정했습니다. 따라서 위젯들의 크기 비율을 Figma 화면과 동일하게 만들기 위해 모든 위젯의 너비와 높이에 각각 widthRatio
와 heightRatio
를 곱한 값을 사용했습니다.
메인 화면은 CustomScrollView와 Sliver 위젯을 사용하여 구성됩니다. 아래는 CustomScrollView 내부의 SliverList와 SliverToBoxAdapter를 사용하여 섹션을 구분하고 구분선을 표시하는 부분입니다.
CustomScrollView(
slivers: [
// 네비게이션바
SliverToBoxAdapter(
child: nav_widget(context),
),
// 구분선
SliverList(
delegate: SliverChildListDelegate([
// 구분선
Container(
margin: EdgeInsets.only(
top: heightRatio * 11,
left: widthRatio * 10,
right: widthRatio * 10,
),
width: widthRatio * 355,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
width: widthRatio * 3,
strokeAlign: BorderSide.strokeAlignCenter,
color: Colors.white.withOpacity(0.5),
),
),
),
),
]),
),
// ...
],
),
: Dev's Pick 책 목록은 좌우로 스크롤이 되도록 하면서, 전체 화면이 위아래로 스크롤이 되도록 구현하고 싶었습니다. 전체 화면을 ScrollChildScrollView(child: Column(...))
로 수직 스크롤을 구현한 후, 그 자식으로 Dev's Pick 목록을 SingleChildScrollView( scrollDirection: Axis.horizontal, child: ...)
를 갖도록 시도한 결과 지속적으로 오류가 발생했습니다. ScrollView안에 ScrollVeiw를 넣었을 때, 각 위젯들이 스크롤을 두고 경쟁하면서 발생하는 오류인 것 같았습니다. 따라서 유연한 스크롤링 구현이 가능한 Sliver 계열의 위젯을 사용하였습니다.
AppBar
AppBar를 커스터마이징하여 메뉴버튼, 로고, 검색 아이콘, 마이페이지 버튼을 추가하였습니다. 각각의 onPressed 이벤트에서는 해당 페이지로 이동하도록 설정되어 있습니다.
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:military_bookstore/screen/search_bookTitle_screen.dart';
AppBar appbar_widget(BuildContext context) {
double deviceWidth = MediaQuery.of(context).size.width;
double deviceHeight = MediaQuery.of(context).size.height;
double widthRatio = deviceWidth / 375;
double heightRatio = deviceHeight / 812;
return AppBar(
backgroundColor: Color(0xA545B0C5),
// elevation: 1.0,
iconTheme: IconThemeData(color: Colors.white),
leading: Builder(builder: (context) {
return IconButton(
icon: SvgPicture.asset(
"assets/main_screen_icon/menu_btn.svg",
),
onPressed: () {
// Drawer를 열기 위한 동작
Scaffold.of(context).openDrawer(); // 이 부분이 Drawer를 열어주는 코드
},
);
}),
actions: [
IconButton(
icon: Image.asset(
'assets/app_logo.png',
),
onPressed: () async {
Navigator.pushNamed(context, '/main');
}),
SizedBox(width: widthRatio * 3),
IconButton(
icon: SvgPicture.asset(
"assets/main_screen_icon/appbar_search.svg",
width: widthRatio * 252,
height: heightRatio * 36,
),
onPressed: () {
// 검색 아이콘이 눌렸을 때의 동작
Navigator.push(context,
MaterialPageRoute(builder: (context) => SearchBookTitleScreen()));
},
),
IconButton(
icon: SvgPicture.asset(
"assets/main_screen_icon/mypage_btn.svg",
width: widthRatio * 32,
height: heightRatio * 32,
),
onPressed: () {
Navigator.pushNamed(context, '/account');
},
),
SizedBox(width: widthRatio * 5),
],
);
}
Drewer
위 Appbar에서 좌측의 메뉴버튼을 클릭하면 아래와 같은 Drewer가 나타납니다.
로그아웃 상태(위), 로그인 상태(아래)
아래 코드는 위 Drewer Widget
의 코드입니다. 아래 코드에서는 Drawer
를 생성하고, 사용자 인증 상태에 따라 다른 DrawerHeader
를 보여주도록 구현했습니다. 또한 각각의 네비게이션 아이템에 대한 ListTile도 추가되어 있습니다. 각각의 ListTile
은 사용자가 해당 항목을 탭했을 때의 동작을 정의합니다. 목적지가 문자열인 경우는 Navigator
경로를 사용하고, 위젯인 경우는 해당 위젯으로 이동하도록 설정했습니다.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:military_bookstore/screen/bookworm_rank_screen.dart';
import 'package:military_bookstore/screen/category_bookList_screen.dart';
import 'package:military_bookstore/screen/quarterly_bookList_screen.dart';
import 'package:military_bookstore/screen/vote_next_book_screen.dart';
Drawer drewer_widget(BuildContext context) {
final double widthRatio = MediaQuery.of(context).size.width / 375;
final double heightRatio = MediaQuery.of(context).size.height / 812;
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
Container(
padding: EdgeInsets.all(0),
decoration: BoxDecoration(
color: const Color(0xBB4580C5),
),
width: double.infinity,
alignment: Alignment.bottomLeft,
child: FutureBuilder<dynamic>(
future: getCurrentUser(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
final bool isLogin = snapshot.data != '';
return isLogin
? loginDrawerHeader(context, snapshot.data)
: logoutDrawerHeader(context, widthRatio, heightRatio);
}
},
),
),
buildListTile(
context,
Icons.home,
'홈',
'/main',
),
buildListTile(
context,
Icons.calendar_today,
'분기별 보기',
QuarterlyBookListScreen(),
),
buildListTile(
context,
Icons.category,
'분야별 보기',
CategoryBookListScreen(),
),
buildListTile(
context,
Icons.stars,
'다독자 랭킹',
BookwormScreen(), // TODO: 네비게이션 추가
),
buildListTile(
context,
Icons.how_to_vote,
'책 투표하기',
VoteNextBookScreen(), // TODO: 네비게이션 추가
),
],
),
);
}
Widget loginDrawerHeader(BuildContext context, DocumentSnapshot snapshot) {
return UserAccountsDrawerHeader(
currentAccountPicture: const CircleAvatar(
backgroundImage: AssetImage('assets/app_logo.png'),
),
accountName: Text(snapshot['username']),
accountEmail: Container(
child: Row(
children: [
Text(snapshot.id),
Spacer(),
Container(
height: 30,
child: TextButton(
onPressed: () {
FirebaseAuth.instance.signOut();
Navigator.pushNamed(context, '/main');
} ,
child: Text(
"로그아웃",
style: const TextStyle(
fontSize: 10,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
color: Colors.white,
decoration: TextDecoration.underline,
decorationColor: Colors.white,
),
),
),
),
],
),
),
);
}
DrawerHeader logoutDrawerHeader(
BuildContext context, double widthRatio, double heightRatio) {
return DrawerHeader(
child: Container(
alignment: Alignment.topLeft,
child: Row(
children: [
OutlinedButton(
onPressed: () => Navigator.pushNamed(context, '/login'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
),
child: Text(
"로그인",
style: const TextStyle(
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
OutlinedButton(
onPressed: () => Navigator.pushNamed(context, '/signup'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
),
child: const Text(
"회원가입",
style: TextStyle(
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Future<dynamic> getCurrentUser() async {
final User? user = FirebaseAuth.instance.currentUser;
if (user != null) {
final DocumentSnapshot documentSnapshot = await FirebaseFirestore.instance
.collection('users')
.doc(user.email)
.get();
return documentSnapshot;
} else {
return '';
}
}
Widget buildListTile(
BuildContext context, IconData icon, String title, dynamic destination) {
return ListTile(
leading: Icon(icon),
title: Text(
title,
style: const TextStyle(
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
),
),
onTap: () {
if (destination != null) {
if (destination is String) {
Navigator.pushNamed(context, destination);
} else if (destination is Widget) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => destination),
);
}
}
},
trailing: const Icon(Icons.navigate_next),
);
}
사용자 인증은 아래 코드에서 Firebase Authentication
을 사용하여 현재 사용자를 가져오고, 해당 사용자의 정보를 Firestore
에서 가져오고, 사용자가 로그인 상태인지에 따라 다른 DrawerHeader
를 보여주게 됩니다. Firebase Authentication
에 대해서는 다음 글에서 더 자세히 다루도록 하겠습니다.
Future<dynamic> getCurrentUser() async {
final User? user = FirebaseAuth.instance.currentUser;
if (user != null) {
final DocumentSnapshot documentSnapshot = await FirebaseFirestore.instance
.collection('users')
.doc(user.email)
.get();
return documentSnapshot;
} else {
return '';
}
}
이를 통해 Flutter 앱의 Drawer
를 커스터마이징하여 사용자 인증 기능을 추가하고, Navigator
를 설정해 보았습니다.
nav_widget
nav_widget
함수는 Flutter 앱에서 사용자가 특정 화면으로 이동할 수 있는 네비게이션 위젯을 구현한 함수입니다. SingleChildScrollView(scrollDirection: Axis.horizontal, ...)
를 활용해 수평 스크롤 공간 위젯을 만들었습니다. 또한, 특정 가로 공간 내에서 여러 개의 네비게이션 아이템을 표시했고 각 아이템을 클릭할 때 해당하는 화면으로 이동할 수 있도록 하였습니다.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:military_bookstore/screen/bookworm_rank_screen.dart';
import 'package:military_bookstore/screen/category_bookList_screen.dart';
import 'package:military_bookstore/screen/quarterly_bookList_screen.dart';
import 'package:military_bookstore/screen/vote_next_book_screen.dart';
SingleChildScrollView nav_widget(BuildContext context){
double deviceWidth = MediaQuery.of(context).size.width;
double deviceHeight = MediaQuery.of(context).size.height;
double widthRatio = deviceWidth / 375;
double heightRatio = deviceHeight / 812;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap:() {
Navigator.pushNamed(context, '/main');
},
child: Container(
width: widthRatio * 60,
height: heightRatio * 42,
padding: const EdgeInsets.all(10),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'Home',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xD8444444),
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
),
),
),
),),
GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) => QuarterlyBookListScreen()
)
);
},
child: Container(
width: widthRatio * 92,
height: heightRatio * 42,
padding: const EdgeInsets.all(10),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'분기별 보기',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xD8444444),
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
),
),
),
),
),
GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) => CategoryBookListScreen()
)
);
},
child: Container(
width: widthRatio * 93,
height: heightRatio * 38,
padding: const EdgeInsets.all(10),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'분야별 보기',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xD8444444),
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
),
),
),
),
),
GestureDetector(
child: Container(
width: widthRatio * 91,
height: heightRatio * 42,
padding: const EdgeInsets.all(10),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'책벌레 랭킹',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xD8444444),
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
),
),
),
),
onTap: (){
Navigator.push(context, MaterialPageRoute(
builder: (context) => BookwormScreen()
)
);
}
),
GestureDetector(
child: Container(
width: widthRatio * 76,
height: heightRatio * 42,
padding: const EdgeInsets.all(10),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'투표하기',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xD8444444),
fontSize: 15,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
),
),
),
),
onTap:() {
Navigator.push(context, MaterialPageRoute(
builder: (context) => VoteNextBookScreen()
),
);
},
),
Container(
width: widthRatio * 5,
height: heightRatio * 42,
),
],
),
);
}
코드에서 cloud_firestore
패키지를 사용하여 Firebase Firestore와 통신합니다. 아래는 Firestore에서 쿼터별 도서 목록을 가져오는 부분입니다.
Future<Map<String,dynamic>> getQuarterlyBookList() async{
/// JinJoongBookList collection을 참조하는 Querysnapshot을 통해 data 가져옴
Map<String, dynamic> quarterlyList = {};
QuerySnapshot<Map<String, dynamic>> querySnapshot =
await FirebaseFirestore.instance.collection("JinJoongBookList").get();
/// 여기서 doc은 docRef.get()한 것과 같은 상태
querySnapshot.docs.forEach((doc) => quarterlyList[doc.id] = doc.data());
return quarterlyList;
}
아래는 Firestore
의 데이터 구조입니다. JinJoongBookList collection
는 각 년도에 해당하는 id
의 document
들을 갖고 있습니다. 또한 각 document
의 data는 1Q
부터 4Q
까지를 키 값으로 갖고 각 분기의 책 목록들을 배열(리스트)로 갖는, Map<String, List<String>> 타입
으로 구성했습니다.
FutureBuilder를 통해 getQuarterlyBookList()의 반환값을 snapshot.data
로 받아올 수 있습니다. yearWithQuarter
는 snapshot.data
를 활용하여 모든 분기들을 '년도 분기' ('2023Y 4Q') 와 같은 형식의 문자열로 구성된 배열로 만든 것입니다. (ex. [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---]) yearWithQuarter
에서 가장 최신 분기 문자열을 가져와서 년도와 분기를 ' '으로 split하여 snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]
로 해당 분기의 도서 목록을 quarterlyBookList
에 가져왔습니다. getQuarterlyBookList()
함수의 실행 결과를 기다린 후, if(snapshot.hasData)
조건문을 활용하여 만약 받아온 데이터가 아직 없는 경우 CircularProgressIndicator()
를 반환하도록 하여 로딩 스피너를 표시하였습니다.
// ...
FutureBuilder(
future: getQuarterlyBookList(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var yearList = snapshot.data!.keys.toList();
var recentYear = yearList.last;
/// yearList에 각 쿼터 붙인 리스트들 만들기
/// [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---],
List<dynamic> yearWithQuarter = snapshot
.data![recentYear].keys
.map((key) => '$recentYear $key')
.toList();
var firstElement = yearWithQuarter.removeAt(0);
yearWithQuarter.add(firstElement);
var recentQuarter = yearWithQuarter[0];
List<dynamic> yearAndQuarter = recentQuarter.split(' ');
List<String> quarterlyBookList = List<String>.from(
snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
} else {
// 데이터가 없는 경우 로딩 스피너를 표시
return CircularProgressIndicator();
}
},
),
위 firebase console
사진과 같이, developerPickedBooks
collection을 책 제목을 id로 갖는 문서들로 구성하였습니다.
아래 코드는 개발자가 선택한 도서 목록을 가져와 화면에 표시하는 부분입니다. FutureBuilder
를 사용하여 비동기적으로 도서 목록을 가져온 후, 데이터의 상태에 따라 다른 화면을 표시합니다. 데이터를 가져오는 동안에는 로딩 스피너를 표시하고, 데이터를 성공적으로 가져오면 개발자가 선택한 도서 목록을 화면에 표시합니다.
Future<List<String>> getDevPicks() async {
QuerySnapshot devPicksCollecion = await FirebaseFirestore.instance
.collection("developerPickedBooks")
.get();
List<String> devPickList = [];
devPicksCollecion.docs.forEach((doc) => devPickList.add(doc.id));
return devPickList;
}
//...
// Dev's Pick 책 목록을 표시하는 부분
FutureBuilder(
future: getDevPicks(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// 데이터를 가져오는 중인 경우 로딩 스피너 표시
return CircularProgressIndicator();
} else if (snapshot.hasError) {
// 데이터 가져오기 실패 시 에러 메시지 표시
return Text('Error: ${snapshot.error}');
} else {
// 가져온 데이터를 기반으로 도서 목록 표시
return Padding(
padding: EdgeInsets.only(left: 0, right: 15),
child: Row(
children: snapshot.data!.map((bookTitle) => Container(
// 각 도서를 나타내는 컨테이너
// ...
)).toList(),
),
);
}
},
),
아래 코드는 화면의 도서 목록에서 각 아이템을 표시하는 부분입니다. GestureDetector
를 사용하여 아이템을 누를 경우 해당 도서의 상세 화면(Detail Screen)
으로 이동하도록 구현되어 있습니다. 각 아이템은 ListTile
과 같은 형태로 구성되어 있으며, 사용자의 클릭에 반응하여 상세 정보를 보여줍니다.
// 도서 목록 아이템을 표시하는 부분
Widget lstItem(List<String> bookList, int index, widthRatio, heightRatio) {
return GestureDetector(
onTap: () {
// 아이템을 누를 때 도서 상세 화면으로 이동
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookDetailScreen(
bookTitle: bookList[index],
),
),
);
},
child: Container(
// 아이템 내용 및 스타일링
// ...
),
);
}
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:military_bookstore/database/get_aladin_database.dart';
import 'package:military_bookstore/database/get_database_Info.dart';
import 'package:military_bookstore/screen/book_detail_screen.dart';
import 'package:military_bookstore/widget/appbar_widget.dart';
import 'package:military_bookstore/widget/drewer_widget.dart';
import 'package:military_bookstore/widget/nav_widget.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
void initState() {
super.initState();
}
Widget build(BuildContext context) {
double deviceWidth = MediaQuery.of(context).size.width;
double deviceHeight = MediaQuery.of(context).size.height;
double widthRatio = deviceWidth / 375;
double heightRatio = deviceHeight / 812;
return Scaffold(
appBar: appbar_widget(context),
drawer: drewer_widget(context),
body: Center(
child: Container(
clipBehavior: Clip.antiAlias,
/// 배경색상 설정
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(-0.00, -1.00),
end: Alignment(0, 1),
colors: [Color(0xA545B0C5), Color(0xFF4580C5), Color(0xFF4580C5)],
),
),
child: FutureBuilder(
future: getQuarterlyBookList(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var yearList = snapshot.data!.keys.toList();
var recentYear = yearList.last;
/// yearList에 각 쿼터 붙인 리스트들 만들기
/// [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---],
List<dynamic> yearWithQuarter = snapshot
.data![recentYear].keys
.map((key) => '$recentYear $key')
.toList();
var firstElement = yearWithQuarter.removeAt(0);
yearWithQuarter.add(firstElement);
var recentQuarter = yearWithQuarter[0];
List<dynamic> yearAndQuarter = recentQuarter.split(' ');
List<String> quarterlyBookList = List<String>.from(
snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
return CustomScrollView(
slivers: [
// 네비게이션바
SliverPadding(
padding: EdgeInsets.only(
top: heightRatio * 10,
),
sliver: SliverToBoxAdapter(
child: nav_widget(context),
),
),
// 구분선
SliverList(
delegate: SliverChildListDelegate([
// 구분선
Container(
margin: EdgeInsets.only(
top: heightRatio * 11,
left: widthRatio * 10,
right: widthRatio * 10,
),
width: widthRatio * 355,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
width: widthRatio * 3,
strokeAlign: BorderSide.strokeAlignCenter,
color: Colors.white.withOpacity(0.5),
),
),
),
),
])),
/// Dev's Pick !
SliverList(
delegate: SliverChildListDelegate([
/// Dev's Pick !
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(0),
margin: EdgeInsets.only(
left: widthRatio * 12,
top: heightRatio * 20,
),
width: widthRatio * 23,
height: heightRatio * 23,
child: SvgPicture.asset(
'assets/main_screen_icon/star.svg',
),
),
Container(
margin: EdgeInsets.only(
top: heightRatio * 20,
),
width: widthRatio * 85,
height: heightRatio * 23,
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
"Dev's Pick !",
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
),
),
],
),
])),
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: FutureBuilder(
future: getDevPicks(),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return Padding(
padding:
EdgeInsets.only(left: 0, right: 15),
child: Row(
/// Dev's Pick 책 목록의 요소를 하나씩 꺼냄
children: snapshot.data!
.map((bookTitle) => Container(
margin: EdgeInsets.only(
left: widthRatio * 15,
top: heightRatio * 15,
),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.5),
blurRadius: 4,
offset: Offset(2, 4),
spreadRadius: 1,
)
],
),
child: FutureBuilder(
future:
getBookImage(bookTitle),
builder:
(BuildContext context,
AsyncSnapshot<Image>
snapshot) {
if (snapshot
.connectionState ==
ConnectionState
.waiting) {
return CircularProgressIndicator();
} else if (snapshot
.hasError) {
return Text(
'Error: ${snapshot.error}');
} else {
return GestureDetector(
onTap:(){
Navigator.push(context,MaterialPageRoute(builder: (context) => BookDetailScreen(bookTitle: bookTitle)));
},
child: Image(
image: snapshot
.data!.image,
width: widthRatio * 105,
height: widthRatio *
105 *
1.48,
fit: BoxFit.fill,
),
);
}
},
),
))
///각 컨테이너 객체들을 children 리스트로 만듦
.toList(),
),
);
}
}),
),
),
// 구분선
SliverList(
delegate: SliverChildListDelegate([
// 구분선
Container(
margin: EdgeInsets.only(
top: heightRatio * 20,
left: widthRatio * 10,
right: widthRatio * 10,
),
width: widthRatio * 355,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
width: widthRatio * 3,
strokeAlign: BorderSide.strokeAlignCenter,
color: Colors.white.withOpacity(0.5),
),
),
),
),
])),
SliverList(
delegate: SliverChildListDelegate([
Row(
children: [
Container(
padding: EdgeInsets.all(0),
margin: EdgeInsets.only(
left: widthRatio * 13,
top: heightRatio * 20,
),
width: widthRatio * 23,
height: heightRatio * 23,
child: SvgPicture.asset(
"assets/main_screen_icon/white_calender.svg"),
),
Container(
margin: EdgeInsets.only(
left: widthRatio * 5,
top: heightRatio * 20,
right: widthRatio * 240,
),
width: widthRatio * 71,
height: heightRatio * 23,
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
recentQuarter,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
)),
],
),
])),
SliverPadding(
padding: EdgeInsets.only(
top: heightRatio * 20,
left: widthRatio * 13,
right: widthRatio * 13),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return lstItem(quarterlyBookList, index,
widthRatio, heightRatio);
},
childCount: quarterlyBookList.length,
),
),
),
],
);
} else {
return CustomScrollView(
slivers: [
// 네비게이션바
SliverPadding(
padding: EdgeInsets.only(
top: heightRatio * 10,
),
sliver: SliverToBoxAdapter(
child: nav_widget(context),
),
),
// 구분선
SliverList(
delegate: SliverChildListDelegate([
// 구분선
Container(
margin: EdgeInsets.only(
top: heightRatio * 11,
left: widthRatio * 10,
right: widthRatio * 10,
),
width: widthRatio * 355,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
width: widthRatio * 3,
strokeAlign: BorderSide.strokeAlignCenter,
color: Colors.white.withOpacity(0.5),
),
),
),
),
])),
SliverToBoxAdapter(
child: CircularProgressIndicator(),
),
],
);
}
}),
),
),
);
}
Widget lstItem(List<String> bookList, int index, widthRatio, heightRatio) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookDetailScreen(
bookTitle: bookList[index],
)),
);
},
child: Container(
margin: EdgeInsets.only(bottom: heightRatio * 20),
padding: EdgeInsets.symmetric(
vertical: heightRatio * 10, horizontal: widthRatio * 10),
decoration: ShapeDecoration(
color: Colors.white.withOpacity(0),
shape: RoundedRectangleBorder(
side: BorderSide(width: 2, color: Color(0xBFFFFFFF)),
borderRadius: BorderRadius.circular(20),
),
),
child: Container(
margin: EdgeInsets.only(left: widthRatio * 5),
child: Row(
children: [
FutureBuilder(
future: getBookImage(bookList[index]),
builder: (BuildContext context, AsyncSnapshot<Image> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Image.asset(
'assets/app_logo.png',
);
} else {
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Color(0x3F000000),
blurRadius: 5,
offset: Offset(2, 4),
spreadRadius: 3,
)
],
),
child: Image(
image: snapshot.data!.image,
width: widthRatio * 105,
height: widthRatio * 105 * 1.48,
fit: BoxFit.fill,
),
);
}
},
),
Container(
margin: EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
margin: EdgeInsets.only(bottom: heightRatio * 20),
constraints: BoxConstraints(
maxWidth: 200.0,
),
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
bookList[index],
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontSize: 18,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
)),
],
),
FutureBuilder(
future: getAladinDescription(bookList[index]),
builder: (BuildContext context,
AsyncSnapshot<String> snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text(
'책소개를 불러오는데 실패했습니다.',
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
);
} else {
return Container(
width: widthRatio * 190,
height: heightRatio * 101,
child: Text(
snapshot.data!,
maxLines: 4,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
);
}
},
),
],
),
),
],
),
),
),
);
}
Future<List<String>> getDevPicks() async {
QuerySnapshot devPicksCollecion = await FirebaseFirestore.instance
.collection("developerPickedBooks")
.get();
List<String> devPickList = [];
devPicksCollecion.docs.forEach((doc) => devPickList.add(doc.id));
return devPickList;
}
}