⭐️ 개발/플러터

[프로젝트] 일정 스케줄러

짱구러버 2022. 12. 22. 18:58
728x90

들어가기 앞서...

1. 주요로 봐야하는 점들...

  • SQLite 를 이용한 테이터 관리
  • 간단한 SQL 문법
  • Drift 패키지(SQLite ORM)
  • Table Calendar 패키지 (캘린더)
  • Getlt 패키지 (Dependency Injection)
  • Spinkit 패키지 (이쁜 로딩 위젯) 
  • Intl 패키지(다국어화)
  • TextField (글자 인풋받기)

본문으로...

1.  세팅!

// 실제 출시 시에만
dependencies:
  table_calendar: ^3.0.3
  intl: ^0.17.0
  drift: ^1.4.0
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.0.0
  path: ^1.8.1

// 개발 시에만(출시하면 제외된다.)
dev_dependencies:
  drift_dev: ^1.4.0
  build_runner: ^2.1.7
  
// 플러그인들이 의존하는 버전이 서로 다르다는 뜻, 몇가지 버전중 밑에 해당하는 것에 의존하겠다. 라는 뜻! 
dependency_overrides:
  path: ^1.8.1

 

 

2. TableCalender 사용법!

// 모두 DateTime 타입이다.

return TableCalendar(
    focusedDay: DateTime.now(), // 몇월을 보여줄지
    firstDay: DateTime(1800), // 날짜 언제부터 선택 가능
    lastDay: DateTime(3000) // 날짜 어디까지 선택 가능
)

이렇게만 넣어줘도 출력된다.

 

 

 

 

 

 

 

 

 

3. TableCalender 세부 세팅

...
class _CalenderState extends State<Calender> {
  // 어떤 날짜를 눌렀을지를 담는 변수 선언
  DateTime? selectedDay;

  @override
  Widget build(BuildContext context) {
    return TableCalendar(
      focusedDay: DateTime.now(),
      firstDay: DateTime(1800),
      lastDay: DateTime(3000),
      // 헤더 스타일
      headerStyle: HeaderStyle(
        // 2주씩 보이게 하는 버튼
        formatButtonVisible: false,
        // 제목 가운데로
        titleCentered: true,
        // 제목 텍스트 스타일
        titleTextStyle: TextStyle(
          fontWeight: FontWeight.w700,
          fontSize: 16.0,
        ),
      ),
      // 캘린더에서 어떤 특정 날짜를 선택할때 실행된다!
	  // 선택한 날짜가 selectedDay 파라미터로 들어오게 된다.
      // (DateTime? selectedDay;)그래서 상단에 State클래스 안에 저장을 하려고 변수를 선언해둔것이고, 
      onDaySelected: (DateTime selectedDay, DateTime focusedDay) {
        print('selectedDay : $selectedDay');
        // 바뀔때마다 setState 로 상단에 선언한 변수에 넣어준다.
        setState(() {
          this.selectedDay = selectedDay;
        });
      },
      // 특정 날짜가 선택된 지정날짜로 지정 되어야하는지 함수를 넣어주고 bool 값으로 return 
      true: 그 날짜가 선택 o 
      false: 그 날짜 선택 x      
      selectedDayPredicate: (DateTime date) {
        if(selectedDay == null) { // 
          return false; // 어떤 날짜도 되지 않았다.
        }
        // 비교하고 있는 년월일, 우리가 선택한 년월일이 같다면 선택한것이다.
        return date.year == selectedDay!.year &&
            date.month == selectedDay!.month &&
            date.day == selectedDay!.day;
      },
    );
  }
}

연한 파랑 : 오늘 날짜

진한 파랑 :  내가 선택한 날짜 

 

4. TableCalender 스타일링!

 

class _CalenderState extends State<Calender> {
  DateTime? selectedDay;

  @override
  Widget build(BuildContext context) {
    final defaultBoxDecoration = BoxDecoration(
      color: Colors.grey[200],
      borderRadius: BorderRadius.circular(8.0),
    );

    final defaultTextStyle = TextStyle(
      color: Colors.grey[600],
      fontWeight: FontWeight.w700,
    );

    return TableCalendar(
      focusedDay: DateTime.now(),
      firstDay: DateTime(1800),
      lastDay: DateTime(3000),
      // 헤더 스타일
      headerStyle: HeaderStyle(
        // 2주씩 보이게 하는 버튼
        formatButtonVisible: false,
        // 제목 가운데로
        titleCentered: true,
        // 제목 텍스트 스타일
        titleTextStyle: TextStyle(
          fontWeight: FontWeight.w700,
          fontSize: 16.0,
        ),
      ),
      calendarStyle: CalendarStyle(
          // 오늘 날짜 보여주기 여부
          isTodayHighlighted: false,
          // 평일 꾸미기
          defaultDecoration: defaultBoxDecoration,
          // 주말 꾸미기
          weekendDecoration: defaultBoxDecoration,
          // 선택한 날 꾸미기
          selectedDecoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(8.0),
              border: Border.all(
                color: PRIMARY_COLOR,
                width: 1.0,
              )),
          // 평일 텍스트 스타일
          defaultTextStyle: defaultTextStyle,
          // 주말 택스트 스타일
          weekendTextStyle: defaultTextStyle,
          // 선택 날 텍스트 스타일
          selectedTextStyle: defaultTextStyle.copyWith(color: PRIMARY_COLOR)),
      // 특정날짜를 선택할때 실행!
      ...
      // 특정 날짜가 선택된 지정날짜로 지정 되어야하는지 함수를 넣어주고 bool 값으로 return
      ...
  }
}

스타일 자세히 보기) 

더보기

헤더 스타일  HeaderStyle

  • 2주 버튼 보이게 하는 여부 (false : 안보이게)
  • 제목 가운데로 하는 여부 (true : 가운데로 위치)
  • 제목 텍스트 스타일 (굵기 : w700, 크기: 16)

캘린더 스타일 CalenderStyle

  • 꾸미기 Decoration
    • 오늘 날짜 보여주기 여부 (false: 안보이게)
    • 평일 (색상 : 회색[200], 테두리 : 8 만큼)
    • 주말 (색상 : 회색[200], 테두리 : 8 만큼)
    • 선택 날 (색상 : 흰색, 테두리 : 8 만큼, 테두리 색: 메인색상, 테두리 넓이 : 1)
  • 텍스트 스타일 textStyle
    • 평일 (색상 : 회색[600], 글씨 무게(굵기) : w700)
    • 주말 (색상 : 회색[600], 글씨 무게(굵기) : w700)
    • 선택 날 (색상 : 메인 색상, 글씨 무게(굵기) : w700)

 

 

5. 이슈 수정하기!

상황 보기 영상)

2022 년 12월 기준 11월 30일 클릭하면

1) 포커싱될 때마다 빨간표시 

에러 보기)

더보기

