[25.06.04 TIL] 3주차 강의(상태 관리)

김영민·2025년 6월 4일

사전캠프 7일차
공휴일은 사전 캠프가 없어 날짜로는 8일이 아닌 7일차다.
3주차 강의에서는 상태 관리에 대해 학습한 걸 활용해 저번 시간에 실습한 알람 앱에 기능을 더했다.


3주차 강의(상태 관리)

6. 알람 앱

📌 알람앱 기능명세에 따른 할일 목록

  • 알람 등록 화면 Get route 설정
  • 알람 등록 화면 구성(위젯 구성)
  • 알람 등록 기능 개발
  • 홈 화면 등록된 알람 리스트
  • 홈 화면 알람 편집 이벤트로 편집화면 구성
  • 알람 삭제 기능 개발
  • 알람 선택 시 기존 알람 등록 화면 > 알람 편집 화면으로 변경
  • 개별 알람에 활성화/비활성화 기능 적용

6-1. GetX route 설정

flutter pub add get
  • Terminal에 입력해 GetX라이브러리를 설치한다.


Widget build(BuildContext context) {
  return GetMaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      scaffoldBackgroundColor: Colors.black,
      textTheme: TextTheme(
        bodyMedium: TextStyle(color: Colors.white),
      ),
      useMaterial3: true,
    ),
    home: Home(),
  );
}

  • MaterialApp에서 GetMaterialApp으로 변경한다.

6-2. 화면 구성(위젯)

📌 appbar를 통한 header 구성

appBar: AppBar(
  leading: GestureDetector(
    onTap: Get.back,
    behavior: HitTestBehavior.translucent,
    child: Center(
      child: Text(
        '취소',
        style: TextStyle(
          color: Color(0xffff9f0a),
          fontSize: 20,
        ),
      ),
    ),
  ),
  backgroundColor: Colors.transparent,
  title: Text('알람 추가', style: TextStyle(color: Colors.white)),
  actions: [
    GestureDetector(
      onTap: () {},
      child: Padding(
        padding: const EdgeInsets.only(right: 15),
        child: Text(
          '저장',
          style: TextStyle(
            color: Color(0xffff9f0a),
            fontSize: 20,
          ),
        ),
      ),
    )
  ],
),

  • 취소 버튼의 이벤트에 Get.back 은 GetX에서 제공되는 Route를 설정하도록 설정했기 때문에 페이지 전환에 따른 페이지 history가 관리된다. 그렇기 때문에 단순히 뒤로 가기 위해서는 Get.back이라는 함수를 호출하면 된다.

📌 body 구성

body: Center(
  child: Row(
    mainAxisAlignment: MainAxisAlignment.center,
    crossAxisAlignment: CrossAxisAlignment.end,
    children: [
      Text(
        '오전',
        style: TextStyle(
          fontWeight: FontWeight.w100,
          fontSize: 28,
          color: Color(0xffababac),
        ),
      ),
      SizedBox(width: 15),
      SizedBox(
        width: 87,
        height: 87,
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: Color(0xff2c2c2e),
          ),
          child: Center(
            child: TextField(
              maxLength: 2,
              textAlign: TextAlign.center,
              keyboardType: TextInputType.number,
              inputFormatters: <TextInputFormatter>[
                FilteringTextInputFormatter.digitsOnly,
                RangeTextInputFormatter(1, 23),
              ],
              style: TextStyle(
                color: Colors.white,
                fontSize: 40,
                fontWeight: FontWeight.w100,
              ),
              decoration: InputDecoration(
                border: InputBorder.none,
                counterText: '',
              ),
            ),
          ),
        ),
      ),
      SizedBox(width: 15),
      SizedBox(
        width: 87,
        height: 87,
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: Color(0xff2c2c2e),
          ),
          child: Center(
            child: TextField(
              maxLength: 2,
              textAlign: TextAlign.center,
              keyboardType: TextInputType.number,
              inputFormatters: <TextInputFormatter>[
                FilteringTextInputFormatter.digitsOnly,
                RangeTextInputFormatter(0, 59),
              ],
              style: TextStyle(
                color: Colors.white,
                fontSize: 40,
                fontWeight: FontWeight.w100,
              ),
              decoration: InputDecoration(
                border: InputBorder.none,
                counterText: '',
              ),
            ),
          ),
        ),
      ),
    ],
  ),
),

  • keyboardType: TextInputType.number 이렇게 설정하면 키보드가 숫자만 노출되는 것을 확인할 수 있다.
    또한, 사용자가 복사 붙여넣기 등을 통해 숫자가 아닌 값을 넣을 수 있기 때문에 그것을 차단하기 위해서 inputFormatter라는 옵션이 있다.
    이 옵션을 통해 FilteringTextInputFormatter.digitsOnly를 사용하면 숫자만 입력을 받을 수 있게 된다.

