[Flutter] Flutter 앱의 분기별 책 목록 화면 구현하기: QuarterlyBookListScreen 클래스

StudipU·2024년 3월 3일
0

이번 글에서는 진중문고 분야별 책 목록 화면을 구현한 방법에 대해 알아보겠습니다. 코드를 하나씩 살펴보면서 각 부분의 역할과 기능을 자세히 설명하겠습니다.

QuarterlyBookListScreen 클래스 소개 ✨

QuarterlyBookListScreenFirestore에 분기별로 저장된 진중문고 도서 리스트를 드롭다운 버튼을 통해 선택한 분기의 도서 리스트를 화면에 출력하는 클래스입니다.

주요 기능 및 코드 분석 🎭

1. selectedValue 변수

아래 코드는 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) {
  
  /// 나머지 코드 (이하 생략)
}

2. 화면 구성 요소

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 {
              // 데이터를 기다리는 동안의 로딩 화면
            }
          },
        ),
      ),
    ),
  );
}

3. 분기별 도서 데이터 가져오기

getQuarterlyBookList() 함수는 FirestoreJinJoongBookList 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;
}

4. 드롭다운에 표시할 분기 리스트 만들기

Firestore의 데이터 구조를 보면 문서 id가 년도고 해당 문서의 data 필드의 키 값이 1Q ~ 4Q로 구성되어 있습니다. 분기별 책 목록 화면에서 드롭다운 버튼은 년도와 분기가 합쳐진 2023Y 1Q와 같은 형식으로 item을 구성하기 위해 yearWithQuarter를 만들었습니다.

yearWithQuarter = [[2019Y 1Q, 2019Y 2Q, 2019Y 3Q, 2019Y 4Q], [2020Y 1Q, 2020Y 2Q, ---], ...]

quarterListyearWithQuarter의 요소들을 expand를 통해 하나로 합친 List이고, yearAndQuarter 현재 선택된 selectedValue' '으로 splitList입니다.

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(
	/// 나머지 코드 (이하 생략)
	)
  }
)

5. 드롭다운 버튼 구현

4번에서 만든 quarterListListitems로 만들어 드롭다운 버튼을 구현했다.

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, // 아이콘 색상 설정
      ),
    ),
  ),
),

6. 도서 리스트 구현

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)
      ),
    ),
  ),
),

QuarterlyBookListScreen 클래스 전체 코드 🎍

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,
                              ),
                            ),
                          );
                        }
                      },
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
profile
컴공 대학생이 군대에서 작성하는 앱 개발 블로그

0개의 댓글