#2      BoxDecoration.debugAssertIsValid (package:flutter/src/painting/box_decoration.dart:129:12)

에러를 클릭해서 따라가보니, 동그라미인경우 border radius 를 사용 불가하다. 라고 출력

// box_decoration.dart

@override
bool debugAssertIsValid() {
  assert(shape != BoxShape.circle || borderRadius == null); // Can't have a border radius if you're a circle.
  return super.debugAssertIsValid();
}

Table Calender 는 날짜를 선택할때 기본값 동그라미로 되어있다.

애니메이션이 진행되는 도중에 동그라미로 변하는데, 동그라미를 사각형으로 변경해주면 된다.

outsideDecoration: BoxDecoration(shape: BoxShape.rectangle),

2) 해당 월로 이동 안됌 

이전 날짜를 클릭하면 포커싱된 날을 해당 날짜로 포커싱해주면된다.

 

class _CalenderState extends State<Calender> {
  ...
  // 추가!
  // 선택된 날을 담기 위한 변수 선언(default 는 오늘!)
  DateTime focusedDay = DateTime.now();

  @override
  Widget build(BuildContext context) {
  ...
    return TableCalendar(
      // 변경! : DateTiem.now() -> focusedDay 
      // 이유! : 포커싱하는날을 오늘날만 하는게 아니라 선택하는 날짜에 따라 달라져야한다.
      focusedDay: focusedDay,
      
      ...      
      
      // 특정 날짜를 선택할때 실행!
      onDaySelected: (DateTime selectedDay, DateTime focusedDay) {
        setState(() {
          this.selectedDay = selectedDay;
          // 추가!
          // 이유! : 선택할때마다 포커싱날짜 변수에 담아주기 위함
          this.focusedDay = selectedDay;
        });
      },
      ...
    );
  }
}

 

6. 다국어 기능 추가!

1) locale 추가

class _CalenderState extends State<Calender> {
  ...

  @override
  Widget build(BuildContext context) {
   ...
    return TableCalendar(
        locale: 'ko_KR',
    	...
    )
}

2) main.dart 에 패키지 불러오기 

2-1) runApp 하면 app 이 실행이 되는데, app 하기전에 intl 패키지를 초기화할 것이다.

결과 intl 패키지에 있는 모든 언어를 사용할 수 있다.

import 'package:intl/date_symbol_data_local.dart';

void main() async {
// fluuter 프레임 워크가 준비가 된 상태인지 확인하는 함수
// runApp() 이 실행되기전 initializeDateFormatting() 함수를 사용하기 위해서 그 전에 프레임 워크가 준비가 되었는지 확인해줘야한다.
  WidgetsFlutterBinding.ensureInitialized();
  
  await initializeDateFormatting();
  
  runApp(
    MaterialApp(
      theme: ThemeData(fontFamily: 'NotoSans'),
      home: HomeScreen(),
    ),
  );
}

원래 runApp() 이 실행되면, WidgetsFlutterBinding.ensureInitialized(); 이 자동적으로 실행이 되는데, runApp() 하기전에 실행해야할 코드가 있기때문에 작성해둔것! 

  WidgetsFlutterBinding.ensureInitialized() 란? 

 

7. TodayBanner 설계

home_screen.dart 에서 selecteddDay 와 focusedDay 를 관리하고 파라미터로 내려줄것이다.

이유: 캘린더 클래스에서 사용하던 selectedDay와 focuesedDay 를 조금더 위클래스에서 상태관리해서 캘린더클래스와 TodayBanner 클래스에 내려주기 위함

그리고 Calender.dart 는 상태관리를 안하니 stless 로 다시 변경해주었다. 부모 클래스에다가 상태관리를 옮겨서 내려줬으니!

class _HomeScreenState extends State<HomeScreen> {
  // 선택 날짜 변수(default 는 DateTime(년, 월, 일))
  DateTime selectedDay = DateTime(
    DateTime.now().year,
    DateTime.now().month,
    DateTime.now().day,
  );
  // 포커싱된 날짜 변수, (default 는 현재 날짜)
  DateTime focusedDay = DateTime.now();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            Calender(
              selectedDay: selectedDay,
              focusedDay: focusedDay,
              onDaySelected: onDaySelected,
            ),
            SizedBox(height: 8.0),
            TodayBanner(
              selectedDay: selectedDay,
              scheduleCount: 3,
            ),
            SizedBox(height: 8.0),            
          ],
        ),
      ),
    );
  }
  
  // 날짜 선택하는 함수도 위에 관리, 원래 캘린더클래스에 있던 것!
  onDaySelected(DateTime selectedDay, DateTime focusedDay) {
    print('selectedDay : $selectedDay');
    setState(() {
      this.selectedDay = selectedDay;
      this.focusedDay = selectedDay;
    });
  }
}

today_banner.dart UI 및 구조 설계

class TodayBanner extends StatelessWidget {
  final DateTime selectedDay;
  final int scheduleCount;

  const TodayBanner(
      {Key? key, required this.selectedDay, required this.scheduleCount})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    final textStyle = TextStyle(
      fontWeight: FontWeight.w700,
      color: Colors.white,
    );
    return Container(
      color: PRIMARY_COLOR,
      child: Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: 16.0,
          vertical: 8.0,
        ),
        child:
            Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
          Text(
            '${selectedDay.year}년 ${selectedDay.month}월 ${selectedDay.day}일',
            style: textStyle,
          ),
          Text(
            '${scheduleCount}개',
            style: textStyle,
          ),
        ]),
      ),
    );
  }
}

8. ScheduleCard 설계

class ScheduleCard extends StatelessWidget {
  final int startTime;
  final int endTime;
  final String content;
  final Color color;

  const ScheduleCard(
      {Key? key,
      required this.startTime,
      required this.endTime,
      required this.content,
      required this.color})
      : 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: Row(
          children: [
            _Time(
              startTime: startTime,
              endTime: endTime,
            ),
            SizedBox(width: 16.0),
            _Content(
              content: content,
            ),
            SizedBox(width: 16.0),
            _Category(
              color: color,
            ),
          ],
        ),
      ),
    );
  }
}

class _Time extends StatelessWidget {
  final int startTime;
  final int endTime;

  const _Time({Key? key, required this.startTime, required this.endTime})
      : 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({Key? key, required this.content}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Expanded(child: Text(content));
  }
}

class _Category extends StatelessWidget {
  final Color color;

  const _Category({Key? key, required this.color}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
      width: 16.0,
      height: 16.0,
    );
  }
}

여기서 스타일로 한번 봐야하는 부분은 

스케쥴 카드의 content 스타일이 위로 정렬 하려고 한다.

 

만약, Row 위젯에 crosAxisAlignment: CrossAxisAlignment.start, 추가한다면?

현재 Row() 위젯을 잘 봐야 한다.

시간, 내용, 카테고리 3가지 클래스가 잇다.