RangeTextinputFormatter

class RangeTextInputFormatter extends TextInputFormatter {
  final int min;
  final int max;
//
  RangeTextInputFormatter(this.min, this.max);
//
  
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    if (newValue.text.isEmpty) {
      return newValue;
    }
//
    final int? value = int.tryParse(newValue.text);
    if (value == null || value < min || value > max) {
      return oldValue;
    }
    return newValue;
  }
}

  • 추가적으로 시간과 분은 설정 범위가 있다.
    시간은 1~23시간, 분의 경우 0~59까지 설정할 수 있어야 한다.
    이것을 해결하기 위해서 별도의 custom Formatter를 이용해야 한다.
    이것을 위해 RangeTextinputFormatter라는 이름으로 클래스를 만들어준다.

6-3 알람 등록 기능 개발

📌 AlarmController 생성

import 'package:get/get.dart';
//
class AlarmController extends GetxController {}

📌 AlarmController 의존성 주입

Get.put(AlarmController());
  • Get.find 통해 해당 컨트롤러에 접근이 가능하게 된다.
    가능하게 되었습니다.

    💡 원래 알람 앱을 만들기 위해 등록/수정 관련 controller, home 화면에 필요한 controller를 각각 만드는 등 controller를 기능별로 쪼개서 관리한다.

📌 AlarmController 등록에 필요한 상태 및 이벤트 생성

import 'package:get/get.dart';
//
class AlarmController extends GetxController {
  int hour = 0;
  int minute = 0;
//
  void setHour(int hour) {
    this.hour = hour;
    update();
  }
//
  void setMinute(int minute) {
    this.minute = minute;
    update();
  }
}

📌 알람 추가 페이지 컨트롤러 연결

