외로운 Nova의 작업실

Flutter 프로그래밍 - 13(서버와 연동하기) 본문

Programming/Flutter

Flutter 프로그래밍 - 13(서버와 연동하기)

Nova_ 2024. 1. 26. 16:51

안녕하세요, 이번장에서는 저번에는 로컬DB에 연결했지만 이번에는 서버와 연동하겠습니다.

 

- 사전 지식

상태 관리를 영어로 State Management라고 부릅니다. 상태는 데이터를 의미하니 결국 상태관리는 데이터 관리입니다. 즉, 현재 상태의 데이터를 관리하는 것입니다. 이제까지 클래스 내부에서 setState()함수를 실행해서 상태를 업데이트해줬습니다. 하지만, 이러한 방식은 작은 프로젝트에서는 아주 효율적이지만 프로젝트가 커지면 커질 수록 같은 변수를 반복적으로 서브 위젯으로 넘겨줘야하니 데이터 관리가 어렵습니다.

 

따라서 글로벌 상태 관리를 통해 한번 넘겨주는 방법을 이용해야합니다.

 

이를 구현하는 방법에는 플러터에서 Bloc, GetX, 리버팟, 프로바이더와 같은 상태 관리 플러그인이 있습니다. 이중에서 가장 사용하기 쉬운 프로바이더를 사용하겠습니다.

 

실제 서버를 운영하는 상황에서는 자연적으로 지연이 생기게됩니다. 이러한 지연을 없애는 방법에는 캐싱이라는 기법을 사용합니다. 이러한 캐싱은 한번 다운로드된 데이터는 런타임중에 캐시에 저장하고 사용합니다. 이때, 긍정적 응답을 사용하면 더욱 빨리 할 수 있습니다.

  • 일반적인 캐시 : 서버에 데이터 저장 요청 => 캐시 저장
  • 긍정적 응답 : 캐시 저장 => 서버에 데이터 저장 요청

이러한 방법을 이용하는 이유는 서버에 데이터 저장 요청에는 지연시간이 있고, 에러확률이 적기때문에 사용합니다.

 

- 사전 준비

이번에는 node.js 서버를 사용할 것입니다. node.js 서버는 요청을 메모리에 저장하고 응답을 간단하게 해줍니다. 데이터베이스 및 처리 코드는 구현하지 않았고, 예시 서버를 사용해 앱과의 연결, 앱의 캐시 사용 부분을 중점으로 코드를 구현하겠습니다. 일단 node.js 서버를 다운로드하고 미리 준비된 서버를 실행하겠습니다.

Node.js — Download (nodejs.org)

 

Node.js — Download

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

위 링크로 들어가서 다운로드 해줍니다. 이후 다음과 예제 서버 폴더에서 다음과 같은 명령어를 입력합니다.

npm install
npm run start:dev

이제 localhost:3000/api로 들어가면 아래와 같은 화면이 나옵니다.

 

- REST API용 모델 구현하기

model/schedule.dart 파일에 있는 Schedule은 드리프트 전용으로 구현된 모델이므로 REST API를 연동할때 사용하기에 적합한 모델을 따로 구현하겠습니다. model/schedule_model.dart 파일을 생성하고 Schedule에 해당하는 모델을 구현하겠습니다.

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으로부터 모델을 만들어내는 생성자
    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으로 변환
    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 파일을 만들고 ScheduleRepository 클래스를 생성하겠습니다.

import 'dart:io';
import 'dart:async';

import 'package:calendar_shedular_local/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>( // resp의 json데이터를 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'];
  }

 

- 글로벌 상태 관리 구현하기 : ScheduleProvider

이제 글로벌 상태의 정보를 담고 변경할 수 있는 ScheduleProvider를 생성해보겠습니다. 이는 ChagneNotifier클래스를 상속받아서 생성됩니다. 우리는 이제 현재 상태(현재 캐시 정보, 선택된 날짜 정보)를 저장하고 변경할때 ScheduleProvider를 통해서 하게됩니다. 쉽게 말하면 현재 상태를 변경하려면 항상 ScheduleProvider를 이용해서 변경합니다./lib/provider/schedule_provider.dart 파일을 만들겠습니다.