Row() 는 가로 형태로 나열하는것이고, crossAxisAlignment: CrossAxisAlignment.start 는 반대축(세로 중 맨 위 부터 시작)

을 한 것이다.

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: Row(
        // 여기다 하면 될까?  crosAxisAlignment: CrossAxisAlignment.start,
          children: [
            _Time(
              startTime: startTime,
              endTime: endTime,
            ),
            SizedBox(width: 16.0),
            _Content(
              content: content,
            ),
            SizedBox(width: 16.0),
            _Category(
              color: color,
            ),
          ],
        ),
      ),
    );

결과) 

_content 와 _category 클래스가 같이 올라간것을 볼 수 있다.

하지만 우리가 원하는 결과물)

_content 만 올라가고, _category 는 그대로!

 

원인 부터 알자!

높이가 Container 위젯이 Row() 위젯 보다 더 높은 높이를 차지하고 있는데도, text 위젯은 차지할수 있는 최대한의 사이즈를 안차지 하고있는 상황이 원인인것이다.

 

2가지 방법이 있다.

1번째 방법) Strech를 사용해서 보여주는데, SizedBox(height) 를 정적으로 설정하고 하기! 

class _Content extends StatelessWidget {
  final String content;

  const _Content({Key? key, required this.content}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Expanded(child: Container(color: Colors.blue, child: Text(content)));
  }
}
child: SizedBox(
  height: 300,
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,

 

 

 

 

 

 

 

2번쨰 방법) IntrinsicHeight() 사용하기!

IntrinsicHeight() 위젯을 사

용하면, child : Row() 위젯 안에 들어있는 위젯중에서 가장 높이가 높은 위젯이랑 같은 사이즈로 만들어준다.

 

child: IntrinsicHeight(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,

마저 다닥다닥 붙어있으니 답답해보이니깐, Padding 적용

// home_screen.dart
...
Padding(
  padding: const EdgeInsets.symmetric(horizontal: 8.0),
  child: ScheduleCard(
      startTime: 8, endTime: 9, content: '기상', color: Colors.red),
)

9. ListView 사용!

ScheduleCard 위젯을 여러개 복사해서 붙여넣으면 밑으로 주루룩 출력이 될건데, 어느 순간 화면을 벗어나게 된다.

그렇다면 스크롤이 가능하게끔 하는 기능은 ListView() 를 사용하면 된다.

child: LisetView.builder(
	itemCount: 3,
	itemBuilder: (context, index) {
    	return ScheduleCard(
        	startTime: 8,
            endTime: 9,
            content: '기상',
            color: Colors.red,
        )
    }
),

추가로 ListView 가 얼만큼의 사이즈로 출력이 되어야하는지, 명시가 안되어있기 때문에 에러가 난다.

그러면 Expanded() 위젯으로 ListView 가 남는 나머지 공간을 모두 차지하라고 명시해주면 된다.

 

에러 내용 )

더보기

Viewports expand in the scrolling direction to fill their container. In this case, a vertical viewport was given an unlimited amount of vertical space in which to expand. This situation typically happens when a scrollable widget is nested inside another scrollable widget.

 

/*

결론 : 수직 공간이 임의로 정해져있으니, ListView가 출력 될 수 있게 수직 공간을 정해주면 된다.

뷰포트가 스크롤 방향으로 확장되어 컨테이너를 채웁니다. 이 경우 수직 뷰포트에는 확장할 수 있는 수직 공간이 무제한으로 제공되었습니다. 이 상황은 일반적으로 스크롤 가능한 위젯이 다른 스크롤 가능한 위젯 내부에 중첩되어 있을 때 발생합니다.

*/

ListView 의 장점 

메모리에 굉장히 유리하다.

아무리 itemCount 숫자가 100이라고 가정한다면?

item Builder 가 0~ 99 까지의 index 를 한번에 출력하려는 것이 아닌 0-9 까지의 인덱스만 출력하고 마지막인덱스에 도달했을때, itemBuilder 가 다음의 index를 가져온다.그렇다면 굉장히 메모리에 부담이 되지 않을것이다.

 

ListView.spearated(
	itemCount:100,
    separatorBuilder: (context, index) {
    // itemBuilder 한번 불릴때마다 그 다음에 불린다.
    	return SizedBox(height: 8.0);
    },
    itemBuilder: (context, index) {
    	return Schedulecard(
        	...
        );
    }
)

10. Floatting button 생성!

10-1) 플로팅 버튼 생성

return Scaffold(
	// 플로팅 버튼 
	floatingActionButton: rederFloatingActionButton(),
    body: SafeArea(
	    ...
    )	
)

// 플로팅 버튼 함수 
FloatingActionButton renderFloatingActionButton() {
    return FloatingActionButton(
      onPressed: () {
      // 바텀 시트 모달 
        showModalBottomSheet(context: context, builder: (_) {
          return Container(color: Colors.white, height: 300,);
        });
      },
      backgroundColor: PRIMARY_COLOR,
      child: Icon(Icons.add),
    );
  }

10-2) 바텀 시트 모달 꾸미기 

1) TextField() 를 사용하면 클릭하면 키보드 패드가 나온다. 가려지기때문에, 그거에 맞게 올려줘야한다.

//위 아래 왼 오른 쪽으로 시스템적으로 가려진 부분을 viewInsets 로 접근이 가능하다.

MedizQuery.of(context).viewInsets.top
MedizQuery.of(context).viewInsets.bottom
MedizQuery.of(context).viewInsets.left
MedizQuery.of(context).viewInsets.right
@override
  Widget build(BuildContext context) {
    // 시스템적으로 가려진 부분
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;

    return Container(
    // 컨테이너의 높이는 휴대폰의 전체길이 / 2 + 시스템적으로 가려진 부분
      height: MediaQuery.of(context).size.height / 2 + bottomInset,
      color: Colors.white,
      child: Padding(
      // 패딩으로 감싸주고, 가려진 부분(키보드 사이즈 만큼)을 bottom 에서 밀어올려준다.
        padding: EdgeInsets.only(bottom: bottomInset),
        child: Column(
          children: [
            TextField(),
          ],
        ),
      ),
    );
  }

2)  바텀 시트 모달의 맥스를 풀어줘야 한다.

그런데도 바텀 모달시트의 크기가 전체를 먹지 못하는 상황이다.

 

그렇다면 플로팅 버튼의 모달 여는 부분에 추가를 해준다. isScrollControlled

이유는 showModalBottomSheet 의 기본 크기가 화면의 반크기만 최대사이즈를 갖기때문이다.

 

FloatingActionButton(
	onPressed: () {
    	showModalBottomSheet(
			...	
            // 추가!
            isScrollControlled: true,
            ...
        )
    }
)

그리고 BottomSheet 안의 UI 를 작업 해주겠다.

텍스트 필드가 공통적으로 들어가니 컴포넌트로 따로 분리시켜놓다.

