지금까지는 State 클래스 내부에서 데이터(상태) 를 변경하고 setState() 함수 를 실행했다.
이 방식은 작은 프로젝트에서는 효율적이지만 프로젝트가 커질수록 같은 변수를 반복적으로 아래의 위젯으로 넘겨줘야 하니 데이터(상태) 관리가 어렵다.
최상위 위젯 Container에서Text 까지color: Colors.blue를 넘겨주려면 중간에 모든 위젯이color: Colors.blue를 가지고 있어야 한다.
혹시나 매개변수를color: Colors.red로 수정한다면? 모두 바꿔야 한다...
따라서 글로벌 상태 관리 툴 을 사용해서 데이터를 목표 위젯에서 직접적으로 가져온다.

플러터에는 Bloc , GetX , Riverpod, Provider 같은 상태 관리 플러그인 (글로벌 상태관리 툴) 이 있다.
여기서 가장 사용하기 쉬운 Provider 를 사용하겠다.
실제 서버를 운영하는 상황에서는 서버를 구매하거나 클라우드에서 운영하게 된다.
이때 자연적으로 지연이 생긴다.
하지만 유저들은 이러한 지연을 마주치면 '앱이 느리다' 라는 느낌을 받을 수 있기 때문에 개발자들은 데이터를 기억하는 캐싱 을 사용한다.
현재 구현한ScheduleProvider에는cache라는 변수가 존재하며 이 변수에는 GET 메서드로 불러온 모든 일정 정보가 전부 담겨있다.
따라서 같은 날짜를 다시 DB에 요청할 때에는 지연이 없다.
요청이 성공하면 현재 캐시에 하나의 일정이 추가될 것이다.
또한 성공 시 추가될 일정은 유저의 입력을 받아서 생성한 일정이기 때문에 어떤 값이 추가될 지도 알고 있다.
따라서 API 요청을 보내기 전에 유저가 입력한 값으로 미리 캐시를 업데이트 해서 응답을 예측할 수 있다. (만약 에러가 발생한다면 임의로 넣어둔 값을 삭제하면 된다.)
이러한 기법을 긍정적 응답 이라고 한다.

이번에 사용할 서버는 일정 불러오기, 생성하기, 삭제하기 기능을 제공한다.
REST API 는 일관된 DB를 갖고 있어야 하고 접근한 리소스를 포함해야 하기 때문에 /schedules API 에 GET, POST, DELETE 메소드 기능을 제작해두었다.

class ScheduleModel {
final String id;
final String content;
final DateTime date;
final int startTime;
final int endTime;
ScheduleModel({
required this.id,
required this.content,
required this.date,
required this.startTime,
required this.endTime,
});
ScheduleModel.fromJson({ // json 으로부터 모델을 만들어내는 생성자, 서버로부터 받은 json 데이터를 ScheduleModel로 매핑
required Map<String, dynamic> json,
}) : id = json['id'],
content = json['content'],
date = DateTime.parse(json['date']),
startTime = json['startTime'],
endTime = json['endTime'];
Map<String, dynamic> toJson() { // 모델을 다시 json 으로 변환, 서버로 네트워크 요청을 보낼 때 json 형식으로 데이터를 변환해야 함
return {
'id': id,
'content': content,
'date':
'${date.year}${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')}',
'startTime': startTime,
'endTime': endTime,
};
}
ScheduleModel copyWith({ // 현재 모델을 특정 속성만 변환해서 새로 생성, 이미 존재하는 인스턴스에서 몇개의 값만 변경하고 싶을 때 사용
String? id,
String? content,
DateTime? date,
int? startTime,
int? endTime,
}) {
return ScheduleModel(
id: id ?? this.id,
content: content ?? this.content,
date: date ?? this.date,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
}
}
제공되는 서버에는 3개의 엔드포인트가 존재하며 정보는 다음과 같다.

