#36 #Flutter 캘린더 일정 관리 UI 상세 구현 (BottomSheet & CustomTextField)

서영·2026년 4월 22일
post-thumbnail

오늘의 수업 주제

지난 시간에 구현한 캘린더 UI에 이어, 실제 일정을 표시하는 ScheduleCard, 오늘 날짜를 알려주는 TodayBanner, 그리고 일정을 입력받기 위한 BottomSheetCustomTextField를 구현하고 화면에 배치하는 방법


코드 분석

component/custom_text_field.dart

사용자로부터 시간이나 일정 내용을 입력받는 공통 위젯

import 'package:calendar_scheduler/const/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class CustomTextFeild extends StatelessWidget {
  final String label;
  final bool isTime;
  const CustomTextFeild({
    required this.label,
    required this.isTime,
    Key? key,
  }) : super(key: key);

  
  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(
            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,
            ),
          ),
        ),
      ],
    );
  }
}
코드 역할비고
final bool isTime시간 입력용(숫자)인지 내용 입력용(멀티라인)인지 구분
maxLines: isTime ? 1 : null시간은 한 줄만, 내용은 줄 제한 없이(null) 입력 가능
expands: !isTime내용 입력창일 때 부모의 남은 공간을 모두 채움
inputFormattersdigitsOnly를 사용해 시간 입력 시 숫자만 허용
suffixText: '시'시간 입력창 우측에 '시' 단위를 표시

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

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

  
  Widget build(BuildContext context) {
    final textStyle = TextStyle(fontWeight: FontWeight.w600, color: PRIMARY_COLOR, fontSize: 16);
    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);

  
  Widget build(BuildContext context) {
    return Expanded(child: Text(content));
  }
}
코드 역할비고
IntrinsicHeight자식 위젯들(시간/내용) 중 가장 높은 위젯에 맞춰 높이를 동기화
padLeft(2, '0')시간이 1자리(예: 9)일 때 앞을 0으로 채워 '09'로 표시
_Time / _Content위젯을 분리하여 코드 가독성과 재사용성을 높임 (Private 위젯)

component/today_banner.dart & schedule_bottom_sheet.dart

상단 날짜 표시 배너와 하단 입력창 부분

// today_banner.dart 일부
return Container(
  color: PRIMARY_COLOR,
  child: Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      Text('${selectDate.year}년 ${selectDate.month}월 ${selectDate.day}일'),
      Text('$count개'),
    ],
  ),
);

// schedule_bottom_sheet.dart 일부
showModalBottomSheet(
  context: context,
  isDismissible: true,
  builder: (_) => ScheduleBottomSheet(),
);
코드 역할비고
MainAxisAlignment.spaceBetween날짜(좌)와 개수(우)를 양 끝으로 배치
isDismissible: true배경 탭 시 바텀 시트가 닫히도록 설정
MediaQuery... / 2화면 높이의 절반만큼만 시트가 올라오도록 설정

screen/home_screen.dart

위의 컴포넌트들을 모두 모아 하나의 화면으로 조립

class _HomeScreenState extends State<HomeScreen> {
  DateTime selectedDate = DateTime.utc(
    DateTime.now().year, DateTime.now().month, DateTime.now().day,
  );

  
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        backgroundColor: PRIMARY_COLOR,
        onPressed: () {
          showModalBottomSheet(
            context: context,
            isDismissible: true,
            builder: (_) => ScheduleBottomSheet(),
          );
        },
      ),
      body: SafeArea(
        child: Column(
          children: [
            MainCalender(selectedDate: selectedDate, onDaySelected: onDaySelected),
            SizedBox(height: 8),
            TodayBanner(selectDate: selectedDate, count: 0),
            ScheduleCard(startTime: 12, endTime: 14, content: '프로그램 공부'),
          ],
        ),
      ),
    );
  }
}

이 파일이 하는 일: 캘린더, 배너, 일정 카드를 세로(Column)로 배치하고, FAB를 통해 일정을 추가할 수 있는 진입점을 제공합니다.


새롭게 알게 된 것

  • IntrinsicHeight: 자식 위젯들의 높이를 가장 큰 자식에 맞춰주는 위젯으로, 디자인의 일관성을 맞추기 매우 편리하다.
  • padLeft(): 문자열의 길이를 고정하고 빈 곳을 특정 문자로 채우는 함수를 통해 시간 포맷팅을 쉽게 구현할 수 있었다.
  • InputFormatters: 텍스트 필드에서 특정 타입(숫자 등)만 입력받게 강제하는 법을 배웠다.

헷갈리는 부분

  • Expanded와 Column: CustomTextField 내에서 Expanded를 사용할 때 부모 위젯의 제약 조건에 따라 에러가 날 수 있어 높이 설정에 유의해야 한다.
  • State 공유: 바텀 시트에서 입력한 값을 어떻게 다시 HomeScreen의 리스트에 추가할지에 대한 로직 구현이 더 연습이 필요할 것 같다.

실습


profile
시대를 따라가는 개발자가 아닌, 시대를 이끄는 개발자

0개의 댓글