// 분리 시켜 놓은 TextFiled 클래스

class CustomTextField extends StatelessWidget {
  final String label;

  const CustomTextField({Key? key, required this.label}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
    // 이유: Column() 위젯을 기본 CrossAxisAlignment 는 center 로 되어있다. 그러니 start 로 변경
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(label, style: TextStyle(
          color: PRIMARY_COLOR,
          fontWeight: FontWeight.w600
        ),),
        TextField(
          cursorColor: Colors.grey,
          decoration: InputDecoration(
              // 테두리 삭제
              border: InputBorder.none,
              // 채우기 여부
              filled: true,
              // 채우기 색상
              fillColor: Colors.grey[300]
          ),
        ),
      ],
    );
  }
}

 

그리고 색깔들을 Row()로 해서 설계 하면 될것 같지만! 저 색상들이 많아지면, 화면 밖으로 넘어가서 이슈가 생기니!

Wrap () 위젯을 사용해주면된다. 

// _ColorPicker 클래스 
{
Wrap(
    // 양옆 간격
    spacing: 8.0,
    // 위아래 간격
    runSpacing:10.0,
	children: [
    	renderColor(Colors.red),
    	renderColor(Colors.orange),
        renderColor(Colors.yellow),
        ...
    ]
);

// 공통 적인 부분은 위젯으로 분리
    Widget renderColor(Color color) {
		return Container(
            decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: color,
            ),
            width: 32.0,
            height: 32.0,        
        );
    }
}

 

텍스트의 input type 을 정해줄수 있다.

TextField(
	keyboardType: TextInputType.number, // 이름, 핸드폰, url, 등 여러가지 있다.
)

포커싱이 되어있는 TextField 에서 포커싱을 해제할수 있다.

TextField 를 포커싱하고 있는 도중에 다른 부분을 클릭하면, 키패트내려가게 하고 싶다!

GestureDetector(
	onTap: (){
    	FocusScope.of(context).requestFocus(FocusNode());
    }
)

그리고 isTime : 시간입력하는 것이냐 아니냐를 bool 으로 받아서 분기처리를 하였다.

maxLines, keyboardType, inputFormatters 

	...
	if(isTime) renderTextField(),
	if(!isTime) Expanded(child: renderTextField()),
	...
}
class CustomTextField extends StatelessWidget {
  final String label;
  // true - 시간 / false - 내용
  final bool isTime;

  const CustomTextField({Key? key, required this.label, required this.isTime})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: TextStyle(color: PRIMARY_COLOR, fontWeight: FontWeight.w600),
        ),
        TextField(
          cursorColor: Colors.grey,
          // 최대 줄 1줄, null 은 줄바꿈이 된다.
          maxLines: isTime? 1: null,
		  // 확장시켜주기 위한 파라미터 true, false
		  expands: !isTime,
          // 키보드 타입 설정
          keyboardType: isTime ? TextInputType.number : TextInputType.multiline,
          // FilteringTextInputFormatter.digitsOnly 는 오직 그타입으로만 입력가능하게 한다.
          inputFormatters: isTime ? [FilteringTextInputFormatter.digitsOnly] :[],
          decoration: InputDecoration(
              // 테두리 삭제
              border: InputBorder.none,
              filled: true,
              fillColor: Colors.grey[300]),
        ),
      ],
    );
  }
}

추가적으로 ... 말 줄임표 나타내는 방법이 있다.

class _Content extends StatelessWidget {
  final String content;
  const _Content({super.key, required this.content});

  @override
  Widget build(BuildContext context) {
    return Text(
      content,
      // 최대 줄 수
      // maxLines: 3,
      // 말줄임표
      // overflow: TextOverflow.ellipsis,
    );
  }
}

 

11. 프로젝트 테이블 구조!

신기하게도 테이블을 생성을 할 수가 있다.

// model/schedule.dart

import 'package:drift/drift.dart';

class Schedules extends Table {
  // PRIMARY KEY
  IntColumn get id => integer()();

  // 내용
  TextColumn get content => text()();

  // 일정 날짜
  DateTimeColumn get date => dateTime()();

  // 시작 시간
  IntColumn get startTime => integer()();

  // 끝 시간
  IntColumn get endTime => integer()();

  // Category Color Table ID
  IntColumn get colorId => integer()();

  // 생성 날짜
  DateTimeColumn get createAt => dateTime()();

}

// model/category_color.dart
import 'package:drift/drift.dart';

class CategoryColors extends Table {
  // PRIMARY KEY
  IntColumn get id => integer()();

  // 색상 코드
  TextColumn get hexCode => text()();
}

Column 의 기능이 있다!

Id 값은 자동적으로 증가 되게끔 drift 에서 제공을 해준다.

시간은 사용자가 디폴트로 지정할수도 있다.

// 모델 테이블을 만드려면 꼭 import 해줘야한다.
import 'package:drift/drift.dart';

// Table 을 상속해라!
Class Schedules extends Table {
	// 기본키, 기본키는 고유값으로 저장을 해야하기 때문에 우리가 직접 입력해주는것이 아닌 자동적으로 들어가게 하자!
   	IntColumn get id => integer().autoIncrement()();
    
    // 생성 날짜, 생성 날짜는 입력되는 현재의 날짜를 넣어주면 된다.
   	DateTimeColumn get createAt => dateTime().clientDefulat(
    	() => DateTime.now(),
    )();    
}

 

12. Code Generation, g.dart 파일 생성!

그리고 part 라는 개념이 있다.

기본적인 import 는 private 함수, 클래스는 불러오지 못한다.

하지만 part 는 private 함수도 불러올 수 있다.

import 'dart:io';

import 'package:drift/drift.dart';
import 'package:scheduler_study/model/category_color.dart';
import 'package:scheduler_study/model/schedule.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:drift/native.dart';

// part 는 import 보다 조금 더 넓은 기능을 한다.
// import 는 private 를 불러올 수 없다.
part 'drift_database.g.dart';

@DriftDatabase(
  tables: [
    Schedules,
    CategoryColors,
  ],
)

//  _$LocalDatabase 는 현재 이 클래스에서 선언해놓지 않았다. 하지만 사용가능한 이유는 part 로 불러왔기 때문이다.
class LocalDatabase extends _$LocalDatabase {
  LocalDatabase() : super(_openConnection());
}

LazyDatabase _openConnection() {
return LazyDatabase(() async {
  // getApplicationDocumentsDirectory : 앱을 설치하게 되면, os 에서 앱별로 각각 사용가능한 부분에 지정을 자동으로 해준다. 그것을 가져오는 함수!
  final dbFolder = await getApplicationDocumentsDirectory();
  // dbFolder 의 경로에 내가 원하는 이름으로 파일을 저장한다.
  final file = File(p.join(dbFolder.path, 'db.sqlite'));

  return NativeDatabase(file);
});
}

그리고 g.dart 파일을 생성하기 위해 flutter pub run build_runner build

 