import 'dart:async';
import 'dart:io';
import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:dio/dio.dart';
class ScheduleRepository {
final _dio = Dio();
final _targetUrl =
'http://${Platform.isAndroid ? '10.0.2.2' : 'localhost'}:3000/schedule'; // 안드로이드에서는 10.0.2.2 가 localhost 에 해당
Future<List<ScheduleModel>> getSchedules({
required DateTime date,
}) async {
final resp = await _dio.get(
_targetUrl,
queryParameters: { // Query 매개변수
'date':
'${date.year}${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')},'
},
);
return resp.data // 모델 인스턴스로 데이터 매핑하기
.map<ScheduleModel>(
(x) => ScheduleModel.fromJson(
json: x,
),
)
.toList();
}
}
생성하고 싶은 일정에 해당하는 ScheduleModel 을 json 형식으로 Body에 제공한다.
.
.
.
Future<String> createSchedule({
required ScheduleModel schedule,
}) async {
final json = schedule.toJson(); // json 으로 변환
final resp = await _dio.post(_targetUrl, data: json);
return resp.data?['id'];
}
.
.
.
Future<String> deleteSchedule({
required String id,
}) async {
final resp = await _dio.delete(_targetUrl, data: {
'id': id, // 삭제할 ID 값
});
return resp.data?['id']; // 삭제된 ID 값 반환
}
Provider 툴 의 ChangeNotifier 클래스 를 상속하기만 하면 어떤 클래스든 프로바이더로 상태 관리를 하도록 만들 수 있다.
import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ScheduleProvider extends ChangeNotifier {
final ScheduleRepository repository; // API 요청 로직을 담은 클래스
DateTime selectedDate = DateTime.utc( // 선택한 날짜
DateTime
.now()
.year,
DateTime
.now()
.month,
DateTime
.now()
.day,
);
Map<DateTime, List<ScheduleModel>> cache = {}; // 일정 정보를 저장해둘 캐시
ScheduleProvider({
required this.repository,
}) : super() {
getSchedules(date: selectedDate);
}
void getSchedules({required DateTime date}) async {
final resp = await repository.getSchedules(date: date); // GET 메서드 보내기
cache.update(date, (value) => resp, ifAbsent: () => resp); // 선택한 날짜의 일정들 업데이트하기
notifyListeners(); // 리슨하는 위젯들 업데이트 하기
}
void createSchedule({
required ScheduleModel schedule,
}) async {
final targetDate = schedule.date;
final savedSchedule = await repository.createSchedule(schedule: schedule);
cache.update(
targetDate,
(value) => [ // 현존하는 캐시 리스트 끝에 새로운 일정 추가
...value,
schedule.copyWith(
id: savedSchedule,
),
]..sort(
(a, b) => a.startTime.compareTo(
b.startTime,
),
),
ifAbsent: () => [schedule], // 날짜에 해당되는 값이 없다면 새로운 캐시 리스트에 새로운 일정 하나만 추가
);
notifyListeners();
}
void deleteSchedule({
required DateTime date,
required String id,
}) async {
final resp = await repository.deleteSchedule(id: id);
cache.update( // 캐시에서 데이터 삭제
date,
(value) => value.where((e) => e.id != id).toList(),
ifAbsent: () => [],
);
notifyListeners();
}
void changeSelectedDate({
required DateTime date,
}) {
selectedDate = date; // 현재 선택된 날짜를 매개변수로 입력받은 날짜로 변경
notifyListeners();
}
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';
import 'package:calendar_scheduler/screen/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // 플러터 프레임워크가 준비될 때 까지 대기
await initializeDateFormatting(); // intl 패키지 초기화 (다국어화)
final database = LocalDatabase(); // DB 생성
GetIt.I.registerSingleton<LocalDatabase>(database); // GetIt에 DB 변수 주입
final repository = ScheduleRepository();
final scheduleProvider = ScheduleProvider(repository: repository);
runApp(
ChangeNotifierProvider( // Provider를 하위 위젯에 제공하기
create: (_) => scheduleProvider,
child: MaterialApp(
home: HomeScreen(),
),
),
);
}
드리프트를 사용할 때 StreamBuilder 를 사용해서 Stream 값을 리스닝 했지만 Provider는 데이터를 불러올 수 있는 watch() 와 read() 함수를 제공해주기 때문에 더이상 StreamBuilder 를 사용할 필요가 없다.
Provider 로 상태(데이터)를 관리할 것이기 때문에 메모리를 많이 차지하는 Stateful 위젯을 사용할 필요가 없다.
(앞선 그림에서 위젯에서 colors 속성을 모두 기억하지 않는 것을 상기해라)
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기
final schedules = provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기
return Scaffold()
.
.
.
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final provider =
context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기
final schedules =
provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기
return Scaffold(
floatingActionButton: FloatingActionButton(
// 새 일정 버튼
backgroundColor: PRIMARY_COLOR,
// 추가 버튼 눌렀을 때 동작할 콜백
onPressed: () {
showModalBottomSheet(
// BottomSheet 열기
context: context,
isDismissible: true, // 배경 탭했을 때 BottomSheet 닫기
builder: (_) => ScheduleBottomSheet(
selectedDate: selectedDate, // 선택된 날짜 selectedDate 넘겨주기
),
isScrollControlled: true, // 할 일 입력창 렌더링
);
},
child: Icon(
// '+' 모양 아이콘
Icons.add,
),
),
body: SafeArea(
// 시스템 UI 피해서 UI 구현
child: Column(
// 달력과 리스트를 세로로 배치
children: [
// 달력 위젯
MainCalendar(
selectedDate: selectedDate, // 선택된 날짜 전달하기
onDaySelected: onDaySelected, // 날짜가 선택됬을 떄 실행할 함수
),
SizedBox(
height: 8.0,
),
//======================================================================
TodayBanner(
selectedDate: selectedDate,
count: schedules.length,
//======================================================================
),
// 오늘 날짜와 할일 개수 보여주는 위젯
SizedBox(
height: 8.0,
),
//======================================================================
Expanded(
// 남는 공간 모두 차지
child: ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return Dismissible(
key: ObjectKey(schedule.id),
direction: DismissDirection.startToEnd,
onDismissed: (DismissDirection direction) {
provider.deleteSchedule(
date: selectedDate, id: schedule.id);
},
child: Padding(
padding: const EdgeInsets.only(
bottom: 8.0, left: 8.0, right: 8.0),
child: ScheduleCard(
startTime: schedule.startTime,
endTime: schedule.endTime,
content: schedule.content,
),
),
);
},
),
),
//======================================================================
],
),
),
);
}
void onDaySelected(DateTime selectedDate, DateTime focusedDate) {
// 날짜 선택될 때 마다 실행할 함수
}
}
class ScheduleBottomSheet extends StatefulWidget {
final DateTime selectedDate; // 선택한 날짜 상위 위젯에서 입력받기
const ScheduleBottomSheet({super.key, required this.selectedDate});
@override
State<ScheduleBottomSheet> createState() => _ScheduleBottomSheetState();
}
class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
final GlobalKey<FormState> formKey = GlobalKey(); // Form 키 생성
int? startTime; // 시작 시간 저장 변수
int? endTime; // 종료 시간 저장 변수
String? content; // 일정 내용 저장 변수
@override
Widget build(BuildContext context) {
// 키보드 높이 가져오기
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Form(
// 텍스트 필드를 한번에 관리할 수 있는 폼
key: formKey, // 폼을 조작할 키 값
child: SafeArea(
child: Container(
height: MediaQuery.of(context).size.height / 2 +
bottomInset, // 화면 반 높이에 키보드 높이 추가하기
color: Colors.white,
child: Padding(
padding: EdgeInsets.only(
left: 8, right: 8, top: 8, bottom: bottomInset),
child: Column(
children: [
Row(
// 시작시간, 종료시간 필드는 가로로 배치
children: [
Expanded(
child: CustomTextField(
// 시작 시간 입력 필드
label: '시작 시간',
isTime: true,
onSaved: (String? val) {
startTime = int.parse(
val!); // 저장이 시작되면 startTime 변수에 텍스트 필드 값 저장
},
validator: timeValidator,
),
),
const SizedBox(width: 16.0),
Expanded(
child: CustomTextField(
// 종료 시간 입력 필드
label: '종료 시간',
isTime: true,
onSaved: (String? val) {
endTime = int.parse(
val!); // 저장이 시작되면 endTime 변수에 텍스트 필드 값 저장
},
validator: timeValidator,
),
),
],
),
SizedBox(height: 8.0),
Expanded(
child: CustomTextField(
// 내용 입력 필드
label: '내용',
isTime: false,
onSaved: (String? val) {
content = val; // 저장이 시작되면 content 변수에 텍스트 필드 값 저장
},
validator: contentValidator,
),
),
//========================================================
SizedBox(
width: double.infinity,
child: ElevatedButton(
// 저장 버튼
onPressed: ()=> onSavePressed(context), // 함수에 context 전달
style: ElevatedButton.styleFrom(
backgroundColor: PRIMARY_COLOR,
),
child: Text('저장'),
),
),
],
),
),
),
));
}
//========================================================
void onSavePressed(BuildContext context) async {
if (formKey.currentState!.validate()) {
// 폼 검증하기
formKey.currentState!.save(); // 폼 저장하기
context.read<ScheduleProvider>().createSchedule(
schedule: ScheduleModel(
id: 'new_model', // 임시 ID
content: content!,
date: widget.selectedDate,
startTime: startTime!,
endTime: endTime!,
),
);
Navigator.of(context).pop(); // 일정 생성 후 화면 뒤로가기
}
}
//========================================================
// 시간 값 검증
String? timeValidator(String? val) {
if (val == null) {
return '값을 입력해 주세요';
}
int? number;
try {
number = int.parse(val);
} catch (e) {
return '숫자를 입력해주세요';
}
if (number < 0 || number > 24) {
return '0시부터 24시 사이를 입력해주세요';
}
return null;
}
// 내용 값 검증
String? contentValidator(String? val) {
if (val == null || val.length == 0) {
return '값을 입력해 주세요';
}
return null;
}
}
import 'package:calendar_scheduler/component/main_calendar.dart';
import 'package:calendar_scheduler/component/schedule_bottom_sheet.dart';
import 'package:calendar_scheduler/component/schedule_card.dart';
import 'package:calendar_scheduler/component/today_banner.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/main.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final provider =
context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기
final schedules =
provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기
return Scaffold(
floatingActionButton: FloatingActionButton(
// 새 일정 버튼
backgroundColor: PRIMARY_COLOR,
// 추가 버튼 눌렀을 때 동작할 콜백
onPressed: () {
showModalBottomSheet(
// BottomSheet 열기
context: context,
isDismissible: true, // 배경 탭했을 때 BottomSheet 닫기
builder: (_) => ScheduleBottomSheet(
selectedDate: selectedDate, // 선택된 날짜 selectedDate 넘겨주기
),
isScrollControlled: true, // 할 일 입력창 렌더링
);
},
child: Icon(
// '+' 모양 아이콘
Icons.add,
),
),
body: SafeArea(
// 시스템 UI 피해서 UI 구현
child: Column(
// 달력과 리스트를 세로로 배치
children: [
// 달력 위젯
MainCalendar(
selectedDate: selectedDate, // 선택된 날짜 전달하기
//======================================================================
onDaySelected: (selectedDate, focusedDate) => onDaySelected(
selectedDate, focusedDate, context), // 날짜가 선택됬을 떄 실행할 함수
//======================================================================
),
SizedBox(
height: 8.0,
),
TodayBanner(
selectedDate: selectedDate,
count: schedules.length,
),
// 오늘 날짜와 할일 개수 보여주는 위젯
SizedBox(
height: 8.0,
),
Expanded(
// 남는 공간 모두 차지
child: ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return Dismissible(
key: ObjectKey(schedule.id),
direction: DismissDirection.startToEnd,
onDismissed: (DismissDirection direction) {
provider.deleteSchedule(
date: selectedDate, id: schedule.id);
},
child: Padding(
padding: const EdgeInsets.only(
bottom: 8.0, left: 8.0, right: 8.0),
child: ScheduleCard(
startTime: schedule.startTime,
endTime: schedule.endTime,
content: schedule.content,
),
),
);
},
),
),
// 할일 위젯
],
),
),
);
}
//======================================================================
void onDaySelected(
DateTime selectedDate, DateTime focusedDate, BuildContext context) {
// 날짜 선택될 때 마다 실행할 함수
final provider = context.read<ScheduleProvider>();
provider.changeSelectedDate(
date: selectedDate,
);
provider.getSchedules(date: selectedDate);
}
}
//======================================================================
드리프트를 사용했을때와 동일하게 잘 동작하는것을 확인할 수 있다!

