[Flutter] 스나이퍼팩토리 5주차 도전하기

KWANWOO·2023년 2월 26일
2
post-thumbnail

스나이퍼팩토리 플러터 5주차 도전과제

네이버 이메일 클론 코딩

네이버 이메일 화면을 클론 코딩해보자. 디자인은 약간 달라도 괜찮지만 명시된 기능은 구현을 해야한다.

아래의 URL에서 데이터를 받아와서 사용한다.
https://sfacassignmentchallenge-default-rtdb.europe-west1.firebasedatabase.app/.json

1. 기능 목록

  1. 이메일의 목록을 사진 디자인과 같이 구현합니다.
  2. 이메일이 도착한 날이 오늘이라면 오늘, 어제라면 어제, 올해와 년도가 같다면 MM.dd , 년도가 다르다면 yyyy.MM.dd 로 보여져야합니다.
  3. 각 리스트는 swipe가 가능하며, 우측 스와이프로 삭제를 한다면, 휴지통으로 분류가 되며 현재 화면에는 보여지지 않습니다.
  4. 우측 하단 FAB은 작성하기 버튼이지만, 휴지통 아이콘을 넣어주세요. 클릭하면 휴지통에 추가된 리스트들이 보이는 화면을 구현해주세요.
  5. 우측상단에서 메일검색이 가능하며, 메일검색을 누르면 두번째 사진, 검색하는 화면이 등장하고 최근에 검색한 목록들이 등장합니다.
  6. 메일검색은 보낸사람을 기준으로 검색이 가능하게 합니다.
  7. 우측 상단 시계버튼을 누르면 메시지를 받은 최신순으로 정렬하게되고, 한번 더 누르게되면 오래된 순으로 보여지게 됩니다.
  8. pull to refresh를 한다면 이메일이 새로 업데이트가되며, 삭제한 이메일은 보이지 않습니다.
  9. 최상단 앱바를 클릭하면 자연스럽게 최상단으로 올라가게 됩니다.

2. 결과물 예시

3. 코드 작성

  • pubspec.yaml
dependencies:
  cupertino_icons: ^1.0.2
  dio: ^5.0.1
  flutter:
    sdk: flutter
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  intl: ^0.18.0
  pull_to_refresh: ^2.0.0

dev_dependencies:
  build_runner: ^2.3.3
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter
  hive_generator: ^2.0.0 
  • main.dart
import 'package:email_app/model/Email.dart';
import 'package:email_app/page/main_page.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';

Future<void> main() async {
  await Hive.initFlutter(); //Hive 초기화
  Hive.registerAdapter(EmailAdapter()); //이메일 타입 어댑터 설정
  runApp(const EmailApp());
}

class EmailApp extends StatelessWidget {
  const EmailApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainPage(), //메인 페이지 호출
    );
  }
}
  • main_page.dart
import 'package:dio/dio.dart';
import 'package:email_app/model/Email.dart';
import 'package:email_app/model/Search.dart';
import 'package:email_app/page/recycle_bin_page.dart';
import 'package:email_app/widget/EmailCard.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';

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

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

class _MainPageState extends State<MainPage> {
  int? dataLength; //네트워크에서 가져온 데이터 길이
  Future<List>? result; //네트워크 데이터 결과
  bool reverseDate = false; //날짜 정렬 역순
  Box? box; //hive box
  List recyclebinList = []; //휴지통 리스트
  RefreshController refreshController = RefreshController(); //새로고침 컨트롤러
  ScrollController scrollController = ScrollController(); //리스트뷰 스크롤 컨트롤러

  //hive box를 열고 휴지통 데이터 초기화
  getBox() async {
    box = await Hive.openBox('recyclebin');
    if (box != null) {
      recyclebinList = box!.get('recyclebin') ?? [];
    }
  }

  //네트워크에서 데이터 가져오기
  Future<List> getData() async {
    Dio dio = Dio();
    String url =
        'https://sfacassignmentchallenge-default-rtdb.europe-west1.firebasedatabase.app/.json';

    try {
      var response = await dio.get(url).then((value) => value.data['emails']);
      dataLength = response.length; //데이터 길이 설정
      setState(() {});
      return response;
    } catch (e) {
      throw Exception('Failed to load data');
    }
  }