13. 쿼리 호출!

main.dart 에서 쿼리 생성하는 문 작성

class LocalDatabase extends _$LocalDatabase {
  LocalDatabase() : super(_openConnection());

  // 스케쥴 추가 insert 문
  Future<int> createSchedule(SchedulesCompanion data) =>
      into(schedules).insert(data);

  // 색상 추가 insert 문
  Future<int> createCategoryColor(CategoryColorsCompanion data) =>
      into(categoryColors).insert(data);

  // 카테고리 컬러 다 불러오는 select 문
  Future<List<CategoryColor>> getCategoryColors() =>
      select(categoryColors).get();


//  스키마를 무조건 override 를 해야한다.
데이터 베이스 상태의 버전 이다. 
  @override
  int get schemaVersion => 1;
}

14. 벨리데이션 하는 법!

onChanged 로 하면 각각 하나마다 validation 상태값을 가지고 있어야한다.

그러니 모든 벨리데이션을 한번에 관리할 수 있는 TextFormField() 위젯을 사용하면 된다.

 

 

1) 벨리데이션을 하기 위한 좋지 않은 방법! (onChanged)

TextField(
	onChanged: (String? value) {
		if(val == null || val.isEmpty) {
        	return '아이디 입력해라';
        }else{
        	return null;
        }
    }
),
TextField(
	onChanged: (String? value) {
		if(val == null || val.isEmpty) {
         	return '닉네임을 입력해라';           
        }else{
        	return null;	
        }
    }
)

2) 한번에 관리하는 좋은 방법!

2-1) 한번에 textFiled 를 관리하고 싶은 해당하는 클래스의 상위클래스에다가 Form 태그를 감싸주면 감싸주고

// 내가 벨리데이션 상태관리를 하고 싶은 부모에다가 Form() 태그를 감싸줘라!
// key 는 Form 을 관리하는 컨트롤러라고 생각하면 된다.

// Stful

final GlobalKey<FormState> formKey = GlobalKey();

Form(
	key: formKey,
	children: [
        _Time(),
        _Content(),
    ],
),

2-2) 저장하는 버튼에는 validation 이 출력하냐 안출력하냐의 분기처리를 적어주고,

자식클래스에는 validator에 null 또는 빈값일때 출력하고 싶은 분기처리를 적어주면된다.

// 부모 클래스
// 저장 버튼 있는 함수
 void onSavePressed() {
    // formKey 는 생성했는데 Form 위젯과 결합을 안했을때 (Form 위젯에 key 값 안넣을때)
    if (formKey.currentState == null) {
      return;
    }
    // 모든 textField 를 검증을 하고서 다 null 값이 다 리턴되면(모두 에러가 안나면) true 리턴
    if (formKey.currentState!.validate()) {
      // true: TextFormField(validator)은 null 로 리턴을 했다.
      print('에러가 없습니다.');
    } else {
      // false: TextFormField(validator)은 string 값을 리턴한다.
      print('에러 가 있습니다.');
    }
  }


// 자식클래스
// 아이디
TextFormField(
	validator: (String? val) {
    	if(val == null || val.isEmpty) {
 			return '아이디 값입력해주세요';
        } else {
        	return null;
        }
    }
),
 
// 닉네임
TextFormField(
	validator: (String? val) {
    	if(val == null || val.isEmpty) {
 			return '닉네임 값입력해주세요';
        } else {
        	return null;
        }
    }
),

결과)

15. ValidationMode 사용해보기!

저장 버튼을 누를때마다 validation 이 나오는 것보다 입력하는것에 따라 자동적으로 벨리데이션 하게 해보자

Form(
    key: formKey,
    // 자동적으로 벨리데이션 해주는 것! 
    autovalidateMode: AutovalidateMode.always,
)

 

16. Form을 저장하는 법!

 

검증을 하는데 쓰는 validator 를 사용했고, 검증 완료 되고 저장이 될 때의 함수를 알아보자!

// 언제 호출되냐 TextFormField 를 사용하는 상위인 Form()태그에서 save 함수 를 불렀을때 모두 호출된다.

TextFormField(
	onSaved: (String? val) {}
)

외부에서 만들어서 넣어주도록 하자!

이유: 실제로는 마지막으로 저장 버튼이 눌렸을때 값들을 가져오는 용도로 사용할 것 이기때문에, 외부에서 함수를 파라미터로 넣어주는 방식으로 선언하자!

// class ScheduleBottomSheet extends StatefulWidget {
int? startTime;
int? endTime;
String? content;


// class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
_Time(
    onStartSaved: (String? newValue) {
        print('newValue $newValue');
	    startTime = int.parse(newValue!);
    },
    onEndSaved: (String? newValue) {
   		endTime = int.parse(newValue!);
    },
),
...
_Content(
	onSaved: (String? newValue) {
    	content = newValue; 
    }
)


// 저장 버튼 누를때
void onSavePressed() {
...   
    if (formKey.currentState!.validate()) {
      // 저장함수 호출!
      formKey.currentState!.save();
    } else {
      print('에러 가 있습니다.');
    }
  }

17. Select 쿼리 UI 에 적용하기!

색상을 디비에 넣어둔것을 출력해주도록 하겠다.

dependency injection 의존성 주입을 시켜준다.

get_it: ^7.2.0

main.dart 에다가 database 를 접근 가능하게 변수를 만들어놨는데, 하위클래스에서 어떻게 이 변수에 접근할까?

파라미터를 내려주기에는 코드가 길어지고 더러워질거다.

Getit 클래스를 통해서 어디서든 database 를 접근 가능해진다.

import 'package:get_it/get_it.dart';

void main() async {
	...
	final database = LocalDatabase();
	// getit 클래스로 database 어디든 접근 가능해짐
	GetIt.I.registerSingleton<LocalDatabase>(database);

}

dataBase 를 접근해서 출력하고 싶은 곳에 적어주자

 

import 'package:scheduler_study/database/drift_database.dart';

// GetIt 패키지로 의존성을 주입시킨 것이다. 그래서 데이터 베이스에 선언해놓은 함수들 사용가능하다.
FutureBuilder<List<CategoryColor>>(
    future: GetIt.I<LocalDatabase>().getCategoryColors(),
    builder: (context, snapshot) {
      print(snapshot.data);
      return _ColorPicker(
        colors: snapshot.hasData
            ? snapshot.data!.map((e) => Color(
                  int.parse(
                    'FF${e.hexCode}',
                    radix: 16,
                  ),
                )).toList()
            : [],
      );
    }
),

 

이렇게 가공을 한이유는?

