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

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

오늘의 수업 주제

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


코드 분석

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개의 댓글