17장에 이어 실제 REST API를 서버와 연동하고 효율적으로 데이터를 관리할 수 있게 상태 관리와 캐싱 작업을 진행한다.
서버는 코드팩토리의 플러터 프로그래밍에서 제공해주는 18장의 노드서버를 사용했습니다.
// lib/model/schedule_model.dart
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,
});
// json으로 부터 모델을 만들어 내는 생성자
ScheduleModel.formJson({
required Map<String, dynamic> json,
}) : id = json["id"],
content = json["content"],
date = DateTime.parse(json["date"]),
startTime = json["startTime"],
endTime = json["endTime"];
// 모델을 다시 Json으로 변환
Map<String, dynamic> toJson() {
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);
}
}
// lib/repository/schedule_repository.dart
import "dart:async";
import "dart:io";
import "package:calendar_scheduler/model/schedule_model.dart";
import "package:dio/dio.dart";
class ScheduleRepository {
final _dio = Dio();
// 안드로이드에서는 10.0.2.2가 localhost임
final _targetUrl =
"http://${Platform.isAndroid ? "10.0.2.2" : "localhost"}:3000/schedule";
Future<List<ScheduleModel>> getSchedules({required DateTime date}) async {
final resp = await _dio.get(_targetUrl, queryParameters: {
"date":
"${date.year}${date.month.toString().padLeft(2, "0")}${date.day.toString().padLeft(2, "0")}",
});
return resp.data
.map<ScheduleModel>((x) => ScheduleModel.formJson(json: x))
.toList();
}
Future<String> createSchedule({required ScheduleModel schedule}) async {
final json = schedule.toJson();
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});
return resp.data?["id"];
}
}
// lib/provider/schedule_provider.dart
import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
class ScheduleProvider extends ChangeNotifier {
// api요청 로직을 담은 클래스
final ScheduleRepository repository;
// 성택한 날짜
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);
// 캐시 날짜 업데이트
cache.update(date, (value) => resp, ifAbsent: () => resp);
// 리슨하는 위젯들 업데이트
notifyListeners();
}
void createSchedules({required ScheduleModel schedule}) async {
final targetDate = schedule.date;
// 긍정적 응답 추가
final uuid = Uuid();
final tempId = uuid.v4();
final newSchedule = schedule.copyWith(id: tempId);
// 긍정적 응답 구간
cache.update(
targetDate,
(value) => [
...value,
newSchedule,
]..sort(
(a, b) => a.startTime.compareTo(
b.startTime,
),
),
ifAbsent: () => [newSchedule]);
notifyListeners();
try {
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 deleteSchedules({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)));
}
cache.update(
date,
(value) => value.where((e) => e.id != id).toList(),
ifAbsent: () => [],
);
notifyListeners();
}
void changeSelectedDate({
required DateTime date,
}) async {
selectedDate = date;
notifyListeners();
}
}
// lib/main.dart
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:intl/date_symbol_data_local.dart';
import "package:get_it/get_it.dart";
import 'package:provider/provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// intl 패키지 초기화(다국어화)
await initializeDateFormatting("ko_kr", null);
// DB 생성
final database = LocalDatabase();
final repository = ScheduleRepository();
final scheduleProvider = ScheduleProvider(repository: repository);
// GetIt에 데이터베이스 변수 주입
GetIt.I.registerSingleton<LocalDatabase>(database);
// Provider 하위 위젯에 제공하기
runApp(ChangeNotifierProvider(
create: (_) => scheduleProvider,
child: MaterialApp(home: HomeScreen()),
));
}
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/provider/schedule_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HomeScreen extends StatelessWidget {
DateTime selectedDate = DateTime.utc(
DateTime.now().year, DateTime.now().month, DateTime.now().day);
Widget build(BuildContext context) {
// 프로바이더 변경이 있을 때마다 build() 함수 재 실행
final provider = context.watch<ScheduleProvider>();
// 선택된 날짜 가져오기
final selectedDate = provider.selectedDate;
// 선택된 날짜에 해당되는 일정들 가져오기
final schedules = provider.cache[selectedDate] ?? [];
return Scaffold(
floatingActionButton: FloatingActionButton(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0))),
backgroundColor: PRIMARY_COLOR,
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true, // 배경 탭 했을때 화면닫기
builder: (_) => ScheduleBottomSheet(
selectedDate: selectedDate,
),
// BottomSheet의 높이를 화면의 최대 높이로 정의하고 스크롤 가능하게 변경
isScrollControlled: true);
},
child: const Icon(Icons.add),
),
body: SafeArea(
child: Column(
children: [
MainCalendar(
selectedDate: selectedDate,
onDaySelected: (selectedDate, focusedDate) =>
onDaySelected(selectedDate, focusedDate, context),
),
const SizedBox(
height: 8.0,
),
TodayBanner(selectedDate: selectedDate, count: schedules.length),
const SizedBox(
height: 8.0,
),
Expanded(
child: ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
// 현재 index에 해당되는 일정
final schedule = schedules[index];
return Dismissible(
key: ObjectKey(schedule.id),
direction: DismissDirection.startToEnd,
onDismissed: (DismissDirection direction) {
// 밀기 했을때 실핼 함수
provider.deleteSchedules(
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);
}
}
// lib/component/schedule_bottom_sheet.dart
import 'package:calendar_scheduler/component/custom_text_field.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
class ScheduleBottomSheet extends StatefulWidget {
// 선택된 날짜 상위 위젯에서 입력받기
final DateTime selectedDate;
const ScheduleBottomSheet({required this.selectedDate, super.key});
State<ScheduleBottomSheet> createState() => _ScheduleBottomSheetState();
}
class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
final GlobalKey<FormState> formKey = GlobalKey();
int? startTime; // 시작 시간 저장 변수
int? endTime; // 종료 시간 저장 변수
String? content; // 일정 내용 저장 변수
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 변수에 텍스트 필드 값 저장
startTime = int.parse(val!);
},
validator: timeValidator,
)),
SizedBox(
width: 16.0,
),
Expanded(
child: CustomTextField(
label: "종료 시간",
isTime: true,
onSaved: (String? val) {
// 저장이 실행되면 endTime 변수에 텍스트 필드 값 저장
endTime = int.parse(val!);
},
validator: timeValidator,
)),
],
),
const SizedBox(
height: 8.0,
),
Expanded(
child: CustomTextField(
label: "내용",
isTime: false,
onSaved: (String? val) {
// 저장이 실행되면 content 변수에 텍스트 필드 값 저장
content = val;
},
validator: contentValidator,
)),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => onSavePressed(context),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(1)),
backgroundColor: PRIMARY_COLOR,
),
child: const Text(
"저장",
style: TextStyle(color: Colors.white),
),
),
)
],
),
),
),
),
);
}
void onSavePressed(BuildContext context) async {
if (formKey.currentState!.validate()) {
formKey.currentState!.save();
context.read<ScheduleProvider>().createSchedules(
schedule: ScheduleModel(
id: "new_Model",
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;
}
}