로컬데이터에 색상을 공통적인(FF부분 이외의 것을 저장시켰기 때문이다.) 부분 이후의 것을 가공해서 출력하기 위함이다.
// print(snapshot.data);
// [CategoryColor(id: 1, hexCode: F44336), CategoryColor(id: 2, hexCode: FF9800), CategoryColor(id: 3, hexCode: FFEB3B), CategoryColor(id: 4, hexCode: FCAF50), CategoryColor(id: 5, hexCode: 2196F3), CategoryColor(id: 6, hexCode: 3F51B5), CategoryColor(id: 7, hexCode: 9C27B0)]

int.parse('FF'${e.hexCode}, radix: 16);
// radix: 16 -> 16 진수로 변환해주기 위함이다. 이걸 넣기전에는 10진수이다.

결과 ) 

디비에서 잘 불러왔다.

18. 색상 상태관리!

색상을 상태 관리해서 색상을 선택 후 저장 버튼을 누르면 선택한 색상 고유 값이 파라미터로 넘어가길 원한다.

 

typedef?

함수는 반환타입 or void 를 지정한 후, 함수명과 파라미터로 되어있는 구조이다.

typedef 도 큰 차이 없다.

즉, typedef 를 사용하면 함수들을 변수처럼 사용가능하게 만들어준다.

그럼 그냥 함수를 호출하면 되는것아닌가? 왜 굳이 typedef 를 사용하는데?

클래스안에서 함수를 넣어줘야할 때가 있다.

void add(int a, int b) {
	print('a 더하기 b 는 ${a+b}이다.');
}

void subtract(int a, int b){
	print('a 빼기 b 는 ${a-b}이다.');
}


typedef Operation(int a, int b);

// 임의 함수
void calculate(int a, int b, Operation oper) {
	oper(a, b);
}

// main 함수
void main() {
  calculate(1, 2, add); // a 더하기 b 는 3이다.
  calculate(3, 2, subtract); // a 빼기 b 는 1이다.
}


함수에 인자로 함수를 넣고, 그값을 받은 calcuate 는 첫번째와 두번째 인자로 세번째 typedef 를 실행한다.

 부모클래스에서 자식클래스로 함수를 넘겨주기 위해 ColorIdSetter typedef 로  선언했다. 

int selectedColorId = 1;

return _ColorPicker(
	colors: ...,
    selectedColorId: ...,
    colorIdSetter: (int id){
    	setState((){
        	selectedColorId = id;
        });
    }
);


// typedef 정의
typedef ColorIdSetter = void Fuction(int id);

class _ColorPicker extends StatelessWidget {
  final List<CategoryColor> colors;
  final int selectedColorId;
  final ColorIdSetter colorIdSetter; // int id를 파라미터로 보내는 void 함수

  const _ColorPicker(
      {Key? key,
      required this.colors,
      required this.selectedColorId,
      required this.colorIdSetter})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8.0,
      runSpacing: 10.0,
      children: colors
          .map((e) => GestureDetector(
              onTap: () {
                colorIdSetter(e.id);
              },
              child: renderColor(e, selectedColorId == e.id)))
          .toList(),
    );
  }

 

19. Schedule 저장해버리기!

더보기

import 한 패키지에서 Column 을 두군데에서 받아서 이런 에러가 나는 것인데, drift 에서는 Value 만 사용할것이니 밑에 처럼 추가!

 

import 'package:drift/drift.dart' show Value;

 

lib/components/new_schedule_bottom_sheet.dart:44:24: Error: 'Column' is imported from both 'package:drift/src/dsl/dsl.dart' and 'package:flutter/src/widgets/basic.dart'.
                child: Column(
                       ^^^^^^

GetIt 패키지를 통해서 <LocalDatabase> 에 선언한 insert 문을 사용할 수 있고, insert 문에 맞춰 values 를 넣어준것이다.

// 선택한 날짜
final DateTime seletedDate;

...
void onSavePressed() async {
	// int key 를 반환한다.
	final key = GetIt.I<LocalDatabase>().createSchedule(shcedulesCompanion(
        date: Value(widget.selectedDate),
        startTime: ...,
        endTime: ...,
        content: ...,
        colorId: ...,
    ));

	// 뒤로가기!
    Navigator.of(context).pop();
}

 

20. Stream 으로 쿼리 결과값 받기!

위에서 GetIt 패키지로 로컬 디비에 저장한것을 데이터 통신으로 불러오겠다.

 

리스트를 출력할때는 스케줄을 추가할때마다 자동적으로 update 될 수 있게! StreamBuilder를 사용할거다!

StreamBuilder 는 상태가 변할때마다 api 를 다시 호출한다.

import 'package:get_it/get_it.dart';
import 'package:scheduler_study/database/drift_database.dart';

return Expanded(
  child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      // streamBuilder 로 선언!
      child: StreamBuilder<List<Schedule>>(
          stream: GetIt.I<LocalDatabase>().watchSchedules(),
          builder: (context, snapshot) {
            print(snapshot.data);
            return ListView.separated(
              separatorBuilder: (context, index) {
                return SizedBox(height: 8.0);
              },
              itemCount: 3,
              itemBuilder: (context, index) {
                return ScheduleCard(
                    startTime: 8,
                    endTime: 9,
                    content: '기상',
                    color: Colors.red);
              },
            );
          })),

select 문을 Stream 으로 작성하는 방법은 .watch() 를 써주면된다. (.get() 이 아닌!)

// drift_database.dart

// update 될때마다 계속 지속적으로 받는 Stream
  Stream<List<Schedule>> watchSchedules() =>
      select(schedules).watch();

21. TimeZone 시차고려하기!

12월 26일 하나의 스케쥴을 입력한 상태이고  where 을 통해 오늘 날짜로 filter 된 스케쥴 만 나오게 해놓은 상태이다.

 

초기 앱 처음 킬때

flutter: ---------------origin----------------
flutter: [Schedule(id: 1, content: test1, date: 2022-12-26 00:00:00.000, startTime: 11, endTime: 12, colorId: 2, createAt: 2022-12-26 15:40:59.000)]
flutter: ---------------filterd----------------
flutter: 2022-12-26 00:00:00.000
flutter: [Schedule(id: 1, content: test1, date: 2022-12-26 00:00:00.000, startTime: 11, endTime: 12, colorId: 2, createAt: 2022-12-26 15:40:59.000)]

27일 클릭후 다시 26일로 돌아온 결과

flutter: ---------------origin----------------
flutter: [Schedule(id: 1, content: test1, date: 2022-12-26 00:00:00.000, startTime: 11, endTime: 12, colorId: 2, createAt: 2022-12-26 15:40:59.000)]
flutter: ---------------filterd----------------
flutter: 2022-12-26 00:00:00.000Z
flutter: []

갑자기 date 뒤에 z 가 생기고, filter 한 스케쥴이 빈배열이 되었다!!!

 

z 는 UTC 시간에서 0 시 를 의미한다.

UTC 는 전세계에서 나라별로 시차를 나타내는 Standard 라고 생각하면된다.

참고로 우리나라는 +9 이다. 중심시간으로 부터 9시간 더해주면된다.

z 안붙어있는 date 는 현재 시간

z 붙어있는 date 는 UTC 기준으로 0시, 중심시간이다.

둘중을 통일 시켜주면된다.

처음 저장할때 UTC 기준으로 저장을 시켜놓던지, 날짜를 선택할때 한국기준으로 시간을 저장하던지!

class _HomeScreenState extends State<HomeScreen> {
// 첫렌더링 할때 UTC 기준으로 생성한게 아닌 현재시간을 한국 기준으로 정한것
    DateTiem seletedDay = DateTime(
        DateTime.now().year,
        DateTime.now().month,
        DateTime.now().day,
    )
}

// UTC 기준이다. 그렇기 때문에 캘린더에서 날짜를 옮기는 순간 UTC 기준으로 시간이 변한다.
onDaySelected(DateTime selectedDay, DatreTime focusedDay) {
	setState() {
    	...
    }
}

첫랜더링 할때 시간 저장하는 것을 utc 로 바꿔주자! 그럼 첫 렌더링때도 utc기준으로 날짜를 저장하는 것을 볼수 있다.

   DateTiem seletedDay = DateTime.utc(
        DateTime.now().year,
        DateTime.now().month,
        DateTime.now().day,
   )

결과)