body: Center(
  child: Row(
    mainAxisAlignment: MainAxisAlignment.center,
    crossAxisAlignment: CrossAxisAlignment.end,
    children: [
      GetBuilder<AlarmController>(
        builder: (controller) {
          return Text(
            controller.hour < 12 ? '오전' : '오후',
            style: TextStyle(
              fontWeight: FontWeight.w100,
              fontSize: 28,
              color: Color(0xffababac),
            ),
          );
        },
      ),
  • 오전/오후를 표기하기 위해서 GetBuilder를 사용해서 상태가 변경될 때마다 이 문자만 다시 그려지도록 설정한다.
    시간의 값이 12시보다 낮으면 오전 그 이후는 오후로 표기되도록 삼항 연산자를 사용해서 처리한다.
      SizedBox(width: 15),
      SizedBox(
        width: 87,
        height: 87,
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: Color(0xff2c2c2e),
          ),
          child: Center(
            child: TextField(
              maxLength: 2,
              textAlign: TextAlign.center,
              keyboardType: TextInputType.number,
              inputFormatters: <TextInputFormatter>[
                FilteringTextInputFormatter.digitsOnly,
                RangeTextInputFormatter(1, 23),
              ],
              style: TextStyle(
                color: Colors.white,
                fontSize: 40,
                fontWeight: FontWeight.w100,
              ),
              decoration: InputDecoration(
                border: InputBorder.none,
                counterText: '',
              ),
              onChanged: (String hour) {
                if (hour == '') return;
                Get.find<AlarmController>().setHour(int.parse(hour));
              },
            ),
          ),
        ),
      ),
      SizedBox(width: 15),
      SizedBox(
        width: 87,
        height: 87,
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: Color(0xff2c2c2e),
          ),
          child: Center(
            child: TextField(
              maxLength: 2,
              textAlign: TextAlign.center,
              keyboardType: TextInputType.number,
              inputFormatters: <TextInputFormatter>[
                FilteringTextInputFormatter.digitsOnly,
                RangeTextInputFormatter(0, 59),
              ],
              style: TextStyle(
                color: Colors.white,
                fontSize: 40,
                fontWeight: FontWeight.w100,
              ),
              decoration: InputDecoration(
                border: InputBorder.none,
                counterText: '',
              ),
              onChanged: (String minute) {
                if (minute == '') return;
                Get.find<AlarmController>().setMinute(int.parse(minute));
              },
            ),
          ),
        ),
      ),
    ],
  ),
),

Get.find<AlarmController>().setHour(int.parse(hour));

  • 시간 입력 필드와 분 입력 필드 동일하게 onChanged 함수를 통해 값을 update 될 수 있도록 controller에 각각 setHour 과 setMinute로 연결해준다.
    이때 TextField에서 전달되는 입력 값은 String이기 때문에 int 타입으로 형 변환을 시켜준다.

📌 홈화면에서 사용되는 알람 목록 상태 추가

