Dandy Now!
  • [Flutter] "모두가 할 수 있는 플러터 UI 입문" - 모두의숙소 웹 만들기 | 플러터 웹 | 빌드 실패 문제
    2022년 02월 08일 10시 24분 52초에 업로드 된 글입니다.
    작성자: DandyNow
    728x90
    반응형

    "최주호, 정호준, & 정동진. (2021). 모두가 할 수 있는 플러터 UI 입문. 앤써북"으로 플러터 공부를 하고 있다. 9장에서는 모두의숙소 웹을 만들었다. 플로터로 웹도 만들 수 있다. [그림 1]은 완성된 페이지이다.

     

    [그림 1] 완성된 모두의숙소 웹

     

    앱으로도 빌드를 시도했는데 [그림 2]와 같은 에러가 발생했다. 최신의 Kotlin Gradle plugin이 필요하다고 한다. 기존에 만들었던 앱 프로젝트를 빌드해봐도 동일한 현상이 발생했기 때문에 이번에 작성한 코드의 문제는 아닌 것으로 보인다. [그림 2]는 VS Code에서 보여주는 에러 메시지이며, 안드로이드 스튜디오에도 역시 빌드되지 않았다. 이렇게 저렇게 만져 보았지만 아직은 해결방법을 찾지 못했다(2022년 02월 09일 해결함).

    [!] Your project requires a newer version of the Kotlin Gradle plugin.

    [그림 2] 안드로이드 앱 빌드 실패

     

    다른 PC에서 GitHub에 Push된 동일한 코드를 Pull 하여 실행해 보았다. [그림 3]과 같이 AVD에서 정상적으로 실행되었다. 하지만 기존 PC에서는 위 문제가 해결되지 않고 있다.

     

    [그림 3] 완성된 모두의숙소 앱

     


     

    // main.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/pages/home_page.dart';
    
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: HomePage(),
        );
      }
    }

     

    // constants.dart
    import 'package:flutter/material.dart';
    
    // 색상 정의
    const kAccentColor = Color(0xFFFF385C);

     

    // size.dart
    import 'package:flutter/material.dart';
    
    // 간격
    const double gap_xl = 40;
    const double gap_l = 30;
    const double gap_m = 20;
    const double gap_s = 10;
    const double gap_xs = 5;
    
    // 헤더 높이
    const double header_height = 620;
    
    // MediaQuery 클래스로 화면 사이즈를 받을 수 있다.
    double getBodyWidth(BuildContext context) {
      return MediaQuery.of(context).size.width * 0.7;
    }

     

    // styles.dart
    import 'package:flutter/material.dart';
    
    TextStyle h4({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 34, fontWeight: FontWeight.bold, color: mColor);
    }
    
    TextStyle h5({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: mColor);
    }
    
    TextStyle subtitle1({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: mColor);
    }
    
    TextStyle subtitle2({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: mColor);
    }
    
    TextStyle overLine({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: mColor);
    }
    
    TextStyle body1({Color mColor = Colors.black}) {
      return TextStyle(fontSize: 16, color: mColor);
    }

     

    // pages/home_page.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/components/home/home_body.dart';
    import 'package:flutter_airbnb/components/home/home_header.dart';
    
    class HomePage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: ListView(
            children: [
              HomeHeader(),
              HomeBody(),
            ],
          ),
        );
      }
    }

     

     

    // common/common_form_field.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/styles.dart';
    
    class CommonFormField extends StatelessWidget {
      final prefixText;
      final hintText;
    
      const CommonFormField({required this.prefixText, required this.hintText});
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            TextFormField(
              textAlignVertical: TextAlignVertical.bottom,
              decoration: InputDecoration(
                // TextFormField 내부에 패딩을 줄 수 있다.
                contentPadding: EdgeInsets.only(top: 30, left: 20, bottom: 10),
                hintText: hintText,
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(10),
                ),
                focusedBorder: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(10),
                  borderSide: BorderSide(
                    color: Colors.black,
                    width: 2,
                  ),
                ),
              ),
            ),
            // Positioned를 사용한 이유는 TextFormField 공간에 글자를 삽입하기 위해서 이다.
            Positioned(
              top: 8,
              left: 20,
              child: Text(
                prefixText,
                style: overLine(),
              ),
            ),
          ],
        );
      }
    }

     

     

    // home/home_header.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/components/home/home_header_appbar.dart';
    import 'package:flutter_airbnb/components/home/home_header_form.dart';
    import 'package:flutter_airbnb/size.dart';
    
    class HomeHeader extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return SizedBox(
          width: double.infinity,
          height: header_height,
          child: Container(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: AssetImage("assets/background.jpg"),
                  fit: BoxFit.cover,
                ),
              ),
              child: Column(
                children: [
                  HomeHeaderAppBar(),
                  HomeHeaderForm(),
                ],
              )),
        );
      }
    }

     

     

    // home/home_header_appbar.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/constants.dart';
    import 'package:flutter_airbnb/size.dart';
    import 'package:flutter_airbnb/styles.dart';
    import 'package:flutter_svg/flutter_svg.dart'; // logo.svg 적용 위해 외부 라이브러리 사용
    
    class HomeHeaderAppBar extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.all(gap_m),
          child: Row(
            children: [
              _buildAppBarLogo(),
              Spacer(),
              _buildAppBarMenu(),
            ],
          ),
        );
      }
    
      Widget _buildAppBarLogo() {
        return Row(
          children: [
            SvgPicture.asset("assets/logo.svg", // logo.svg 적용하였다.
                width: 30,
                height: 30,
                color: kAccentColor),
            SizedBox(width: gap_s),
            Text("RoomOfAll", style: h5(mColor: Colors.white)),
          ],
        );
      }
    
      Widget _buildAppBarMenu() {
        return Row(
          children: [
            // 클릭 이벤트 적용은 InkWell, TextButton 위젯을 사용하면 된다.
            Text("회원가입", style: subtitle1(mColor: Colors.white)),
            SizedBox(width: gap_m),
            Text("로그인", style: subtitle1(mColor: Colors.white)),
          ],
        );
      }
    }

     

     

    // home/home_header_form.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/constants.dart';
    import 'package:flutter_airbnb/size.dart';
    import 'package:flutter_airbnb/styles.dart';
    import 'package:flutter_airbnb/components/common/common_form_field.dart';
    
    class HomeHeaderForm extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        double screenWidth = MediaQuery.of(context).size.width;
        return Padding(
          padding: const EdgeInsets.only(top: gap_m), // AppBar와 간격
          // 정렬 위젯
          child: Align(
            alignment:
                // Alignment(-0.6, 0),
                screenWidth < 520 ? Alignment(0, 0) : Alignment(-0.6, 0), // 변경
            child: Container(
              width: 420,
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(20),
              ),
              child: Form(
                child: Padding(
                    padding: const EdgeInsets.all(gap_l),
                    child: Column(
                      children: [
                        _buildFormTitle(), // Form 위젯 제목 영역
                        _buildFormField(), // Form 위젯 Text 입력 양식 영역
                        _buildFormSubmit(), // Form 위젯 전송 버튼 영역
                      ],
                    )),
              ),
            ),
          ),
        );
      }
    
      Widget _buildFormTitle() {
        return Column(
          children: [
            Text(
              "모두의 숙소에서 숙소를 검색하세요.",
              style: h4(),
            ),
            SizedBox(height: gap_xs),
            Text(
              "기분 좋은 만남, 아름다운 추억, 모두의 숙소와 함께하세요.",
              style: body1(),
            ),
            SizedBox(height: gap_m),
          ],
        );
      }
    
      Widget _buildFormField() {
        return Column(
          children: [
            CommonFormField(
              prefixText: "위치",
              hintText: "근처 추천 장소",
            ),
            SizedBox(height: gap_s),
            Row(
              children: [
                Expanded(
                    child: CommonFormField(
                  prefixText: "체크인",
                  hintText: "날짜 입력",
                )),
                Expanded(
                    child: CommonFormField(
                  prefixText: "체크 아웃",
                  hintText: "날짜 입력",
                )),
              ],
            ),
            SizedBox(height: gap_s),
            Row(
              children: [
                Expanded(
                    child: CommonFormField(
                  prefixText: "성인",
                  hintText: "2",
                )),
                Expanded(
                    child: CommonFormField(
                  prefixText: "어린이",
                  hintText: "0",
                )),
              ],
            ),
            SizedBox(height: gap_m),
          ],
        );
      }
    
      Widget _buildFormSubmit() {
        return SizedBox(
          width: double.infinity,
          height: 50,
          child: TextButton(
            style: TextButton.styleFrom(
                backgroundColor: kAccentColor,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(10),
                )),
            onPressed: () {
              print("서브밋 클릭됨");
            },
            child: Text(
              "검색",
              style: subtitle1(mColor: Colors.white),
            ),
          ),
        );
      }
    }

     

     

    // home/home_body.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/components/home/home_body_banner.dart';
    import 'package:flutter_airbnb/components/home/home_body_popular.dart';
    import 'package:flutter_airbnb/size.dart';
    
    class HomeBody extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        double bodyWidth = getBodyWidth(context);
        // SizedBox 위젯을 가운데 정렬하기 위해 Align을 사용한다(Center 위젯도 가능함).
        return Align(
          child: SizedBox(
            width: bodyWidth, // 화면의 70%만 차지하게 하기 위해 Column의 영역을 강제시킨다.
            child: Column(
              children: [
                HomeBodyBanner(),
                HomeBodyPopular(),
              ],
            ),
          ),
        );
      }
    }

     

     

    // home/home_body_banner.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/size.dart';
    import 'package:flutter_airbnb/styles.dart';
    
    class HomeBodyBanner extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.only(top: gap_m),
          child: Stack(
            children: [
              _buildBannerImage(),
              _buildBannerCaption(),
            ],
          ),
        );
      }
    }
    
    Widget _buildBannerImage() {
      return ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: Image.asset(
          "assets/banner.jpg",
          fit: BoxFit.cover,
          width: double.infinity,
          height: 320,
        ),
      );
    }
    
    Widget _buildBannerCaption() {
      return Positioned(
        top: 40,
        left: 40,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              constraints: BoxConstraints(
                maxWidth: 200,
              ),
              child: Text(
                "이제, 여행은 이탈리아로",
                style: h4(mColor: Colors.white),
              ),
            ),
            SizedBox(height: gap_m),
            Container(
              constraints: BoxConstraints(
                maxHeight: 250,
              ),
              child: Text(
                "새로운 공간에 머물러 보세요. 살아보기, 출장, 여행 등 다양한 목적에 맞는 숙소를 찾아보세요.",
                style: subtitle1(mColor: Colors.white),
              ),
            ),
            SizedBox(height: gap_m),
            SizedBox(
              height: 35,
              width: 170,
              child: TextButton(
                style: TextButton.styleFrom(
                  backgroundColor: Colors.white,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(5),
                  ),
                ),
                onPressed: () {},
                child: Text(
                  "가까운 여행지 둘러보기",
                  style: subtitle2(),
                ),
              ),
            ),
          ],
        ),
      );
    }

     

     

    // home/home_body_popular.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/components/home/home_body_popular_item.dart';
    import 'package:flutter_airbnb/size.dart';
    import 'package:flutter_airbnb/styles.dart';
    
    class HomeBodyPopular extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Padding(
          // 상단에 마진을 준다.
          padding: const EdgeInsets.only(top: gap_m),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              _buildPopularTitle(),
              _buildPopularList(),
            ],
          ),
        );
      }
    
      Widget _buildPopularTitle() {
        return Column(
          children: [
            Text(
              "이탈리아 숙소에 직접 다녀간 게스트의 후기",
              style: h5(),
            ),
            Text(
              "게스트 후기 2,500,000개 이상, 평균 평점 4.7점(5점 만점)",
              style: body1(),
            ),
            SizedBox(height: gap_m),
          ],
        );
      }
    
      Widget _buildPopularList() {
        // 전체 화면사이즈가 1000이라면,
        // _buildPopularList의 넓이는 화면의 70%이므로 700이다.
        // HomeBodyPopularItem의 넓이는 700의 1/3 인 233.33 -5의 크기이므로 228.33이다.
        // 228.33의 인기 아이템 3개가 배치되면 684.99 크기이고 남은 크기는 15.01이 남는다.
        // 그래서 각 HomeBodyPopularItem 위젯 사이에 SizeBox를 7.5 줄 수 있다.
        // Wrap 위젯은 공간이 충분하지 않을 때 Overflow 에러가 발생하지 않도록 하고 다음 줄로 넘어가게 한다(교차축 정렬).
        return Wrap(
          children: [
            // id 값은 사진을 선택하기 위해 필요하다.
            HomeBodyPopularItem(id: 0),
            SizedBox(width: 7.5),
            HomeBodyPopularItem(id: 1),
            SizedBox(width: 7.5),
            HomeBodyPopularItem(id: 2),
          ],
        );
      }
    }

     

     

    // home/home_body_popular_item.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_airbnb/constants.dart';
    import 'package:flutter_airbnb/size.dart';
    import 'package:flutter_airbnb/styles.dart';
    
    class HomeBodyPopularItem extends StatelessWidget {
      final id;
      final popularList = [
        "p1.jpg",
        "p2.jpg",
        "p3.jpg",
      ];
    
      HomeBodyPopularItem({required this.id});
    
      @override
      Widget build(BuildContext context) {
        // 인기 아이템은 전체화면의 70%의 1/3만큼의 사이즈의 -5의 크기를 가진다.
        double popularItemWidth = getBodyWidth(context) / 3 - 5;
        return Padding(
          padding: const EdgeInsets.only(bottom: gap_xl),
          child: Container(
            constraints: BoxConstraints(
              minWidth: 320,
            ),
            child: SizedBox(
              width: popularItemWidth,
              child: Column(
                children: [
                  _buildPopularItemImage(),
                  _buildPopularItemStar(),
                  _buildPopularItemComment(),
                  _buildPopularItemUserInfo(),
                ],
              ),
            ),
          ),
        );
      }
    
      Widget _buildPopularItemImage() {
        return Column(
          children: [
            ClipRRect(
              borderRadius: BorderRadius.circular(10),
              child: Image.asset("assets/${popularList[id]}", fit: BoxFit.cover),
            ),
            SizedBox(height: gap_s),
          ],
        );
      }
    
      Widget _buildPopularItemStar() {
        return Column(
          children: [
            Row(
              children: [
                Icon(Icons.star, color: kAccentColor),
                Icon(Icons.star, color: kAccentColor),
                Icon(Icons.star, color: kAccentColor),
                Icon(Icons.star, color: kAccentColor),
                Icon(Icons.star, color: kAccentColor),
              ],
            ),
            SizedBox(height: gap_s),
          ],
        );
      }
    
      Widget _buildPopularItemComment() {
        return Column(
          children: [
            Text(
              "너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요 너무 좋아요",
              style: body1(),
              maxLines: 3,
    
              overflow: TextOverflow.ellipsis, // 글자가 3 라인을 벗어나면 ... 처리된다.
            ),
            SizedBox(height: gap_s),
          ],
        );
      }
    
      Widget _buildPopularItemUserInfo() {
        return Row(
          children: [
            CircleAvatar(
              backgroundImage: AssetImage("assets/profile.jpg"),
            ),
            SizedBox(width: gap_s),
            Column(
              children: [
                Text(
                  "Sewol",
                  style: subtitle1(),
                ),
                Text("한국"),
              ],
            )
          ],
        );
      }
    }
    728x90
    반응형
    댓글