네이버 이메일 화면을 클론 코딩해보자. 디자인은 약간 달라도 괜찮지만 명시된 기능은 구현을 해야한다.
아래의 URL에서 데이터를 받아와서 사용한다.
https://sfacassignmentchallenge-default-rtdb.europe-west1.firebasedatabase.app/.json
- 이메일의 목록을 사진 디자인과 같이 구현합니다.
- 이메일이 도착한 날이 오늘이라면 오늘, 어제라면 어제, 올해와 년도가 같다면 MM.dd , 년도가 다르다면 yyyy.MM.dd 로 보여져야합니다.
- 각 리스트는 swipe가 가능하며, 우측 스와이프로 삭제를 한다면, 휴지통으로 분류가 되며 현재 화면에는 보여지지 않습니다.
- 우측 하단 FAB은 작성하기 버튼이지만, 휴지통 아이콘을 넣어주세요. 클릭하면 휴지통에 추가된 리스트들이 보이는 화면을 구현해주세요.
- 우측상단에서 메일검색이 가능하며, 메일검색을 누르면 두번째 사진, 검색하는 화면이 등장하고 최근에 검색한 목록들이 등장합니다.
- 메일검색은 보낸사람을 기준으로 검색이 가능하게 합니다.
- 우측 상단 시계버튼을 누르면 메시지를 받은 최신순으로 정렬하게되고, 한번 더 누르게되면 오래된 순으로 보여지게 됩니다.
- pull to refresh를 한다면 이메일이 새로 업데이트가되며, 삭제한 이메일은 보이지 않습니다.
- 최상단 앱바를 클릭하면 자연스럽게 최상단으로 올라가게 됩니다.
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
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(), //메인 페이지 호출
);
}
}
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),
),
);
}
}
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);
},
);
}),
),
),
);
}
}
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,
),
),
],
),
],
),
),
),
),
);
}
}
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;
}
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); //결과 출력
},
);
});
}
}
해당 포스팅에서는 기능 목록의 내용들을 어떤 방식으로 구현했는지에 대해서만 간단히 설명하고자 한다.
커스텀 위젯인 EmailCard
를 따로 작성하여 ListView.builder
로 화면을 구현했다.
EmailCard
에서 compareDate()
를 작성하여 오늘과 어제, 같은 년도를 판단하여 각각 다른 문자열을 반환하고, 이를 출력했다.
Dismissible
위젯을 사용했으며, 휴지통에 있는 데이터와 기본 이메일 리스트에 다른 기능을 설정해 보았다. 기본 이메일에서는 휴지통으로 이동해 삭제되고, 휴지통의 메일을 스와이프 하면 다시 복원된다.
휴지통 리스트를 따로 생성하고 여기에 저장되지 않은 데이터만 출력하여 휴지통에 있는 이메일은 화면에 보이지 않게 했다.
MainPage
에서 FAB을 만들고 눌렀을 때, RecycleBinPage
로 이동했다.
이동할 때 휴지통 리스트를 넘겨주어 이 값들을 EmailCard
로 출력했다.
Search
클래스를 생성하고 SearchDelegate
를 상속받아 검색 클래스를 작성했다.
메일 검색을 눌렀을 때 showSearch()
를 호출해 검색창이 뜨도록 했다.
showSearch()
를 호출할 때 매개변수로 검색 가능 요소 리스트를 전달하는데 여기에 보낸 사람의 이름이 담긴 리스트를 넣어주었다.
시계 버튼을 누르면 미리 생성해 놓은 reverseDate
의 값을 true
나 false
로 변경하고, 본문에서 sort
를 사용해 reverseDate
가 true
면 오래된 순으로 false
면 최신순으로 정렬했다.
pull_to_refresh
패키지를 사용해 새로고침을 구현했고, 삭제된 이메일은 보이지 않도록 하기 위해 휴지통 리스트를 Hive로 로컬에 저장해 관리했다.
ScrollController
를 리스트뷰에 설정하고 앱바의 타이틀을 눌렀을 때 최상단으로 이동하도록 GestureDetector
의 onTap
이벤트에 animateTo()
메소드를 사용했다.
[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>
형태도 어댑터를 적용해야 돌아가는게 맞는건지는 모르겠다. 클래스 모델을 저장하려면 타입어댑터를 설정해야 된다고 해서 한건데...이 타입들이 리스트에 들어가 있는 형태를 저장할 때도 필요한게 맞았을라나..? 어쨌든 마무리는 했지만 완벽하진 않은것 같다.ㅠㅠ