이번 글에서는 진중문고 분야별 책 목록 화면을 구현한 방법에 대해 알아보겠습니다. 코드를 하나씩 살펴보면서 각 부분의 역할과 기능을 자세히 설명하겠습니다.
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를 사용하여 비동기적으로 데이터를 가져와서 화면을 구성합니다. 데이터가 로드되면 해당 데이터를 사용하여 화면을 구성하고, 로딩 중에는 로딩 화면을 표시합니다.
Widget 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 {
// 데이터를 기다리는 동안의 로딩 화면
}
},
),
),
),
);
}
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;
}
}