이번 글에서는 진중문고 분야별 책 목록 화면을 구현한 방법에 대해 알아보겠습니다. 코드를 하나씩 살펴보면서 각 부분의 역할과 기능을 자세히 설명하겠습니다.
QuarterlyBookListScreen
는 Firestore
에 분기별로 저장된 진중문고 도서 리스트를 드롭다운 버튼을 통해 선택한 분기의 도서 리스트를 화면에 출력하는 클래스입니다.
아래 코드는 QuarterlyBookListScreen 위젯과 해당 위젯의 상태 클래스 _CategoryBookListScreenState를 정의합니다. selectedValue 변수는 사용자가 선택한 분기를 저장합니다. initState() 메서드에서 selectedValue의 초기 값을 '2023Y 4Q'으로 설정합니다.
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 QuarterlyBookListScreen extends StatefulWidget {
const QuarterlyBookListScreen({super.key});
State<QuarterlyBookListScreen> createState() =>
_QuarterlyBookListScreenState();
}
class _QuarterlyBookListScreenState extends State<QuarterlyBookListScreen> {
String? selectedValue;
late List<String> yearAndQuarter;
void initState() {
super.initState();
selectedValue = "2023Y 4Q";
yearAndQuarter = selectedValue!.split(' ');
}
/// setState 함수가 다시 실행되면 build가 다시 됨
Widget build(BuildContext context) {
/// 나머지 코드 (이하 생략)
}
Scaffold 위젯을 사용하여 앱의 기본적인 레이아웃을 구성합니다. FutureBuilder를 사용하여 비동기적으로 데이터를 가져와서 화면을 구성합니다. 데이터가 로드되면 해당 데이터를 사용하여 화면을 구성하고, 로딩 중에는 로딩 화면을 표시합니다.
build(BuildContext context) {
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: getCategoryBookList(),
builder: (context, snapshot) {
if (snapshot.hasData) {
// 데이터가 있을 때의 화면 구성
} else {
// 데이터를 기다리는 동안의 로딩 화면
}
},
),
),
),
);
}
Widget
getQuarterlyBookList()
함수는 Firestore
의 JinJoongBookList collection
의 문서 id(책 분야 종류) 정보를 가져와, Map<Map<String, List>> type의 각 문서 정보를 리스트 요소로 갖는 quarterlyList를 리턴합니다.
ex.{'2019Y' : {1Q: [---], 2Q: [---], 3Q: [---]}, '2020Y' : {1Q: [---], 2Q: [---], }, }
Future<Map<String,dynamic>> getQuarterlyBookList() async{
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
의 데이터 구조를 보면 문서 id
가 년도고 해당 문서의 data 필드의 키 값이 1Q ~ 4Q로 구성되어 있습니다. 분기별 책 목록 화면에서 드롭다운 버튼은 년도와 분기가 합쳐진 2023Y 1Q
와 같은 형식으로 item을 구성하기 위해 yearWithQuarter를 만들었습니다.
yearWithQuarter = [[2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---], ...]
quarterList
는 yearWithQuarter
의 요소들을 expand
를 통해 하나로 합친 List
이고, yearAndQuarter
현재 선택된 selectedValue
를 ' '
으로 split
한 List
입니다.
yearAndQuarter[0]
는 현재 선택된 값의 년도를, yearAndQuarter[1]
는 현재 선택된 값의 분기를 나타냅니다. 따라서 snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]
는 위 3번에서 getQuarterlyBookList()
를 통해 가져온 {'2019Y' : {1Q: [---], 2Q: [---], 3Q: [---]}, '2020Y' : {1Q: [---], 2Q: [---], }, }
형식의 데이터에서, yearAndQuarter[0]
에 해당하는 년도의 yearAndQuarter[1]
번째 분기 도서 List를 가져오는 것입니다.
FutureBuilder(
future: getQuarterlyBookList(),
builder: (context, snapshot) {
if(snapshot.hasData){
List<dynamic> yearList = snapshot.data!.keys.toList();
/// yearList에 각 쿼터 붙인 리스트들 만들기
List<dynamic> yearWithQuarter =
yearList.map((year) => snapshot.data![year].keys.map((key) => '$year $key').toList()).toList();
yearWithQuarter.forEach((list){
var firstElement = list.removeAt(0);
list.add(firstElement);
});
List<dynamic> sortedYearWithQuarter = yearWithQuarter.reversed.toList();
/// .expand는 map과 유사하지만 각 요소에 함수를 적용한 결과가 단일 값이 아니라 리스트인 경우
/// [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q, 2020Y 1Q, 2020Y 2Q, ---]
List<dynamic> quarterList = sortedYearWithQuarter.expand((list) => list).toList();
List<String> quarterlyBookList =
List<String>.from(snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
return Column(
/// 나머지 코드 (이하 생략)
)
}
)
4번에서 만든 quarterList
의 List
를 items
로 만들어 드롭다운 버튼을 구현했다.
Container(
width: widthRatio * 110,
height: heightRatio * 28,
margin: EdgeInsets.only(top: heightRatio * 3),
padding: EdgeInsets.only(left: widthRatio * 10),
decoration: ShapeDecoration(
color: Color(0xFF46B0C6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
alignment: Alignment.center,
child: DropdownButton<String>(
value: selectedValue,
onChanged: (String? newValue) {
setState(() {
selectedValue = newValue;
yearAndQuarter = selectedValue!.split(' ');
quarterlyBookList = List<String>.from(snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
});
},
items: quarterList.map<DropdownMenuItem<String>>((var value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.w700,
height: 0,
letterSpacing: -0.35,
),
dropdownColor: Color(0xFF46B0C6),
underline: Container(),
icon: Icon(
Icons.arrow_drop_down, // 드롭다운 아이콘 지정
color: Colors.white, // 아이콘 색상 설정
),
),
),
),
quarterlyBookList
는 4번에서 설명드린 바와 같이, yearAndQuarter[0]
에 해당하는 년도의 yearAndQuarter[1]
번째 분기 도서 List를 가져오는 것입니다. 따라서 이 특정 분기의 도서 목록들을 lstItem()을 통해 화면에 출력하는 것입니다.
Container(
margin: EdgeInsets.only(top: heightRatio * 18),
width: widthRatio * 350,
height: heightRatio * 550,
child: SingleChildScrollView(
child: Column(
children: List.generate(
quarterlyBookList.length,
(index) => lstItem(quarterlyBookList, index, widthRatio, heightRatio)
),
),
),
),
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 QuarterlyBookListScreen extends StatefulWidget {
const QuarterlyBookListScreen({super.key});
State<QuarterlyBookListScreen> createState() =>
_QuarterlyBookListScreenState();
}
class _QuarterlyBookListScreenState extends State<QuarterlyBookListScreen> {
String? selectedValue;
late List<String> yearAndQuarter;
void initState() {
super.initState();
selectedValue = "2023Y 4Q";
yearAndQuarter = selectedValue!.split(' ');
}
/// setState 함수가 다시 실행되면 build가 다시 됨
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(
/// {2019Y : {1Q: [---], 2Q: [---], 3Q: [---]}. 2020Y : {1Q: [], 2Q: [], } }
future: getQuarterlyBookList(),
builder: (context, snapshot) {
if(snapshot.hasData){
List<dynamic> yearList = snapshot.data!.keys.toList();
/// yearList에 각 쿼터 붙인 리스트들 만들기
/// [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---],
List<dynamic> yearWithQuarter = yearList.map((year) => snapshot.data![year].keys.map((key) => '$year $key').toList()).toList();
yearWithQuarter.forEach((list){
var firstElement = list.removeAt(0);
list.add(firstElement);
});
List<dynamic> sortedYearWithQuarter = yearWithQuarter.reversed.toList();
/// .expand는 map과 유사하지만 각 요소에 함수를 적용한 결과가 단일 값이 아니라 리스트인 경우
/// [2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q, 2020Y 1Q, 2020Y 2Q, ---]
List<dynamic> quarterList = sortedYearWithQuarter.expand((list) => list).toList();
List<String> quarterlyBookList = List<String>.from(snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
return Column(
children: [
nav_widget(context),
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),
),
),
),
),
Container(
margin: EdgeInsets.only(
top: heightRatio * 20,
),
child: Row(
children: [
Container(
margin: EdgeInsets.only(
left: widthRatio * 18,
),
width: widthRatio * 23,
height: heightRatio * 23,
child: SvgPicture.asset(
"assets/main_screen_icon/white_calender.svg"),
),
Container(
margin: EdgeInsets.only(
left: widthRatio * 8,
right: widthRatio * 107,
),
width: widthRatio * 91,
height: heightRatio * 23,
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'분기별 책 목록',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
)),
Container(
width: widthRatio * 110,
height: heightRatio * 28,
margin: EdgeInsets.only(top: heightRatio * 3),
padding: EdgeInsets.only(left: widthRatio * 10),
decoration: ShapeDecoration(
color: Color(0xFF46B0C6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
alignment: Alignment.center,
child: DropdownButton<String>(
value: selectedValue,
onChanged: (String? newValue) {
setState(() {
selectedValue = newValue;
yearAndQuarter = selectedValue!.split(' ');
quarterlyBookList = List<String>.from(snapshot.data![yearAndQuarter[0]][yearAndQuarter[1]]);
});
},
items: quarterList.map<DropdownMenuItem<String>>((var value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontFamily: 'GowunBatang',
fontWeight: FontWeight.w700,
height: 0,
letterSpacing: -0.35,
),
dropdownColor: Color(0xFF46B0C6),
underline: Container(),
icon: Icon(
Icons.arrow_drop_down, // 드롭다운 아이콘 지정
color: Colors.white, // 아이콘 색상 설정
),
),
),
],
),
),
Container(
margin: EdgeInsets.only(top: heightRatio * 18),
width: widthRatio * 350,
height: heightRatio * 550,
child: SingleChildScrollView(
child: Column(
children: List.generate(
quarterlyBookList.length,
(index) =>
lstItem(quarterlyBookList, index, widthRatio, heightRatio)),
),
),
)
],
);
}else{
return Column(
children: [
nav_widget(context),
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),
),
),
),
),
Container(
margin: EdgeInsets.only(
top: heightRatio * 20,
),
child: Row(
children: [
Container(
margin: EdgeInsets.only(
left: widthRatio * 18,
),
width: widthRatio * 23,
height: heightRatio * 23,
child: SvgPicture.asset(
"assets/main_screen_icon/white_calender.svg"),
),
Container(
margin: EdgeInsets.only(
left: widthRatio * 8,
right: widthRatio * 107,
),
width: widthRatio * 91,
height: heightRatio * 23,
child: FittedBox(
fit: BoxFit.fitWidth,
child: Text(
'분기별 책 목록',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xE5001F3F),
fontFamily: 'GowunBatang',
fontWeight: FontWeight.bold,
height: 0,
letterSpacing: -0.40,
),
),
)),
],
),
),
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',width: widthRatio * 105,fit: BoxFit.fill
);
} 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,
),
),
);
}
},
),
],
),
),
],
),
),
),
);
}
}