이번 글에서는 진중문고 분야별 책 목록 화면
을 구현한 방법에 대해 알아보겠습니다. 코드를 하나씩 살펴보면서 각 부분의 역할과 기능을 자세히 설명하겠습니다.
CategoryBookListScreen
는 Firestore
에 저장된 진중문고 도서 리스트를 분야별로 구분하여 각각의 Category
로 묶은 후, 드롭다운 버튼을 통해 선택한 Category
의 도서 리스트를 화면에 출력하는 클래스입니다.
아래 코드는 CategoryBookListScreen
위젯과 해당 위젯의 상태 클래스 _CategoryBookListScreenState를 정의합니다. selectedValue
변수는 사용자가 선택한 카테고리를 저장합니다. initState()
메서드에서 selectedValue
의 초기 값을 '경제경영'으로 설정합니다.
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 CategoryBookListScreen extends StatefulWidget {
const CategoryBookListScreen({super.key});
State<CategoryBookListScreen> createState() =>
_CategoryBookListScreenState();
}
class _CategoryBookListScreenState extends State<CategoryBookListScreen> {
String? selectedValue;
void initState() {
super.initState();
selectedValue = "경제경영";
}
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
getCategoryBookList()
함수는 Firestore
의 category collection
의 문서 id(책 분야 종류) 정보를 가져와, Map<Map<String, List>> type의 각 문서 정보를 리스트 요소로 갖는 categoryList를 리턴합니다.
[{ '분야1' : {'세부분야1' : [책1, 책2, 책3, ...], } }, { '분야2' : ...}]
Future<Map<String, dynamic>> getCategoryBookList() async {
Map<String, dynamic> categoryList = {};
QuerySnapshot<Map<String, dynamic>> querySnapshot =
await FirebaseFirestore.instance.collection("category").get();
querySnapshot.docs.forEach((doc) => categoryList[doc.id] = doc.data());
return categoryList;
}
위의 getCategoryBookList()
에서 반환받은 리스트에는 대분야 안에 세부분야들이 있고, 해당 세부분야에 대한 도서목록 배열로 구성되어있다. 이 구조에서 대분야 아래에 있는 세부분야들을 없애고 특정 대분야 하의 세부분야에 매핑된 도서배열들을 전부 expend
하여 {'대분야1': [도서1, 도서2],}
와 같은 형식으로 만드는 코드이다. 아무래도 드롭다운 버튼의 항목들을 세부분야로까지 나누기에는 효율성이 떨어진다고 판단했기 때문이다. categoryList
는 totalElement
에서 키값들로 만든 리스트이다.
FutureBuilder(
future: getCategoryBookList(),
builder: (context, snapshot) {
if (snapshot.hasData){
Map<String, dynamic> totalElement = {};
snapshot.data!.forEach((key, value) {
List<dynamic> subElement = value.values.toList();
totalElement[key] = List<String>.from(subElement.expand((list) => list).toList());
});
List<String> categoryList = totalElement.keys.toList();
// 이후 코드는 생략
4번에서 totalElement
를 통해 만든 categoryList
는 모든 대분야들을 요소로 갖는 리스트이고, 이 대분야들을 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;
});
},
items: categoryList.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Container(
constraints: BoxConstraints(
maxWidth: 80.0,
),
child: FittedBox(
fit: BoxFit.fitWidth,
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, // 아이콘 색상 설정
),
),
)
totalElement
는 4번에서 설명한 것과 같이, '대분야'를 key값
으로 갖고 해당 대분야에 속하는 도서들의 List
를 value
값으로 갖는 Map
입니다. 현재 드롭다운 버튼에서 선택된 대분야인 selectedValue
를 이용하여, totalElement[selectedValue]
로 현재 선택된 대분야의 도서들 정보를 가져왔습니다.
Container(
margin: EdgeInsets.only(top: heightRatio * 18),
width: widthRatio * 350,
height: heightRatio * 550,
child: SingleChildScrollView(
child: Column(
children: List.generate(
totalElement[selectedValue]!.length,
(index) => lstItem(totalElement[selectedValue]!, index, widthRatio, heightRatio)),
),
),
)
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 CategoryBookListScreen extends StatefulWidget {
const CategoryBookListScreen({super.key});
State<CategoryBookListScreen> createState() =>
_CategoryBookListScreenState();
}
class _CategoryBookListScreenState extends State<CategoryBookListScreen> {
String? selectedValue;
void initState() {
super.initState();
selectedValue = "경제경영";
}
/// 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(
/// {경제경영 : {경제학-경제일반 : ["스틱",---]} }, {과학: {-:[]} }, }
future: getCategoryBookList(),
builder: (context, snapshot) {
if (snapshot.hasData){
Map<String, dynamic> totalElement = {};
snapshot.data!.forEach((key, value) {
/// 각 분야의 세부 분야가 (경제학-경제일반 : [----]) 형태의 value
/// 이 value에서 values.toList()는 세부분야의 도서들
/// totalElement의 Key는 대분야, Value는 대분야 안의 속한 모든 도서들
List<dynamic> subElement = value.values.toList();
// print(subElement.expand((list) => list).toList().runtimeType);
totalElement[key] = List<String>.from(subElement.expand((list) => list).toList());
});
List<String> categoryList = totalElement.keys.toList();
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;
});
},
items: categoryList.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Container(
constraints: BoxConstraints(
maxWidth: 80.0,
),
child: FittedBox(
fit: BoxFit.fitWidth,
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(
totalElement[selectedValue]!.length,
(index) =>
lstItem(totalElement[selectedValue]!, 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,
),
),
);
}
},
),
],
),
),
],
),
),
),
);
}
Future<Map<String,dynamic>> getCategoryBookList() async{
Map<String, dynamic> categoryList = {};
QuerySnapshot<Map<String, dynamic>> querySnapshot = await FirebaseFirestore.instance.collection("category").get();
/// 여기서 doc은 docRef.get()한 것과 같은 상태
querySnapshot.docs.forEach((doc) => categoryList[doc.id] = doc.data());
return categoryList;
}
}