Must Have 코드팩토리의 플러터 프로그래밍 - 18 ~ 19
book
Must Have 코드팩토리의 플러터 프로그래밍 18 ~ 19
18장 [Project #2] 데이터베이스 적용하기
P.535 (18.3)
테스트하기 : 최종 완성본 결과
// lib/database/drift_database.dart
import 'package:calendar_scheduler/model/schedule.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'dart:io';
part 'drift_database.g.dart';
@DriftDatabase(
tables: [
Schedules,
],
)
class LocalDatabase extends _$LocalDatabase {
LocalDatabase() : super(_openConnection());
Stream<List<Schedule>> watchSchedules(DateTime date) =>
(select(schedules)..where((tbl) => tbl.date.equals(date))).watch();
Future<int> createSchedule(SchedulesCompanion data) =>
into(schedules).insert(data);
Future<int> removeSchedule(int id) =>
(delete(schedules)..where((tbl) => tbl.id.equals(id))).go();
int get schemaVersion => 1;
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'db.sqlite'));
return NativeDatabase(file);
});
}
// lib/database/drift_database.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'drift_database.dart';
// ignore_for_file: type=lint
class $SchedulesTable extends Schedules
with TableInfo<$SchedulesTable, Schedule> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$SchedulesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumn<String> content = GeneratedColumn<String>(
'content', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _dateMeta = const VerificationMeta('date');
@override
late final GeneratedColumn<DateTime> date = GeneratedColumn<DateTime>(
'date', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
static const VerificationMeta _startTimeMeta =
const VerificationMeta('startTime');
@override
late final GeneratedColumn<int> startTime = GeneratedColumn<int>(
'start_time', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _endTimeMeta =
const VerificationMeta('endTime');
@override
late final GeneratedColumn<int> endTime = GeneratedColumn<int>(
'end_time', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns => [id, content, date, startTime, endTime];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'schedules';
@override
VerificationContext validateIntegrity(Insertable<Schedule> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('content')) {
context.handle(_contentMeta,
content.isAcceptableOrUnknown(data['content']!, _contentMeta));
} else if (isInserting) {
context.missing(_contentMeta);
}
if (data.containsKey('date')) {
context.handle(
_dateMeta, date.isAcceptableOrUnknown(data['date']!, _dateMeta));
} else if (isInserting) {
context.missing(_dateMeta);
}
if (data.containsKey('start_time')) {
context.handle(_startTimeMeta,
startTime.isAcceptableOrUnknown(data['start_time']!, _startTimeMeta));
} else if (isInserting) {
context.missing(_startTimeMeta);
}
if (data.containsKey('end_time')) {
context.handle(_endTimeMeta,
endTime.isAcceptableOrUnknown(data['end_time']!, _endTimeMeta));
} else if (isInserting) {
context.missing(_endTimeMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Schedule map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return Schedule(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
content: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}content'])!,
date: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}date'])!,
startTime: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}start_time'])!,
endTime: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}end_time'])!,
);
}
@override
$SchedulesTable createAlias(String alias) {
return $SchedulesTable(attachedDatabase, alias);
}
}
class Schedule extends DataClass implements Insertable<Schedule> {
final int id;
final String content;
final DateTime date;
final int startTime;
final int endTime;
const Schedule(
{required this.id,
required this.content,
required this.date,
required this.startTime,
required this.endTime});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['content'] = Variable<String>(content);
map['date'] = Variable<DateTime>(date);
map['start_time'] = Variable<int>(startTime);
map['end_time'] = Variable<int>(endTime);
return map;
}
SchedulesCompanion toCompanion(bool nullToAbsent) {
return SchedulesCompanion(
id: Value(id),
content: Value(content),
date: Value(date),
startTime: Value(startTime),
endTime: Value(endTime),
);
}
factory Schedule.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Schedule(
id: serializer.fromJson<int>(json['id']),
content: serializer.fromJson<String>(json['content']),
date: serializer.fromJson<DateTime>(json['date']),
startTime: serializer.fromJson<int>(json['startTime']),
endTime: serializer.fromJson<int>(json['endTime']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'content': serializer.toJson<String>(content),
'date': serializer.toJson<DateTime>(date),
'startTime': serializer.toJson<int>(startTime),
'endTime': serializer.toJson<int>(endTime),
};
}
Schedule copyWith(
{int? id,
String? content,
DateTime? date,
int? startTime,
int? endTime}) =>
Schedule(
id: id ?? this.id,
content: content ?? this.content,
date: date ?? this.date,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
@override
String toString() {
return (StringBuffer('Schedule(')
..write('id: $id, ')
..write('content: $content, ')
..write('date: $date, ')
..write('startTime: $startTime, ')
..write('endTime: $endTime')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, content, date, startTime, endTime);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Schedule &&
other.id == this.id &&
other.content == this.content &&
other.date == this.date &&
other.startTime == this.startTime &&
other.endTime == this.endTime);
}
class SchedulesCompanion extends UpdateCompanion<Schedule> {
final Value<int> id;
final Value<String> content;
final Value<DateTime> date;
final Value<int> startTime;
final Value<int> endTime;
const SchedulesCompanion({
this.id = const Value.absent(),
this.content = const Value.absent(),
this.date = const Value.absent(),
this.startTime = const Value.absent(),
this.endTime = const Value.absent(),
});
SchedulesCompanion.insert({
this.id = const Value.absent(),
required String content,
required DateTime date,
required int startTime,
required int endTime,
}) : content = Value(content),
date = Value(date),
startTime = Value(startTime),
endTime = Value(endTime);
static Insertable<Schedule> custom({
Expression<int>? id,
Expression<String>? content,
Expression<DateTime>? date,
Expression<int>? startTime,
Expression<int>? endTime,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (content != null) 'content': content,
if (date != null) 'date': date,
if (startTime != null) 'start_time': startTime,
if (endTime != null) 'end_time': endTime,
});
}
SchedulesCompanion copyWith(
{Value<int>? id,
Value<String>? content,
Value<DateTime>? date,
Value<int>? startTime,
Value<int>? endTime}) {
return SchedulesCompanion(
id: id ?? this.id,
content: content ?? this.content,
date: date ?? this.date,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (content.present) {
map['content'] = Variable<String>(content.value);
}
if (date.present) {
map['date'] = Variable<DateTime>(date.value);
}
if (startTime.present) {
map['start_time'] = Variable<int>(startTime.value);
}
if (endTime.present) {
map['end_time'] = Variable<int>(endTime.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SchedulesCompanion(')
..write('id: $id, ')
..write('content: $content, ')
..write('date: $date, ')
..write('startTime: $startTime, ')
..write('endTime: $endTime')
..write(')'))
.toString();
}
}
abstract class _$LocalDatabase extends GeneratedDatabase {
_$LocalDatabase(QueryExecutor e) : super(e);
late final $SchedulesTable schedules = $SchedulesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [schedules];
}
// lib/model/schedule.dart
import 'package:drift/drift.dart';
class Schedules extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get content => text()();
DateTimeColumn get date => dateTime()();
IntColumn get startTime => integer()();
IntColumn get endTime => integer()();
}
// lib/main.dart
import 'package:calendar_scheduler/screen/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:get_it/get_it.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting();
final database = LocalDatabase();
GetIt.I.registerSingleton<LocalDatabase>(database);
runApp(
MaterialApp(
home: HomeScreen(),
),
);
}
// lib/screen/home_screen.dart
import 'package:flutter/material.dart';
import 'package:calendar_scheduler/component/main_calendar.dart';
import 'package:calendar_scheduler/component/schedule_card.dart';
import 'package:calendar_scheduler/component/today_banner.dart';
import 'package:calendar_scheduler/component/schedule_bottom_sheet.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:get_it/get_it.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DateTime selectedDate = DateTime.utc(
DateTime.now().year,
DateTime.now().month,
DateTime.now().day,
);
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
backgroundColor: PRIMARY_COLOR,
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (_) => ScheduleBottomSheet(
selectedDate: selectedDate,
),
);
},
child: Icon(
Icons.add,
),
),
body: SafeArea(
child: Column(
children: [
MainCalendar(
selectedDate: selectedDate,
onDaySelected: onDaySelected,
),
SizedBox(height: 8.0),
StreamBuilder<List<Schedule>>(
stream: GetIt.I<LocalDatabase>().watchSchedules(selectedDate),
builder: (context, snapshot) {
return TodayBanner(
selectedDate: selectedDate,
count: snapshot.data?.length ?? 0,
);
}
),
SizedBox(height: 8.0),
Expanded(
child: StreamBuilder<List<Schedule>>(
stream: GetIt.I<LocalDatabase>().watchSchedules(selectedDate),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final schedule =
snapshot.data![index];
return Dismissible(
key: ObjectKey(schedule.id),
direction: DismissDirection.startToEnd,
onDismissed: (DismissDirection direction) {
GetIt.I<LocalDatabase>().removeSchedule(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) {
setState(() {
this.selectedDate = selectedDate;
});
}
}
// lib/const/color.dart
import 'package:flutter/material.dart';
const PRIMARY_COLOR = Color(0xff0DB2B2);
final LIGHT_GREY_COLOR = Colors.grey[200]!;
final DARK_GREY_COLOR = Colors.grey[600]!;
final TEXT_FIELD_FILL_COLOR = Colors.grey[300]!;
// lib/component/custom_text_field.dart
import 'package:calendar_scheduler/const/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
final String label;
final bool isTime;
final FormFieldSetter<String> onSaved;
final FormFieldValidator<String> validator;
const CustomTextField({
required this.label,
required this.isTime,
required this.onSaved,
required this.validator,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: PRIMARY_COLOR,
fontWeight: FontWeight.w600,
),
),
Expanded(
flex: isTime ? 0 : 1,
child: TextFormField(
onSaved: onSaved,
validator: validator,
cursorColor: Colors.grey,
maxLines: isTime ? 1 : null,
expands: !isTime,
keyboardType: isTime ? TextInputType.number : TextInputType.multiline,
inputFormatters: isTime
? [
FilteringTextInputFormatter.digitsOnly,
]
: [],
decoration: InputDecoration(
border: InputBorder.none,
filled: true,
fillColor: Colors.grey[300],
suffixText: isTime ? '시' : null,
),
),
),
],
);
}
}
// lib/component/main_calendar.dart
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:calendar_scheduler/const/colors.dart';
class MainCalendar extends StatelessWidget {
final OnDaySelected onDaySelected;
final DateTime selectedDate;
MainCalendar({
required this.onDaySelected,
required this.selectedDate,
});
@override
Widget build(BuildContext context) {
return TableCalendar(
locale: 'ko_kr',
onDaySelected: onDaySelected,
selectedDayPredicate: (date) =>
date.year == selectedDate.year &&
date.month == selectedDate.month &&
date.day == selectedDate.day,
firstDay: DateTime(1800, 1, 1),
lastDay: DateTime(3000, 1, 1),
focusedDay: DateTime.now(),
headerStyle: HeaderStyle(
titleCentered: true,
formatButtonVisible: false,
titleTextStyle: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16.0,
),
),
calendarStyle: CalendarStyle(
isTodayHighlighted: false,
defaultDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: LIGHT_GREY_COLOR,
),
weekendDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: LIGHT_GREY_COLOR,
),
selectedDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
border: Border.all(
color: PRIMARY_COLOR,
width: 1.0,
),
),
defaultTextStyle: TextStyle(
fontWeight: FontWeight.w600,
color: DARK_GREY_COLOR,
),
weekendTextStyle: TextStyle(
fontWeight: FontWeight.w600,
color: DARK_GREY_COLOR,
),
selectedTextStyle: TextStyle(
fontWeight: FontWeight.w600,
color: PRIMARY_COLOR,
),
),
);
}
}
// lib/component/schedule_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:calendar_scheduler/component/custom_text_field.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:get_it/get_it.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
class ScheduleBottomSheet extends StatefulWidget {
final DateTime selectedDate;
const ScheduleBottomSheet({
required this.selectedDate,
Key? key,
}) : super(key: key);
@override
State<ScheduleBottomSheet> createState() => _ScheduleBottomSheetState();
}
class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
final GlobalKey<FormState> formKey = GlobalKey();
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!);
},
validator: timeValidator,
),
),
const SizedBox(width: 16.0),
Expanded(
child: CustomTextField(
label: '종료 시간',
isTime: true,
onSaved: (String? val) {
endTime = int.parse(val!);
},
validator: timeValidator,
),
),
],
),
SizedBox(height: 8.0),
Expanded(
child: CustomTextField(
label: '내용',
isTime: false,
onSaved: (String? val) {
content = val;
},
validator: contentValidator,
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onSavePressed,
style: ElevatedButton.styleFrom(
primary: PRIMARY_COLOR,
),
child: Text('저장'),
),
),
],
),
),
),
),
);
}
void onSavePressed() async {
if (formKey.currentState!.validate()) {
formKey.currentState!.save();
await GetIt.I<LocalDatabase>().createSchedule(
SchedulesCompanion(
startTime: Value(startTime!),
endTime: Value(endTime!),
content: Value(content!),
date: Value(widget.selectedDate),
),
);
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;
}
}
// lib/component/schedule_card.dart
import 'package:calendar_scheduler/const/colors.dart';
import 'package:flutter/material.dart';
class ScheduleCard extends StatelessWidget {
final int startTime;
final int endTime;
final String content;
const ScheduleCard({
required this.startTime,
required this.endTime,
required this.content,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(
width: 1.0,
color: PRIMARY_COLOR,
),
borderRadius: BorderRadius.circular(8.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Time(
startTime: startTime,
endTime: endTime,
),
SizedBox(width: 16.0),
_Content(
content: content,
),
SizedBox(width: 16.0),
],
),
),
),
);
}
}
class _Time extends StatelessWidget {
final int startTime;
final int endTime;
const _Time({
required this.startTime,
required this.endTime,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final textStyle = TextStyle(
fontWeight: FontWeight.w600,
color: PRIMARY_COLOR,
fontSize: 16.0,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${startTime.toString().padLeft(2, '0')}:00',
style: textStyle,
),
Text(
'${endTime.toString().padLeft(2, '0')}:00',
style: textStyle.copyWith(
fontSize: 10.0,
),
),
],
);
}
}
class _Content extends StatelessWidget {
final String content;
const _Content({
required this.content,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Expanded(
child: Text(
content,
),
);
}
}
// lib/component/today_banner.dart
import 'package:calendar_scheduler/const/colors.dart';
import 'package:flutter/material.dart';
class TodayBanner extends StatelessWidget {
final DateTime selectedDate;
final int count;
const TodayBanner({
required this.selectedDate,
required this.count,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final textStyle = TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
);
return Container(
color: PRIMARY_COLOR,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${selectedDate.year}년 ${selectedDate.month}월 ${selectedDate.day}일',
style: textStyle,
),
Text(
'$count개',
style: textStyle,
),
],
),
),
);
}
}
19장 [Project #3] 서버와 연동하기
P.568 (19.4)
테스트하기 : 최종 완성본 결과
// 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();
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.fromJson(
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 {
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 createSchedule({
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(),
);
}
}
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();
}
void changeSelectedDate({
required DateTime date,
}) {
selectedDate = date;
notifyListeners();
}
}
// 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,
});
ScheduleModel.fromJson({
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() {
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/model/schedule.dart
// ... 18장과 동일 ...
// lib/database/drift_database.dart
// ... 18장과 동일 ...
// lib/database/drift_database.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'drift_database.dart';
// **************************************************************************
// DriftDatabaseGenerator
// **************************************************************************
// ignore_for_file: type=lint
class Schedule extends DataClass implements Insertable<Schedule> {
final int id;
final String content;
final DateTime date;
final int startTime;
final int endTime;
const Schedule(
{required this.id,
required this.content,
required this.date,
required this.startTime,
required this.endTime});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['content'] = Variable<String>(content);
map['date'] = Variable<DateTime>(date);
map['start_time'] = Variable<int>(startTime);
map['end_time'] = Variable<int>(endTime);
return map;
}
SchedulesCompanion toCompanion(bool nullToAbsent) {
return SchedulesCompanion(
id: Value(id),
content: Value(content),
date: Value(date),
startTime: Value(startTime),
endTime: Value(endTime),
);
}
factory Schedule.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Schedule(
id: serializer.fromJson<int>(json['id']),
content: serializer.fromJson<String>(json['content']),
date: serializer.fromJson<DateTime>(json['date']),
startTime: serializer.fromJson<int>(json['startTime']),
endTime: serializer.fromJson<int>(json['endTime']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'content': serializer.toJson<String>(content),
'date': serializer.toJson<DateTime>(date),
'startTime': serializer.toJson<int>(startTime),
'endTime': serializer.toJson<int>(endTime),
};
}
Schedule copyWith(
{int? id,
String? content,
DateTime? date,
int? startTime,
int? endTime}) =>
Schedule(
id: id ?? this.id,
content: content ?? this.content,
date: date ?? this.date,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
@override
String toString() {
return (StringBuffer('Schedule(')
..write('id: $id, ')
..write('content: $content, ')
..write('date: $date, ')
..write('startTime: $startTime, ')
..write('endTime: $endTime')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, content, date, startTime, endTime);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Schedule &&
other.id == this.id &&
other.content == this.content &&
other.date == this.date &&
other.startTime == this.startTime &&
other.endTime == this.endTime);
}
class SchedulesCompanion extends UpdateCompanion<Schedule> {
final Value<int> id;
final Value<String> content;
final Value<DateTime> date;
final Value<int> startTime;
final Value<int> endTime;
const SchedulesCompanion({
this.id = const Value.absent(),
this.content = const Value.absent(),
this.date = const Value.absent(),
this.startTime = const Value.absent(),
this.endTime = const Value.absent(),
});
SchedulesCompanion.insert({
this.id = const Value.absent(),
required String content,
required DateTime date,
required int startTime,
required int endTime,
}) : content = Value(content),
date = Value(date),
startTime = Value(startTime),
endTime = Value(endTime);
static Insertable<Schedule> custom({
Expression<int>? id,
Expression<String>? content,
Expression<DateTime>? date,
Expression<int>? startTime,
Expression<int>? endTime,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (content != null) 'content': content,
if (date != null) 'date': date,
if (startTime != null) 'start_time': startTime,
if (endTime != null) 'end_time': endTime,
});
}
SchedulesCompanion copyWith(
{Value<int>? id,
Value<String>? content,
Value<DateTime>? date,
Value<int>? startTime,
Value<int>? endTime}) {
return SchedulesCompanion(
id: id ?? this.id,
content: content ?? this.content,
date: date ?? this.date,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (content.present) {
map['content'] = Variable<String>(content.value);
}
if (date.present) {
map['date'] = Variable<DateTime>(date.value);
}
if (startTime.present) {
map['start_time'] = Variable<int>(startTime.value);
}
if (endTime.present) {
map['end_time'] = Variable<int>(endTime.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SchedulesCompanion(')
..write('id: $id, ')
..write('content: $content, ')
..write('date: $date, ')
..write('startTime: $startTime, ')
..write('endTime: $endTime')
..write(')'))
.toString();
}
}
class $SchedulesTable extends Schedules
with TableInfo<$SchedulesTable, Schedule> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$SchedulesTable(this.attachedDatabase, [this._alias]);
final VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: 'PRIMARY KEY AUTOINCREMENT');
final VerificationMeta _contentMeta = const VerificationMeta('content');
@override
late final GeneratedColumn<String> content = GeneratedColumn<String>(
'content', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
final VerificationMeta _dateMeta = const VerificationMeta('date');
@override
late final GeneratedColumn<DateTime> date = GeneratedColumn<DateTime>(
'date', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
final VerificationMeta _startTimeMeta = const VerificationMeta('startTime');
@override
late final GeneratedColumn<int> startTime = GeneratedColumn<int>(
'start_time', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
final VerificationMeta _endTimeMeta = const VerificationMeta('endTime');
@override
late final GeneratedColumn<int> endTime = GeneratedColumn<int>(
'end_time', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns => [id, content, date, startTime, endTime];
@override
String get aliasedName => _alias ?? 'schedules';
@override
String get actualTableName => 'schedules';
@override
VerificationContext validateIntegrity(Insertable<Schedule> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('content')) {
context.handle(_contentMeta,
content.isAcceptableOrUnknown(data['content']!, _contentMeta));
} else if (isInserting) {
context.missing(_contentMeta);
}
if (data.containsKey('date')) {
context.handle(
_dateMeta, date.isAcceptableOrUnknown(data['date']!, _dateMeta));
} else if (isInserting) {
context.missing(_dateMeta);
}
if (data.containsKey('start_time')) {
context.handle(_startTimeMeta,
startTime.isAcceptableOrUnknown(data['start_time']!, _startTimeMeta));
} else if (isInserting) {
context.missing(_startTimeMeta);
}
if (data.containsKey('end_time')) {
context.handle(_endTimeMeta,
endTime.isAcceptableOrUnknown(data['end_time']!, _endTimeMeta));
} else if (isInserting) {
context.missing(_endTimeMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Schedule map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return Schedule(
id: attachedDatabase.options.types
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
content: attachedDatabase.options.types
.read(DriftSqlType.string, data['${effectivePrefix}content'])!,
date: attachedDatabase.options.types
.read(DriftSqlType.dateTime, data['${effectivePrefix}date'])!,
startTime: attachedDatabase.options.types
.read(DriftSqlType.int, data['${effectivePrefix}start_time'])!,
endTime: attachedDatabase.options.types
.read(DriftSqlType.int, data['${effectivePrefix}end_time'])!,
);
}
@override
$SchedulesTable createAlias(String alias) {
return $SchedulesTable(attachedDatabase, alias);
}
}
abstract class _$LocalDatabase extends GeneratedDatabase {
_$LocalDatabase(QueryExecutor e) : super(e);
late final $SchedulesTable schedules = $SchedulesTable(this);
@override
Iterable<TableInfo<Table, dynamic>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [schedules];
}
// lib/main.dart
import 'package:calendar_scheduler/screen/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:get_it/get_it.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';
import 'package:provider/provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting();
final database = LocalDatabase();
final repository = ScheduleRepository();
final scheduleProvider = ScheduleProvider(repository: repository);
GetIt.I.registerSingleton<LocalDatabase>(database);
runApp(
ChangeNotifierProvider(
create: (_) => scheduleProvider,
child: MaterialApp(
home: HomeScreen(),
),
),
);
}
// lib/screen/home_screen.dart
import 'package:flutter/material.dart';
import 'package:calendar_scheduler/component/main_calendar.dart';
import 'package:calendar_scheduler/component/schedule_card.dart';
import 'package:calendar_scheduler/component/today_banner.dart';
import 'package:calendar_scheduler/component/schedule_bottom_sheet.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:get_it/get_it.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:provider/provider.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
class HomeScreen extends StatelessWidget {
DateTime selectedDate = DateTime.utc(
DateTime.now().year,
DateTime.now().month,
DateTime.now().day,
);
@override
Widget build(BuildContext context) {
final provider = context.watch<ScheduleProvider>();
final selectedDate = provider.selectedDate;
final schedules = provider.cache[selectedDate] ?? [];
return Scaffold(
floatingActionButton: FloatingActionButton(
backgroundColor: PRIMARY_COLOR,
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (_) => ScheduleBottomSheet(
selectedDate: selectedDate,
),
);
},
child: Icon(
Icons.add,
),
),
body: SafeArea(
child: Column(
children: [
MainCalendar(
selectedDate: selectedDate,
onDaySelected: (selectedDate, focusedDate) =>
onDaySelected(selectedDate, focusedDate, context),
),
SizedBox(height: 8.0),
TodayBanner(
selectedDate: selectedDate,
count: 0,
),
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);
}
}
// lib/const/colors.dart
// ... 18장과 동일 ...
// lib/component/custom_text_field.dart
// ... 18장과 동일 ...
// lib/component/main_calendar.dart
// ... 18장과 동일 ...
// lib/component/schedule_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:calendar_scheduler/component/custom_text_field.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:get_it/get_it.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:provider/provider.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
class ScheduleBottomSheet extends StatefulWidget {
final DateTime selectedDate;
const ScheduleBottomSheet({
required this.selectedDate,
Key? key,
}) : super(key: key);
@override
State<ScheduleBottomSheet> createState() => _ScheduleBottomSheetState();
}
class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
final GlobalKey<FormState> formKey = GlobalKey();
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!);
},
validator: timeValidator,
),
),
const SizedBox(width: 16.0),
Expanded(
child: CustomTextField(
label: '종료 시간',
isTime: true,
onSaved: (String? val) {
endTime = int.parse(val!);
},
validator: timeValidator,
),
),
],
),
SizedBox(height: 8.0),
Expanded(
child: CustomTextField(
label: '내용',
isTime: false,
onSaved: (String? val) {
content = val;
},
validator: contentValidator,
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => onSavePressed(context),
style: ElevatedButton.styleFrom(
primary: 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',
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;
}
}
// lib/component/schedule_card.dart
// ... 18장과 동일 ...
// lib/component/today_banner.dart
// ... 18장과 동일 ...
참고
이 글은 골든래빗 《Must Have 코드팩토리의 플러터 프로그래밍 2판》의 스터디 내용 입니다.
스터디
Q.
A.