flutter: ---------------origin----------------
flutter: [Schedule(id: 1, content: test1, date: 2022-12-26 09:00:00.000, startTime: 11, endTime: 12, colorId: 2, createAt: 2022-12-26 16:52:51.000)]

 

22. 쿼리에 직접 Where 필터 적용하기!

 

  stream: GetIt.I<LocalDatabase>().watchSchedules(),
  builder: (context, snapshot) {
    print('---------------origin----------------');
    print(snapshot.data);

    List<Schedule> schedules = [];

    if (snapshot.hasData) {
      schedules = snapshot.data!
          .where((element) => element.date.toUtc() == selectedDate)
          .toList();

      print('---------------filterd----------------');
      print(selectedDate);
      print(schedules);

    }

결과)

flutter: ---------------filterd----------------
flutter: 2022-12-26 00:00:00.000Z
flutter: [Schedule(id: 1, content: test1, date: 2022-12-26 09:00:00.000, startTime: 11, endTime: 12, colorId: 2, createAt: 2022-12-26 16:52:51.000)]

 

하지만 이런식의 filter 는 snapshot.data 를 전부다 가져온다... 

그러면 메모리에 스케쥴들을 전부 다 불러오는 것이기 때문에, 메모리가 꽉차게 된다.

그러니 flutter 단에서 다 불러와서 filter 를 하는 것이 아니라, drit 의 쿼리문에서 제한을 둬야한다!!

 // update 될때마다 계속 지속적으로 받는 Stream
 
 // 이게 원래 정석이다.
  Stream<List<Schedule>> watchSchedules(DateTime date) {
    final query = select(schedules);
    query.where((tbl) => tbl.date.equals(date));
    return query.watch();
  }
  
  ⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
  // select(schedules).watch() 되는것랑 같다!
  
  return (select(schedules)..where((tbl) => tbl.date.equals(date))).watch();
  
  
  //예 )
  
  int number = 3;
  
  final response = number.toString();
  // '3' -> String 
  
  final response2 = number..toString();  
  // 3 -> int
  // .toString() 함수는 실행이 되는데, 함수가 실행이 되는 대상이 리턴이 된다.

처음에는 streambuilder 에서 받아온 값으로  fliter 를 했는데, 쿼리문에서 where 절을 걸어서 해당되는 값만 데이터가 넘어오니, streambuilder 결과로 넘어 온값은 필터문을 삭제해주었다! 

child: StreamBuilder<List<Schedule>>(
              stream: GetIt.I<LocalDatabase>().watchSchedules(selectedDate),
              builder: (context, snapshot) {
                if(!snapshot.hasData) {
                  return Center(
                    child: CircularProgressIndicator(),
                  );
                }
                if(snapshot.hasData && snapshot.data!.isEmpty){
                  // 데이터 통신은 잘해서 data 는 잘 받아오는데, 리스트가 빈 값이라면?
                  return Center(
                    child: Text('스케줄이 없습니다.'),
                  );
                }
                return ListView.separated(
                  itemCount: snapshot.data!.length,
                  separatorBuilder: (context, index) {
                    return SizedBox(height: 8.0);
                  },
                  itemBuilder: (context, index) {
                    // 각각의 인덴스별 스케쥴
                    final schedule = snapshot.data![index];

                    return ScheduleCard(
                        startTime: schedule.startTime,
                        endTime: schedule.endTime,
                        content: schedule.content,
                        color: Colors.red);
                  },
                );
              })),

23. Join 쿼리 사용하기!

현재 색상을 해당 되는 id 값을 넘겨주는데, 카테고리 컬러 테이블과  스케쥴 테이블의 색상 선택 값을 비교하려면  테이블 간의 JOIN을 해줘야한다.

 

// model/ schedule_with_color.dart 새로 생성

import 'package:scheduler_study/database/drift_database.dart';

// 두개의 테이블의 대한 값을 담기 위함
class ScheduleWithColor {
  final Schedule schedule;
  final CategoryColor categoryColor;

  ScheduleWithColor({
    required this.schedule,
    required this.categoryColor,
  });
}
// update 될때마다 계속 지속적으로 받는 Stream
  Stream<List<ScheduleWithColor>> watchSchedules(DateTime date) {
    // .. : ..where() 함수가 실행이 되는데, 실행되는 대상이 주체(select(schedules)) 가 된다.
    // return (select(schedules)..where((tbl) => tbl.date.equals(date))).watch();

    final query = select(schedules).join([
      // innerJoin(join 할 테이블, 조건)
      // join 할때는 equalsExp 를 사용
      // schedules 와 categoryColors 조인하는 데 categoryColors.id 와 schedules.colorId 같은 것만!
      innerJoin(categoryColors, categoryColors.id.equalsExp(schedules.colorId))
    ]);

    // 테이블이 두 개이기 때문에 schedules 라고 테이블 정의 해준다.
    query.where(schedules.date.equals(date));

    // rows : 필터링된 모든 데이터들, row : 각각의 데이터 를 ScheduleWithColor 에 넣어준거다.
    // readeTable(테이블) : 해당하는 테이블 읽어와라
    return query.watch().map((rows) => rows
        .map((row) => ScheduleWithColor(
            schedule: row.readTable(schedules),
            categoryColor: row.readTable(categoryColors)))
        .toList());
  }

  // 데이터 베이스 상태의 버전이다. 데이터 구조가 변경될 때 올려주면 된다.
  @override
  int get schemaVersion => 1;
}
// <ScheduleWithColor> 로 타입변경 
child: StreamBuilder<List<ScheduleWithColor>>(
              stream: GetIt.I<LocalDatabase>().watchSchedules(selectedDate),
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return Center(
                    child: CircularProgressIndicator(),
                  );
                }
                if (snapshot.hasData && snapshot.data!.isEmpty) {
                  // 데이터 통신은 잘해서 data 는 잘 받아오는데, 리스트가 빈 값이라면?
                  return Center(
                    child: Text('스케줄이 없습니다.'),
                  );
                }
                return ListView.separated(
                  itemCount: snapshot.data!.length,
                  separatorBuilder: (context, index) {
                    return SizedBox(height: 8.0);
                  },

// 변경
                  itemBuilder: (context, index) {
                    // 각각의 인덴스별 스케쥴
                    final scheduleWithColor = snapshot.data![index];

                    return ScheduleCard(
                        startTime: scheduleWithColor.schedule.startTime,
                        endTime: scheduleWithColor.schedule.endTime,
                        content: scheduleWithColor.schedule.content,
                        color: Color(int.parse(
                            'FF${scheduleWithColor.categoryColor.hexCode}',
                            radix: 16)));
                  },
                );
              })),

 