앞에서 알아봤던 캐시와 긍정적 응답을 이용해서 지연이 없는것 처럼 보이게 코드를 변경해보겠다.
void createSchedule({
required ScheduleModel schedule,
}) async {
final targetDate = schedule.date;
final savedSchedule = await repository.createSchedule(schedule: schedule);
final uuid = Uuid();
final tempId = uuid.v4(); // 유일한 ID 값을 생성합니다.
final newSchedule = schedule.copyWith(
id: tempId, // 임시 ID를 지정한다.
);
// 긍정적 응답 구간. 서버에서 응답을 받기 전에 캐시를 먼저 업데이트함
cache.update(
targetDate,
(value) => [
// 현존하는 캐시 리스트 끝에 새로운 일정 추가
...value,
newSchedule,
]..sort(
(a, b) => a.startTime.compareTo(
b.startTime,
),
),
ifAbsent: () => [newSchedule],
);
notifyListeners(); // 캐시 업데이트 반영
try {
// API 요청
final savedSchedule = await repository.createSchedule(schedule: schedule);
cache.update( // 서버 응답 기반으로 캐시 업데이트
targetDate,
(value) => value
.map((e) => e.id == tempId
? e.copyWith(
id: savedSchedule,
)
: e)
.toList(),
);
} catch (e) {
cache.update( // 삭제 실패 시 캐시 롤백하기
targetDate,
(value) => value.where((e) => e.id != tempId).toList(),
);
}
notifyListeners();
}
void deleteSchedule({
required DateTime date,
required String id,
}) async {
final targetSchedule = cache[date]!.firstWhere(
(e) => e.id == id,
); // 삭제할 일정 기억
cache.update(
// 캐시에서 데이터 삭제
date,
(value) => value.where((e) => e.id != id).toList(),
ifAbsent: () => [],
); // 긍정적 응답 (응답하기 전에 캐시 먼저 업데이트)
notifyListeners(); // 캐시 업데이트 반영
try {
await repository.deleteSchedule(id: id); // 삭제 함수 실행
} catch (e) { // 삭제 실패 시 캐시 롤백하기
cache.update(
date,
(value) => [...value, targetSchedule]..sort(
(a, b) => a.startTime.compareTo(
b.startTime,
),
),
);
}
notifyListeners();
}
적용 전

적용 후

확실히 적용 후가 약간 더 빠른것 같다.