[flutter] 9주 완성 프로젝트 캠프 과제일지(유데미x스나이퍼팩토리): day10 비밀듣는고양이, font적용

KoEunseo·2023년 10월 5일
0

flutter

목록 보기
18/45

요구사항

제공되는 패키지 secrets_cat_sdk를 활용하여 다음의 기대 결과물을 따라 만드세요.

  • 비밀든는고양이 패키지 : https://pub.dev/packages/secret_cat_sdk
  • 이번 과제는 다음의 결과물과 다른 디자인으로 제작하는데 목표를 두세요.
  • Requirements
    • 앱 이름은 [비밀듣는고양이]가 아닌 다른 이름으로 진행하세요.
    • 앱 내에서 사용될 폰트는 다음과 같습니다.
      • 플러터에 폰트 등록 방법을 검색하여 앱내에 적용할 수 있도록 하세요. neo.ttf
    • 매인 캐릭터 또한 다음의 링크에서 마음에 드는 이미지를 골라서 진행하세요.
    • 페이지들의 배경이미지는 다음의 링크에서 마음에 드는 이미지를 골라서 진행하세요.
    • 각 위젯별 애니메이션은 최소 3개 이상이 적용되어야 합니다. 이 때 적용되는 애니메이션은 자유입니다.
    • 페이지는 3개 이상입니다. 필수 페이지는 다음과 같습니다.
      • SecretPage : 비밀을 볼 수 있는 페이지며, 모든 비밀을 데이터로 불러오며 각 비밀은 페이지로 이루어짐.
      • AuthorPage : 모든 작성자(회원)을 볼 수 있는 페이지
      • UploadPage: 비밀을 업로드할 수 있는 페이지
    • secrets_cat_sdk 패키지를 설치하면 Author와 Secret 데이터타입을 사용할 수 있습니다.
      데이터와 데이터타입을 활용하여 최대한 위 결과물의 비슷하게 앱을 만들어보세요.

font 적용방법

flutter 공식문서

supported font formats

.ttc
.ttf
.otf

  1. assets/fonts에 폰트를 다운받아 넣는다.
  2. pubspec.yaml에 등록한다.
  fonts:
    - family: Neo
      fonts:
        - asset: assets/fonts/neo.ttf
          style: italic
          weight: 700
  1. 전체 앱에 폰트 등록하는법
return MaterialApp(
  theme: ThemeData(fontFamily: 'Neo'),
  home: const MyHomePage(),
);
  1. 특정 위젯에 사용하는법
child: Text(
  'Roboto Mono sample',
  style: TextStyle(fontFamily: 'RobotoMono'),
),

메인 페이지

제일 처음 뜨는 페이지.
페이지 이동하는 버튼이 세 개 있어서 빌더 위젯을 따로 분리해 메인 페이지에서 렌더링하도록 했다.

import 'package:assignment2/builder/NavBtns.dart';
import 'package:assignment2/widgets/Background.dart';
import 'package:flutter/material.dart';

class MainPage extends StatefulWidget {
  const MainPage({super.key});

  
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  
  Widget build(BuildContext context) {
    return Background(
      child: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            const Text(
              'Shit.. 비밀이야',
              style: TextStyle(
                fontSize: 36,
              ),
            ),
            Image.asset(
              'assets/imgs/fashion.png',
              width: 300,
            ),
            const SizedBox(
              height: 200,
              child: NavBtns(), //버튼 렌더링하는 부분.
            ),
          ],
          // button
        ),
      ),
    );
  }
}

페이지에 대한 정보를 따로 리스트로 만들었다. 그리고 그 리스트의 label, page를 버튼 아이템에 전달함.

import 'package:assignment2/variables/pages.dart';
import 'package:assignment2/widgets/NavBtn.dart';
import 'package:flutter/material.dart';

class NavBtns extends StatelessWidget {
  const NavBtns({super.key});

  
  Widget build(BuildContext context) {
    return PageView.builder(
      controller: PageController(viewportFraction: 0.8),
      scrollDirection: Axis.horizontal,
      itemCount: navList.length,
      itemBuilder: (context, idx) {
        return NavBtn(
          label: navList[idx]["label"],
          page: navList[idx]["page"],
        );
      },
    );
  }
}

여기서 역시나 또 약간의 고생을 했는데, 페이지를 어떻게 전달하느냐가 문제였다. 실행시켜야 하는건지, 그냥 전달만 해야하는건지...

final List<Map<String, dynamic>> navList = [
  {
    "name": 'author',
    "image": null,
    "label": '누구?',
    "page": AuthorPage(),
  },
  {
    "name": 'secret',
    "image": 'assets/imgs/sleep.png',
    "label": '어떤?',
    "page": SecretPage(),
  },
  {
    "name": 'upload',
    "image": 'assets/imgs/cat.png',
    "label": '나도..',
    "page": UploadPage(),
  },
];

author_page.dart

class AuthorPage extends StatefulWidget {
  const AuthorPage({super.key});

  
  State<AuthorPage> createState() => _AuthorPageState();
}