24. Order By 로 정렬하기!

프론트에서 정렬을 해줄 수 있지만, 백엔드에서 데이터를 던져줄때 정렬된것을 던져주면 훨씬 편하다.

  // update 될때마다 계속 지속적으로 받는 Stream
  Stream<List<ScheduleWithColor>> watchSchedules(DateTime date) {
    // .. : ..where() 함수가 실행이 되는데, 실행되는 대상이 주체(select(schedules)) 가 된다.
    // return (select(schedules)..where((tbl) => tbl.date.equals(date))).watch();

    final query = select(schedules).join([
      // innerJoin(join 할 테이블, 조건)
      // join 할때는 equalsExp 를 사용
      // schedules 와 categoryColors 조인하는 데 categoryColors.id 와 schedules.colorId 같은 것만!
      innerJoin(categoryColors, categoryColors.id.equalsExp(schedules.colorId))
    ]);

    // 테이블이 두 개이기 때문에 schedules 라고 테이블 정의 해준다.
    query.where(schedules.date.equals(date));

// 추가!
    // 정렬
    query.orderBy([
      // asc -> 오름 차순
      // desc -> descending 내림차순
      OrderingTerm.asc(schedules.startTime),
    ]);

    // rows : 필터링된 모든 데이터들, row : 각각의 데이터 를 ScheduleWithColor 에 넣어준거다.
    // readeTable(테이블) : 해당하는 테이블 읽어와라
    return query.watch().map((rows) => rows
        .map((row) => ScheduleWithColor(
            schedule: row.readTable(schedules),
            categoryColor: row.readTable(categoryColors)))
        .toList());
  }

 

25. TodayBanner 위젯 개선하기!

StreamBuilder<List<ScheduleWithColor>>(
              stream: GetIt.I<LocalDatabase>().watchSchedules(selectedDay),
              builder: (context, snapshot) {
                int count = 0;

                if(snapshot.hasData) {
                  count = snapshot.data!.length;

                }
                return Text(
                  '$count개',
                  style: textStyle,
                );
              }),

 

26. Delete 쿼리 적용하기!

오른쪽에서 왼쪽으로 스와이프 했을때 지워지는 애니메이션 기능

삭제 쿼리 작성

// 테이블의 전체 데이터 삭제
removeSchedule() => delete(schedules).go();


// 외부에서 삭제할 id 값을 받아서 schedules 테이블의 id 와 같은지 비교하고 같은 row 를 삭제를 하겠다. int 값을 리턴 받을 수 있다.(삭제한 id 의 int 값이다.)

Future<int> = removeSchedule(int id) =>
      (delete(schedules)..where((tbl) => tbl.id.equals(id))).go();

swiper 를 하면 삭제가능하게끔 하는 위젯이있다.

// 스와이퍼 가능하게끔 해주는 위젯
                    return Dismissible(
                      // 어떤 위젯이 삭제가 됐는지 인식을 한다. (유니크한 키)
                      key: ObjectKey(scheduleWithColor.schedule.id),
                      // 어디로 스와이프 할지 설정
                      direction: DismissDirection.endToStart,
                      // 스와이프 됐을때 함수 설정
                      onDismissed: (DismissDirection direction){
                        GetIt.I<LocalDatabase>().removeSchedule(scheduleWithColor.schedule.id);
                      },
                      child: ScheduleCard(
                          startTime: scheduleWithColor.schedule.startTime,
                          endTime: scheduleWithColor.schedule.endTime,
                          content: scheduleWithColor.schedule.content,
                          color: Color(int.parse(
                              'FF${scheduleWithColor.categoryColor.hexCode}',
                              radix: 16))),
                    );

 

27. Update 쿼리 적용하기!

특정 스케쥴을 눌렀을때 바텀시트 열리면서, 작성한 부분 출력되고, 변경후 저장기능까지!

update 쿼리 작성

// update 문
  // schedules 을 업데이트 한다. 테이블 id와 선택한 스케쥴의 id 같은 row 컬럼을 찾아서 data 값으로 변경해라!
  Future<int> updateScheduleById(int id, SchedulesCompanion data) =>
      (update(schedules)..where((tbl) => tbl.id.equals(id))).write(data);

스케쥴 클릭하면 스케쥴 바텀 시트 열리게하고 id 값 보내게 설정

우선은 id 를 받을 수 있게 설정(필수가 아니게 설정하고 : 기존에 사용하던거를 헤치지않게)

class ScheduleBottomSheet extends StatefulWidget {
  final DateTime selectedDate;
  final int? scheduleId;

  const ScheduleBottomSheet(
      {Key? key, required this.selectedDate, this.scheduleId})
      : super(key: key);

GestureDetector 위젯 추가해서 파라미터 넘겨준다.

return Dismissible(
                      // 어떤 위젯이 삭제가 됐는지 인식을 한다. (유니크한 키)
                      key: ObjectKey(scheduleWithColor.schedule.id),
                      // 어디로 스와이프 할지 설정
                      direction: DismissDirection.endToStart,
                      // 스와이프 됐을때 함수 설정
                      onDismissed: (DismissDirection direction){
                        GetIt.I<LocalDatabase>().removeSchedule(scheduleWithColor.schedule.id);
                      },
                      child: GestureDetector(
                        onTap: (){
                          showModalBottomSheet(
                              isScrollControlled: true,
                              context: context,
                              builder: (_) {
                                return ScheduleBottomSheet(
                                  selectedDate: selectedDate,
                                  scheduleId: scheduleWithColor.schedule.id,
                                );
                              });
                        },
                        child: ScheduleCard(
                            startTime: scheduleWithColor.schedule.startTime,
                            endTime: scheduleWithColor.schedule.endTime,
                            content: scheduleWithColor.schedule.content,
                            color: Color(int.parse(
                                'FF${scheduleWithColor.categoryColor.hexCode}',
                                radix: 16))),
                      ),
                    );

 


 

728x90