  //새로고침
  void onRefresh() async {
    result = getData();
    refreshController.refreshCompleted();
  }

  
  void initState() {
    super.initState();
    getBox(); //hive box 데이터 초기화
    result = getData(); //네트워크 데이터 초기화
  }

  
  void dispose() async {
    await box!.close(); //hive box 닫기
    refreshController.dispose(); //컨트롤러 해제
    scrollController.dispose(); //컨트롤러 해제
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        backgroundColor: Colors.white,
        foregroundColor: Colors.black,
        elevation: 2,
        title: GestureDetector(
          // 앱바 타이틀 클릭 시 맨 위로 이동
          onTap: () => scrollController.animateTo(
            0,
            duration: Duration(milliseconds: 500),
            curve: Curves.easeIn,
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('프로모션'),
              Icon(
                size: 10,
                color: Colors.greenAccent,
                Icons.fiber_manual_record,
              ),
              if (dataLength != null)
                Text(
                  style: TextStyle(
                    color: Colors.greenAccent,
                  ),
                  dataLength.toString(), //리스트 길이 출력
                ),
            ],
          ),
        ),
        leading: Icon(Icons.menu),
        actions: [
          IconButton(
            // 정렬 역순 체크 값 변경
            onPressed: () {
              reverseDate = !reverseDate;
              setState(() {});
            },
            icon: Icon(Icons.schedule),
          ),
        ],
      ),
      body: FutureBuilder(
        future: result,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            // 데이터 정렬
            snapshot.data!.sort((a, b) {
              var adate = a['sendDate'];
              var bdate = b['sendDate'];
              if (reverseDate) {
                //오래된 순
                return adate.compareTo(bdate);
              } else {
                //최근 순
                return -adate.compareTo(bdate);
              }
            });
            //검색에 사용될 보낸 사람 리스트
            List<String> fromList =
                snapshot.data!.map((e) => e['from'].toString()).toList();

            return Column(
              children: [
                InkWell(
                  //검색 창 호출
                  onTap: () => showSearch(
                      context: context, delegate: Search(fromList, box!)),
                  child: Container(
                    padding: EdgeInsets.all(6.0),
                    margin: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0),
                    decoration: BoxDecoration(
                      color: Colors.black12,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Icon(color: Colors.grey, Icons.search),
                        SizedBox(width: 4),
                        Text(
                          style: TextStyle(
                            fontSize: 16,
                            color: Colors.grey,
                          ),
                          '메일 검색',
                        ),
                      ],
                    ),
                  ),
                ),
                Expanded(
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: SmartRefresher(
                      controller: refreshController,
                      onRefresh: onRefresh,
                      child: ListView.builder(
                        controller: scrollController,
                        itemCount: snapshot.data!.length,
                        itemBuilder: (context, index) {
                          Email email = Email.fromJson(
                              snapshot.data![index]); //Email 객체 매핑
                          return !recyclebinList
                                  .contains(email) //휴지통에 없는 데이터만 출력
                              // 이메일 카드
                              ? EmailCard(
                                  email: email,
                                  isDeleted: false,
                                  onDismissed: (direction) {
                                    recyclebinList.add(email);
                                    box!.put('recyclebin', recyclebinList);
                                  },
                                )
                              : SizedBox();
                        },
                      ),
                    ),
                  ),
                )
              ],
            );
          }
          return CupertinoActivityIndicator(); //로딩 중
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 휴지통 이동
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => RecycleBinPage(
                recyclebinList: recyclebinList,
                box: box!,
              ),
            ),
          ).then((value) => setState(() {
                recyclebinList = box!.get('recyclebin');
              }));
        },
        backgroundColor: Colors.greenAccent,
        child: Icon(Icons.delete_outline),
      ),
    );
  }
}
  • recycle_bin_page.dart
import 'package:email_app/widget/EmailCard.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

class RecycleBinPage extends StatelessWidget {
  const RecycleBinPage({
    super.key,
    required this.recyclebinList,
    required this.box,
  });

  final List recyclebinList; //휴지통 리스트트
  final Box box; //hive 박스

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        backgroundColor: Colors.white,
        foregroundColor: Colors.black,
        elevation: 2,
        title: Text('휴지통'),
        leading: Icon(Icons.menu),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: ListView.builder(
          itemCount: recyclebinList.length,
          itemBuilder: ((context, index) {
            //이메일 카드
            return EmailCard(
              email: recyclebinList[index],
              isDeleted: true, //삭제된 요소
              //휴지통 리스트에서 삭제하여 이메일 복원
              onDismissed: (direction) {
                recyclebinList.remove(recyclebinList[index]);
                box.put('recyclebin', recyclebinList);
              },
            );
          }),
        ),
      ),
    );
  }
}
  • EmailCard.dart