class _AuthorPageState extends State<AuthorPage> {
  var authors;
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Background(
        child: SafeArea(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const CustomAppbar(
                title: '비밀스러운 사람들..',
              ),
              FutureBuilder(
                future: SecretCatApi.fetchAuthors(),
                builder: (context, snapshot) {
                  if (snapshot.connectionState == ConnectionState.done) {
                    authors = snapshot.data;
                    return Container(
                      alignment: Alignment.center,
                      margin: const EdgeInsets.symmetric(
                        vertical: 32,
                      ),
                      child: GridView.builder(
                        padding: const EdgeInsets.all(16),
                        itemCount: authors.length,
                        shrinkWrap: true,
                        gridDelegate:
                            const SliverGridDelegateWithFixedCrossAxisCount(
                                crossAxisCount: 3),
                        itemBuilder: (context, idx) {
                          return AuthorItem(
                            name: authors[idx].name,
                            avatar: authors[idx].avatar,
                          );
                        },
                      ),
                    );
                  }
                  return Container();
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

authorItem.dart

비눗방울에 작성자들이 들어가있는 느낌을 내고 싶었다. 그래서 ShakeY에 from 값을 랜덤으로 생성해서 주도록 함.final randomFrom = random.nextDouble() * 15.0

class AuthorItem extends StatelessWidget {
  const AuthorItem({
    super.key,
    required this.avatar,
    required this.name,
  });

  final String? avatar;
  final String name;

  
  Widget build(BuildContext context) {
    final random = Random();
    final randomFrom = random.nextDouble() * 15.0;

    return Column(
      children: [
        Expanded(
          child: ShakeY(
            from: randomFrom, //0~15 사이 숫자 랜덤으로
            duration: const Duration(seconds: 5),
            infinite: true,
            child: Bubble(
              child: avatar != null
                  ? Image.network(
                      avatar!,
                    )
                  : Image.asset('assets/imgs/origami.png'),
            ),
          ),
        ),
        Text(
          name,
          style: const TextStyle(
            fontSize: 24,
          ),
        ),
      ],
    );
  }
}

secret_page.dart

비밀이 잠자는 유니콘 위를 둥둥 떠다니도록 하고 싶었다.
화면을 비밀 비눗방울 1: 유니콘 1 로 하려고 Expanded로 감싸줌.
비눗방울이 자연스럽게 떠다니도록 하고싶은데, 좀더 연구해봐야할 것 같다.
원래는 비눗방울이 비밀 크기만큼만 적당한 크기를 갖도록 하고 나타나는 방향도 각자 다르게 하고싶었는데 이게 한계였다...

class SecretPage extends StatefulWidget {
  const SecretPage({super.key});

  
  State<SecretPage> createState() => _SecretPageState();
}

class _SecretPageState extends State<SecretPage> {
  var secrets;
  // secret, author{name, username, avatar}
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Background(
        child: SafeArea(
          child: Column(
            children: [
              const CustomAppbar(title: 'shit..'),
              FutureBuilder(
                future: SecretCatApi.fetchSecrets(),
                builder: (context, snapshot) {
                  if (snapshot.connectionState == ConnectionState.done) {
                    secrets = snapshot.data;
                    return Expanded(
                      child: PageView.builder(
                        controller: PageController(
                          viewportFraction: 0.8,
                        ),
                        itemCount: secrets.length,
                        itemBuilder: (context, index) {
                          if (secrets[index].secret != null) {
                            return Secret(secret: secrets[index].secret);
                          }
                          return null;
                        },
                      ),
                    );
                  }
                  return const Secret(
                    secret: '비밀은 누구나 있어..',
                  );
                },
              ),
              Expanded(
                child: ShakeY(
                  from: 10,
                  duration: const Duration(seconds: 5),
                  infinite: true,
                  child: Image.asset(
                    'assets/imgs/sleep.png',
                    width: 300,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

secret.dart

buble이 Expanded때문인가 동그랗지않고 화면을 다 차지했다. 그래서 secret에서 antiAlias를 줌. 근데 뭐 생각보다 나쁘지 않아서 그냥 뒀다. 위아래로 흔들리면서 오버플로우되는 부분이 가려지는데 그게 제법 흔들리면서 모양이 흩트러지는 비눗방울같지 않은가? ㅎㅎ

class Secret extends StatelessWidget {
  const Secret({
    super.key,
    required this.secret,
  });

  final String secret;

  
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(200),
      ),
      child: ShakeY(
        from: 5,
        duration: const Duration(seconds: 5),
        infinite: true,
        child: Bubble(
          child: Center(
            child: Expanded(
              child: Text(
                maxLines: 3,
                overflow: TextOverflow.ellipsis,
                secret != '' ? secret : 'null',
                style: const TextStyle(fontSize: 18),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

upload_page.dart

값을 받아올 때 보니까 빈 문자열을 누군가가 서버에 보내놓은 것 같았다. 처음에 아무것도 안떠서 당황;
그래서 필자는 아예 빈 문자열이 아닐 경우에만 서버에 요청을 하도록 했다. if (secret != '')
submitSecret이라는 불리언 값을 만들어서 데이터 전송시 true로 바꿔 종이학을 보내는 등의 액션이 발생하도록 했다. 한번 true가 되면 개발자가 다시 false로 값을 바꾸지 않는 한 다시 텍스트필드가 화면에 나올 수 없기 때문에 Future.delayed를 통해 5초 뒤에 다시 이 값이 false가 되어 텍스트필드가 다시 나오도록 했다.

class UploadPage extends StatefulWidget {
  const UploadPage({super.key});

  
  State<UploadPage> createState() => _UploadPageState();
}

class _UploadPageState extends State<UploadPage> {
  var controller = TextEditingController();
  bool submitSecret = false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      body: Background(
        child: SafeArea(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CustomAppbar(title: submitSecret ? '비밀 접수중' : '내 비밀은'),
              submitSecret
                  ? Container()
                  : Column(
                      children: [
                        CustomTextField(controller: controller),
                        CustomBtn(
                          onPressed: () {
                            var secret = controller.text;
                            if (secret != '') {
                              try {
                                SecretCatApi.addSecret(secret);
                                submitSecret = true;
                                secret = '';
                                controller.clear();
                                setState(() {});
                                Future.delayed(const Duration(seconds: 5), () {
                                  setState(() {
                                    submitSecret = false;
                                  });
                                });
                              } catch (err) {
                                alert(
                                  context,
                                  message: '에러가 발생했어요.',
                                );
                              }
                            } else {
                              alert(
                                context,
                                message: '아무 비밀도 듣지 못했어요!',
                              );
                            }
                          },
                        ),
                      ],
                    ),
              submitSecret
                  ? ShakeY(
                      from: 20,
                      duration: const Duration(seconds: 2),
                      child: FadeOutLeftBig(
                        animate: true,
                        duration: const Duration(seconds: 5),
                        child: Image.asset(
                          'assets/imgs/origami.png',
                        ),
                      ),
                    )
                  : Bounce(
                      child: Image.asset(
                        'assets/imgs/cat.png',
                        width: 300,
                      ),
                    ),
            ],
          ),
        ),
      ),
    );
  }
}

CustomTextField.dart

class CustomTextField extends StatelessWidget {
  const CustomTextField({
    super.key,
    required this.controller,
  });

  final TextEditingController controller;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(30.0),
      child: TextField(
        minLines: 5,
        maxLines: 5,
        decoration: InputDecoration(
          filled: true,
          fillColor: Colors.white,
          enabledBorder: OutlineInputBorder(
            borderSide: const BorderSide(
              color: Colors.transparent,
            ),
            borderRadius: BorderRadius.circular(20),
          ),
          focusedBorder: OutlineInputBorder(
            borderSide: const BorderSide(
              color: Color(0xffC56CB7),
            ),
            borderRadius: BorderRadius.circular(20),
          ),
          contentPadding: const EdgeInsets.all(16),
        ),
        style: const TextStyle(
          fontSize: 20,
        ),
        controller: controller,
      ),
    );
  }
}

Alert 유틸함수

업로드 페이지에서 보면 에러상황 등에서 alert을 발생시키는 것을 볼 수 있다. 이건 따로 위젯이 있는 게 아니고 필자가 따로 만든 알림창이다.
showDialog를 사용했다. 유저에게 보여줄 메시지를 두번재 인자로 보내야한다. 뭐 확인버튼 외에도 취소나 처음으로 돌아가기 같은 버튼이 있는 게 더 좋겠지만 시간이 모자라서.
사실... stateless 위젯이라 생각했는데 그게 아니고Future<viod>라서 유틸함수인 격인가? 싶다. 정확히는 아직 잘 모르겠음. 위젯으로 생성하려니까 안되더라.

Future<void> alert(BuildContext context, {required message}) async {
  return showDialog(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: const Text('Error'),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('확인'),
          ),
        ],
      );
    },
  );
}

안드로이드 기기에서 에러발생: Bottom overflowed by 168 pixels

개발할때 아이폰 에뮬레이터로 했다. 블로깅을 하기 위해 안드로이드로 빌드를 해보니 새로운 에러가 발생했다.

텍스트필드를 active 시켰을 때 bottom에서 키보드가 올라오면서 오버플로우가 발생한다.
찾아보니 키보드가 켜지면서 리사이즈를 하는 듯.
Scaffold 위젯에 resizeToAvoidBottomInset라는 옵션이 있다. 이 옵션에 false값을 주어 해결.

  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      ... 생략...

키보드가 외부영역을 클릭시 닫히도록 하고싶다.

의외로 이게 안되더라. 당연히 되는건줄;


본 후기는 유데미-스나이퍼팩토리 9주 완성 프로젝트캠프 학습 일지 후기로 작성 되었습니다.

profile
주니어 플러터 개발자의 고군분투기

0개의 댓글