import 'package:calendar_shedular_local/model/schedule_model.dart';
import 'package:calendar_shedular_local/repository/schedule_repository.dart';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class ScheduleProvider extends ChangeNotifier{
  late 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() {repository.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 createShedule({ 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, //id에 해당하는 스케줄 삭제 및 없다면 date값이 없다면 빈 리스트 적용 
            (value) => value.where((e) => e.id != id).toList(),
    ifAbsent: () => []);
    
    notifyListeners();
  }
  
  //현재 선택된 날짜 변경 메소드
  void chagneSelectedDate({ required DateTime date,}){
    selectedDate = date;
    notifyListeners();
  }
}

 

- 프로바이더 초기화

이제 최상위에선 선언을 한번 선언해서 ScheduleProvider를 초기화하겠습니다. 최상위 파일인 main.dart에서 초기화를 진행합니다.

import 'package:flutter/material.dart';
import 'package:calendar_shedular_local/screen/home_screen.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:calendar_shedular_local/database/drift_database.dart';
import 'package:get_it/get_it.dart';
import 'package:calendar_shedular_local/provider/schedule_provider.dart';
import 'package:calendar_shedular_local/repository/schedule_repository.dart';
import  'package:provider/provider.dart';

void main() async{

  WidgetsFlutterBinding.ensureInitialized(); //플러터 프레임워크가 준비될때까지 대기

  await initializeDateFormatting(); //intl 초기화(다국어화)

  final database = LocalDatabase();

  GetIt.I.registerSingleton<LocalDatabase>(database); //GetIt에 데이터베이스 변수 주입하기
  
  final repository = ScheduleRepository();
  final scheduleProvider = ScheduleProvider(repository: repository);

  runApp(
    ChangeNotifierProvider(create: (_) => scheduleProvider,
    child: MaterialApp(
      home: HomeScreen(),
    ),)
  );
}

 

-드리프트를 프로바이더로 대체

프로바이더를 사용하면 더는 StreamBuilder를 사용할 필요 없습니다. 따라서 프로바이더는 watch()와 read()함수를 제공해줍니다. watch()는 지속적으로 값이 변경될대마다 build()함수를 재실행해줍니다.  read()함수는 단발성으로 값을 가져올때 사용합니다. 이제 home_screen.dart 파일을 수정해보겠습니다.

import 'package:flutter/material.dart';
import 'package:calendar_shedular_local/component/main_calaner.dart';
import 'package:calendar_shedular_local/component/schedule_card.dart';
import 'package:calendar_shedular_local/component/today_banner.dart';
import 'package:calendar_shedular_local/component/schedule_bottom_sheet.dart';
import 'package:calendar_shedular_local/const/colors.dart';
import 'package:calendar_shedular_local/database/drift_database.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
import 'package:calendar_shedular_local/provider/schedule_provider.dart';

class HomeScreenState 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, //배경을 탭했을때 BottomSheet 닫기
              builder: (_) => ScheduleBottomSheet(selectedDate: selectedDate,),
              isScrollControlled: true
          );
        },
        child: Icon(
          Icons.add,
        ),
      ),
      body: SafeArea(
        child: Column(
          children: [

            MainCalander(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){

  }
}

 

이제 schedule_bottom_sheet.dart 파일도 수정해보겠습니다.

import 'package:flutter/material.dart';
import 'package:calendar_shedular_local/component/custom_text_field.dart';
import 'package:calendar_shedular_local/const/colors.dart';
import 'package:get_it/get_it.dart';
import '../database/drift_database.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:calendar_shedular_local/model/schedule_model.dart';
import 'package:provider/provider.dart';
import 'package:calendar_shedular_local/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;
  } // 내용값 검증
}

 

- 캐시 긍정적 응답 구현

 

import 'package:flutter/material.dart';
import 'package:calendar_shedular_local/component/main_calaner.dart';
import 'package:calendar_shedular_local/component/schedule_card.dart';
import 'package:calendar_shedular_local/component/today_banner.dart';
import 'package:calendar_shedular_local/component/schedule_bottom_sheet.dart';
import 'package:calendar_shedular_local/const/colors.dart';
import 'package:calendar_shedular_local/database/drift_database.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
import 'package:calendar_shedular_local/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, //배경을 탭했을때 BottomSheet 닫기
              builder: (_) => ScheduleBottomSheet(selectedDate: selectedDate,),
              isScrollControlled: true
          );
        },
        child: Icon(
          Icons.add,
        ),
      ),
      body: SafeArea(
        child: Column(
          children: [

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