import 'package:email_app/model/Email.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class EmailCard extends StatelessWidget {
  const EmailCard(
      {super.key,
      required this.email,
      this.onDismissed,
      required this.isDeleted});

  final Email email; //이메일
  final Function(DismissDirection)? onDismissed; //onDissmissed이벤트 핸들러
  final bool isDeleted; //삭제된 요소인지 확인

  //출력될 날짜 리턴
  String compareDate(DateTime date) {
    var today = DateTime.now();

    if (today.year == date.year &&
        today.month == date.month &&
        today.day == date.day) {
      return '오늘';
    } else if (today.year == date.year &&
        today.month == date.month &&
        today.day - 1 == date.day) {
      return '어제';
    } else if (today.year == date.year) {
      return DateFormat('MM.dd').format(email.sendDate);
    }
    return DateFormat('yyyy.MM.dd').format(email.sendDate);
  }

  
  Widget build(BuildContext context) {
    String sendDate = compareDate(email.sendDate);
    return Dismissible(
      key: UniqueKey(),
      background: Container(
        decoration: BoxDecoration(
          color: isDeleted ? Colors.greenAccent : Colors.redAccent,
          borderRadius: BorderRadius.circular(8.0),
        ),
        alignment: Alignment.centerRight,
        padding: EdgeInsets.only(right: 56),
        child: Icon(
          color: Colors.white,
          isDeleted ? Icons.undo : Icons.delete_outline,
        ),
      ),
      onDismissed: onDismissed, //onDissmissed 핸들러 연결
      child: SizedBox(
        height: 115,
        child: Card(
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Icon(
                      size: 10,
                      color: Colors.greenAccent,
                      Icons.fiber_manual_record,
                    ),
                    SizedBox(width: 4),
                    //송신자
                    Text(
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 16,
                      ),
                      email.from,
                    ),
                    Expanded(child: SizedBox()),
                    //보낸 날짜
                    Text(
                      style: TextStyle(
                        color: Colors.grey,
                      ),
                      sendDate,
                    ),
                    Icon(
                      color: Colors.black12,
                      Icons.star,
                    ),
                  ],
                ),
                Row(
                  children: [
                    SizedBox(width: 14),
                    Container(
                      padding: EdgeInsets.symmetric(horizontal: 6),
                      decoration: BoxDecoration(
                        color: Colors.grey,
                        borderRadius: BorderRadius.circular(16),
                      ),
                      child: Text(
                        style: TextStyle(
                          color: Colors.white,
                          fontWeight: FontWeight.w300,
                        ),
                        'TO',
                      ),
                    ),
                    SizedBox(width: 4),
                    //메일 제목
                    Text(
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                      ),
                      email.title,
                    ),
                  ],
                ),
                Expanded(child: SizedBox()),
                Row(
                  children: [
                    SizedBox(width: 14),
                    //메일 내용
                    Expanded(
                      child: Text(
                        overflow: TextOverflow.ellipsis,
                        maxLines: 2,
                        style: TextStyle(
                          color: Colors.grey,
                        ),
                        email.detail,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
  • Eamil.dart
import 'package:intl/intl.dart';
import 'package:hive/hive.dart';

part 'Email.g.dart';

(typeId: 1)
class Email {
  (0)
  String detail; //이메일 내용
  (1)
  int emailNo; //이메일 번호
  (2)
  String from; //송신자
  (3)
  DateTime sendDate; //보낸 날짜
  (4)
  String title; //이메일 제목

  //생성자
  Email({
    required this.detail,
    required this.emailNo,
    required this.from,
    required this.sendDate,
    required this.title,
  });

  //json 데이터를 받아 멤버 변수에 매핑하여 객체를 생성
  Email.fromJson(Map<String, dynamic> json)
      : detail = json['detail'],
        emailNo = json['emailNo'],
        from = json['from'],
        sendDate = DateFormat('yyyy.MM.dd').parse(json['sendDate']),
        title = json['title'];

  
  String toString() {
    return '$detail, $emailNo, $from, $sendDate, $title';
  }

  
  operator ==(Object other) => other is Email && emailNo == other.emailNo;
}
  • Search.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

class Search extends SearchDelegate {
  final List<String> listExample; //검색 요소 리스트
  final Box box; //hive 박스

  List<String> recentList = []; //최근 검색 리스트
  String selectedResult = ''; //검색 결과 문자열

  //생성자
  Search(this.listExample, this.box) : super(searchFieldLabel: '메일 검색') {
    recentList = box.get('recentList') ?? [];
  }

  //앱바 Actions
  
  List<Widget>? buildActions(BuildContext context) {
    return [
      TextButton(
        child: Text(style: TextStyle(color: Colors.black), '상세'),
        onPressed: () {},
      ),
    ];
  }

  //앱바 Leading
  
  Widget? buildLeading(BuildContext context) {
    return IconButton(
      icon: Icon(Icons.navigate_before),
      onPressed: () => Navigator.pop(context),
    );
  }

  //검색 결과 처리
  
  Widget buildResults(BuildContext context) {
    return Container(
      child: Center(child: Text(selectedResult)),
    );
  }

  //검색 리스트 화면
  
  Widget buildSuggestions(BuildContext context) {
    List<String> suggestionList = [];

    //검색 쿼리가 빈 경우 최근 검색 리스트를 출력
    //검색 쿼리가 있을 경우 검색 요소 리스트에서 찾아서 출력
    query.isEmpty
        ? suggestionList = recentList
        : suggestionList
            .addAll(listExample.where((element) => element.contains(query)));

    return ListView.builder(
        itemCount: suggestionList.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(suggestionList[index]),
            trailing: query.isEmpty
                // 최근 검색 리스트에서 해당 데이터 삭제
                ? IconButton(
                    icon: const Icon(Icons.close),
                    onPressed: () {
                      recentList.remove(suggestionList[index]);
                      box.put('recentList', recentList);
                      query = '';
                    },
                  )
                : const SizedBox(),
            onTap: () {
              selectedResult = suggestionList[index]; //검색 결과 저장
              recentList.add(suggestionList[index]); //최근 검색 추가
              box.put('recentList', recentList); //최근 검색 리스트 hive에 저장
              showResults(context); //결과 출력
            },
          );
        });
  }
}

4. 코드 설명

해당 포스팅에서는 기능 목록의 내용들을 어떤 방식으로 구현했는지에 대해서만 간단히 설명하고자 한다.

1) 이메일의 목록을 사진 디자인과 같이 구현합니다.

커스텀 위젯인 EmailCard를 따로 작성하여 ListView.builder로 화면을 구현했다.

이메일이 도착한 날이 오늘이라면 오늘, 어제라면 어제, 올해와 년도가 같다면 MM.dd , 년도가 다르다면 yyyy.MM.dd 로 보여져야합니다.

EmailCard에서 compareDate()를 작성하여 오늘과 어제, 같은 년도를 판단하여 각각 다른 문자열을 반환하고, 이를 출력했다.

3) 각 리스트는 swipe가 가능하며, 우측 스와이프로 삭제를 한다면, 휴지통으로 분류가 되며 현재 화면에는 보여지지 않습니다.

