18장 서버와 연동하기

송기영·2024년 1월 9일
0

플러터

목록 보기
20/25

17장에 이어 실제 REST API를 서버와 연동하고 효율적으로 데이터를 관리할 수 있게 상태 관리와 캐싱 작업을 진행한다.

서버는 코드팩토리의 플러터 프로그래밍에서 제공해주는 18장의 노드서버를 사용했습니다.

18.1 구현하기

18.1.1 REST API용 모델 구현

// 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);
  }
}

18.1.2 API 요청 기능 구현

// 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"];
  }
}

18.1.3 글로벌 상태 관리 구현: ScheduleProvider

// 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();
  }
}

18.1.4 main.dart

// 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()),
  ));
}

18.1.5 home_screen.dart

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);
  }
}

18.1.6 schedule_bottom_sheet.dart

// 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;
  }
}
profile
업무하면서 쌓인 노하우를 정리하는 블로그🚀 풀스택 개발자를 지향하고 있습니다👻

0개의 댓글