class AlarmController extends GetxController {
  int hour = 0;
  int minute = 0;
//
  List alarmList = [];
  • 여기서 List에 어떤 타입을 지정해 줄지 정의를 해줘야 한다.

📌 alarmModel 설계

class AlarmModel {
  int hour;
  int minute;
  bool isOn;
//
  AlarmModel({
    required this.hour,
    required this.minute,
    this.isOn = true,
  });
}

📌 홈화면에서 사용되는 알람 목록 상태 타입 정의

class AlarmController extends GetxController {
  int hour = 0;
  int minute = 0;
//
  List<AlarmModel> alarmList = [];
  • 이제 사용자가 저장을 누르면 이벤트를 발생시켜 alarmList에 데이터를 넣어주면 된다.

📌 저장 이벤트 등록

void saveAlarm(){
  alarmList.add(AlarmModel(hour: hour, minute: minute));
  update();
}

📌 저장 이벤트 연결

GestureDetector(
  onTap: () {
    Get.find<AlarmController>().saveAlarm();
    Get.back();
  },
  child: Padding(
    padding: const EdgeInsets.only(right: 15),
    child: Text(
      '저장',
      style: TextStyle(
        color: Color(0xffff9f0a),
        fontSize: 20,
      ),
    ),
  ),
)

💡 Controller 삭제되는 이슈 대응
의도한 것과 다르게 저장을 통해 페이지에서 빠져나왔을 때 debug console에
[GETX] "AlarmController" deleted from memory 와 같은 로그가 찍혀 있을 수도 있다.
GetX는 성능 향상을 위해 항상 최적화를 자동으로 진행하다 보니, 사용하지 않을 경우 자동으로 삭제된다.

의존성 영구적 유지 설정

Get.put(AlarmController(), permanent: true);

이럴 경우, 앱을 종료하기 전까지 controller를 삭제되지 않고, 항상 사용해야 하는 controller는 자동으로 삭제되지 않도록 설정을 해줘야 한다.


6-4 홈 화면 등록된 알람 리스트

편집모드 상태 및 이벤트 추가

📌 alarmController에 모드 상태 추가 및 편집모드 전환 이벤트

class AlarmController extends GetxController {
  int hour = 0;
  int minute = 0;
//
  bool isEditMode = false;
//
  List<AlarmModel> alarmList = [];
//
  void setHour(int hour) {
    this.hour = hour;
    update();
  }
//
  void setMinute(int minute) {
    this.minute = minute;
    update();
  }
//
  void saveAlarm() {
    alarmList.add(AlarmModel(hour: hour, minute: minute));
    update();
  }
//
  void toggleEditMode() {
    isEditMode = !isEditMode;
    update();
  }
}
  • 이제 화면에서 편집 버튼을 누르면 toggleEditMode 이벤트를 호출해주면 된다.

📌 편집 버튼 이벤트 연결

leading: GestureDetector(
  onTap: Get.find<AlarmController>().toggleEditMode,
  child: Center(
    child: GetBuilder<AlarmController>(
      builder: (controller) {
        return Text(
          controller.isEditMode ? '완료' : '편집',
          style: TextStyle(
            color:
                controller.isEditMode ? Colors.red : Color(0xffff9f0a),
            fontSize: 20,
          ),
        );
      },
    ),
  ),
),
  • isEditMode에 따라서 버튼도 변경이 필요하다.
    편집을 누르면 완료를 해줘서 다시 원래 상태로 돌아오기 위함으로, 기본적으로 색상 값과 문구만 변경해 주도록 했다.

기타 알람 목록 좌측에 알람 삭제 버튼을 위치

📌 _etcAlarm함수 파라미터 editmode 전달

return Column(
  children: controller.alarmList.map((alarm) {
    return _etcAlarm(alarm, controller.isEditMode);
  }).toList(),
);

📌 _etcAlarm함수 editMode일때 화면 처리

Widget _etcAlarm(AlarmModel alarm, bool isEditMode) {
  return Row(
    children: [
      if (isEditMode)
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 10),
          child: GestureDetector(
            onTap: () {},
            child: Icon(Icons.remove_circle, color: Colors.red),
          ),
        ),
      Expanded(
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
          decoration: BoxDecoration(
              border: Border(bottom: BorderSide(color: Color(0xff262629)))),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Row(
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      Text(alarm.hour < 12 ? '오전' : '오후',
                          style: TextStyle(
                              fontSize: 25, color: Color(0xff8d8d93))),
                      SizedBox(width: 10),
                      Text(
                          '${alarm.hour.toString().padLeft(2, '0')}:${alarm.minute.toString().padLeft(2, '0')}',
                          style: TextStyle(
                              fontSize: 60,
                              color: Color(0xff8d8d93),
                              height: 1,
                              letterSpacing: -3))
                    ],
                  ),
                  Switch(
                    onChanged: (value) {
                      print(value);
                    },
                    value: alarm.isOn,
                  ),
                ],
              ),
              Text('알람',
                  style: TextStyle(fontSize: 18, color: Color(0xff8d8d93))),
            ],
          ),
        ),
      ),
    ],
  );
}
  • 기존의 위젯은 그대로 두고, 좌측에 아이콘을 넣고 빼기만 하면 되니, 전체를 Row로 감싸주고 isEditMode에 따라서 icon를 넣어준다.


이제 실습 중에서 6-5 알람 삭제 기능 개발6-6 알람 선택시 기존 알람 등록 화면 > 알람 편집 화면으로 변경이 남았다.

그리고 다음 실습은 스레드 앱인데 알람 앱보다 더 내용이 많고, 할 것이 많아 보인다.

오늘과 내일 강의는 실습 위주라 코드를 수정하고, 왜 쓰였는지 정도를 알려주는 정도라 TIL 작성할 것은 많지 않다.
하지만 내용이 생각보다 많은데, 참고용 코드를 백업해두는 것으로 해야겠다.

profile
💻 [25.05.26~] Flutter 공부중⏳

0개의 댓글