Dismissible 위젯을 사용했으며, 휴지통에 있는 데이터와 기본 이메일 리스트에 다른 기능을 설정해 보았다. 기본 이메일에서는 휴지통으로 이동해 삭제되고, 휴지통의 메일을 스와이프 하면 다시 복원된다.

휴지통 리스트를 따로 생성하고 여기에 저장되지 않은 데이터만 출력하여 휴지통에 있는 이메일은 화면에 보이지 않게 했다.

4) 우측 하단 FAB은 작성하기 버튼이지만, 휴지통 아이콘을 넣어주세요. 클릭하면 휴지통에 추가된 리스트들이 보이는 화면을 구현해주세요.

MainPage에서 FAB을 만들고 눌렀을 때, RecycleBinPage로 이동했다.

이동할 때 휴지통 리스트를 넘겨주어 이 값들을 EmailCard로 출력했다.

5) 우측상단에서 메일검색이 가능하며, 메일검색을 누르면 두번째 사진, 검색하는 화면이 등장하고 최근에 검색한 목록들이 등장합니다.

Search 클래스를 생성하고 SearchDelegate를 상속받아 검색 클래스를 작성했다.

메일 검색을 눌렀을 때 showSearch()를 호출해 검색창이 뜨도록 했다.

6) 메일검색은 보낸사람을 기준으로 검색이 가능하게 합니다.

showSearch()를 호출할 때 매개변수로 검색 가능 요소 리스트를 전달하는데 여기에 보낸 사람의 이름이 담긴 리스트를 넣어주었다.

7) 우측 상단 시계버튼을 누르면 메시지를 받은 최신순으로 정렬하게되고, 한번 더 누르게되면 오래된 순으로 보여지게 됩니다.

시계 버튼을 누르면 미리 생성해 놓은 reverseDate의 값을 truefalse로 변경하고, 본문에서 sort를 사용해 reverseDatetrue면 오래된 순으로 false면 최신순으로 정렬했다.

8) pull to refresh를 한다면 이메일이 새로 업데이트가되며, 삭제한 이메일은 보이지 않습니다.

pull_to_refresh 패키지를 사용해 새로고침을 구현했고, 삭제된 이메일은 보이지 않도록 하기 위해 휴지통 리스트를 Hive로 로컬에 저장해 관리했다.

9) 최상단 앱바를 클릭하면 자연스럽게 최상단으로 올라가게 됩니다.

ScrollController를 리스트뷰에 설정하고 앱바의 타이틀을 눌렀을 때 최상단으로 이동하도록 GestureDetectoronTap이벤트에 animateTo()메소드를 사용했다.

5. 결과

6. 추가 내용 정리

TypeAdapter로 Hive 사용하기

[Flutter] 스나이퍼팩토리 4주차 도전하기에서 Hive의 기본 사용법에 대해 정리했었다. 이번에는 TypeAdapter를 사용하여 내가 만든 클래스 타입을 Hive에 저장하는 방법을 작성하고자 한다.

역시 먼저 pubspec.yaml에 패키지를 설지해야한다.

dependencies:
  hive: ^2.0.4
  hive_flutter: ^1.0.0

dev_dependencies:
  hive_generator: ^1.1.0
  build_runner: ^2.0.4

다음으로 main.dart에서 Hive를 초기화한다.

import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

Future<void> main() async {
  await Hive.initFlutter();
  runApp(MyApp());
}

Adapter를 생성하려면 먼저 사용할 모델 클래스를 작성해야하는데 아래는 예시로 작성한 Person모델이다.

import 'package:hive/hive.dart';

part 'person.g.dart';

(typeId : 1)
class Person {
  (0)
  String name;

  (1)
  int age;
  
  Person(this.name, this.age);
}

hive를 import하고 파일 이름의 .dart앞에 .g를 넣어 섹션을 추가한다. 이 파일은 hive_generator에 의해 자동으로 생성된다.

클래스에는 @HiveType(typeId: )을 사용하여 모델 클래스를 명확히 한다. 이렇게 하면 Hive는 이 클래스가 TypeAdapter라고 인식한다.

typeId는 Hive가 올바른 Adapter를 찾는데 사용되는 식별 값이며 고유해야한다. (0에서 223 사이의 값만 가능)

각 필드에는 @HiveField(index)를 사용하여 인덱스 번호를 표시한다.

위와 같이 작성을 마쳤으면 터미널에 아래와 같은 커맨드를 실행하여 필요한 데이터베이스 코드를 자동으로 생성한다.
(위의 예시코드에서는 person.g.dart 파일이 생성된다.)

$ flutter packages pub run build_runner build

이제 Adapter를 등록해야 한다. main.dart에서 Hive.initFlutter()아래에 Adapter를 등록하면 된다.

Future<void> main() async {
  await Hive.initFlutter();
  Hive.registerAdapter(PersonAdapter()); // add here
  runApp(MyApp());
}

기본 설정은 끝났고, 이제 Hive의 사용법대로 데이터를 저장하고, 가져올 수 있다.

void main() async {
  var box = await Hive.openBox('persons');
  
  box.add('david', Person('David', 32));
  
  print(box.values);
}

어렵다... ㅠㅠ

도전과제를 거의 하루종일 한 것 같다. ㅋㅋㅋㅋ 어느정도 주어진 기능을 다 구현하긴 했는데 조금 애매하다. 검색 기능을 SearchDelegete를 상속받아 만들어봤는데 주어진 예시와 같이 UI를 만드려면 직접 커스텀해서 만들었으면 더 좋았을 것 같다. 그리고 Hive도 TypeAdapter를 등록해서 사용했는데 List<Email> 형태도 어댑터를 적용해야 돌아가는게 맞는건지는 모르겠다. 클래스 모델을 저장하려면 타입어댑터를 설정해야 된다고 해서 한건데...이 타입들이 리스트에 들어가 있는 형태를 저장할 때도 필요한게 맞았을라나..? 어쨌든 마무리는 했지만 완벽하진 않은것 같다.ㅠㅠ

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보