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

김영민·2025년 6월 5일

사전캠프 8일차
내일은 공휴일로 오늘이 이번주 마지막 날이다.
저번 시간에 실습한 알람 앱을 마무리하고, 스레드 앱 실습을 시작했다.


3주차 강의(상태 관리)

6. 알람 앱

6-5 알람 삭제 기능 개발

📌 고유 아이디 값을 생성하기 위한 uuid 라이브러리 설치

flutter pub add uuid
  • 알람을 삭제 및 수정을 하기 위해서는 모델에 고유 id가 있어야 알람 목록에서 판별할 수 있다.
    Getx 라이브러리 설치와 동일하게 Open in integrated Terminal를 클릭 후 Terminal에 입력한다.

📌 AlarmModel id 값 추가

class AlarmModel {
  String id;
  int hour;
  int minute;
  bool isOn;
//
  AlarmModel({
    required this.hour,
    required this.minute,
    this.isOn = true,
  }) : id = Uuid().v4();
}
  • AlarmModel이 생성되면 자동으로 id 값이 부여되도록 한다.

📌 삭제 아이콘 클릭 이벤트 연결

if (isEditMode)
    Padding(
      padding: const EdgeInsets.symmetric(horizontal: 10),
      child: GestureDetector(
        onTap: () {
          Get.find<AlarmController>().removeAlarm(alarm.id);
        },
        child: Icon(Icons.remove_circle, color: Colors.red),
      ),
  ),
  • 아직 removeAlarm이라는 이벤트를 AlarmController에 만들어주지 않아서 오류가 발생한다.
    해당 함수를 호출할 때 alarm의 id를 넘겨주고 있으며, 해당 id 값으로 알람 목록에서 삭제를 해주면 된다.

📌 alarmController의 삭제 이벤트 로직 구현

void removeAlarm(String id) {
  alarmList.removeWhere((element) => element.id == id);
  update();
}
  • 문제 없이 삭제가 될 것이다.


6-6 알람 선택시 기존 알람 등록 화면 > 알람 편집 화면으로 변경

이제 사용자가 등록한 알람 목록에서 수정이 필요한 부분을 클릭해서 시간을 변경해 주는 update 기능만 구현

📌 알람 목록 item 클릭 이벤트 등록

GestureDetector(
  onTap: () {
    Get.to(AlarmWritePage(alarm: alarm));
  },
  child: Row(
  • 사용자가 알람 목록에서 수정을 원하는 것을 클릭했을 때 이벤트를 처리한다.
    AlarmWritePage로 보내줄 때 대신 alarm 객체를 넘겨준다.
    이로써 화면에서 alarm 객체가 있고 없고에 따라 편집모드인지 새로운 알람을 만드는 것인지를 분기할 수 있다.

    💡 편집 페이지의 경우 생성 페이지와 다른 페이지로 랜딩 되도록 할 수도 있다.
    하지만 화면 구성상 등록 페이지와 편집 페이지가 UI가 동일하기 때문에 별도의 페이지로 관리할 필요는 없다.

📌 AlarmWritePage 수정

class AlarmWritePage extends StatefulWidget {
  final AlarmModel? alarm;
  const AlarmWritePage({super.key, this.alarm});
//
  
  State<AlarmWritePage> createState() => _AlarmWritePageState();
}
  • AlarmModel을 nullable로 설정하여 생성 시에는 alarm 값이 넘어오지 않을 것이니 생성 mode로 하고, 수정 시에는 alarm 값을 보내주기 때문에 수정 mode로 처리하면 된다.
  • AlarmWritePage를 StatelessWidget에서 StatefulWidet으로 변경했다.
    TextField에 초기값을 설정하기 위해서는 Controller를 사용해야 하고, 위젯이 생성될 때 단 한 번만 초기화를 해줘야 하기 때문에 initState 함수가 필요하기 때문이다.

class _AlarmWritePageState extends State<AlarmWritePage> {
  final hourController = TextEditingController();
  final minuteController = TextEditingController();
//  
  
  void initState() {
    super.initState();
    if (widget.alarm != null) {
      hourController.text = widget.alarm!.hour.toString();
      minuteController.text = widget.alarm!.minute.toString();
      Get.find<AlarmController>().editAlarm(widget.alarm!);
    }
  }
  • widget.alarm의 값이 있다는 것은 수정 모드이기 때문에 initState에서 각각 TextEditingController의 text 값에 시간과 분을 넣어준다.
  • editAlarm이라는 함수로 AlarmController에 이벤트를 넣어주는 이유는 hour 값에 따라서 오전/오후를 표시하고 있기 때문이다.

title: Text(widget.alarm != null ? '알람 편집' : '알람 추가',
            style: TextStyle(color: Colors.white)),
  • appBar에 alarm 값이 있다는 것은 알람 편집이고, alarm 값이 없으면 알람 추가를 보여주므로 인해 사용자가 현재 어떤 상태인지를 인지시켜주기 위해 처리해줬다.

GestureDetector(
  onTap: () {
    Get.find<AlarmController>().saveAlarm(id: widget.alarm?.id);
    Get.back();
  },
  child: Padding(
    padding: const EdgeInsets.only(right: 15),
    child: Text(
      '저장',
      style: TextStyle(
        color: Color(0xffff9f0a),
        fontSize: 20,
      ),
    ),
  ),
)
  • 저장 시 id 값을 넘겨주는 이유는 id 값이 있을 경우는 알람 목록에서 id에 해당되는 값의 모델을 수정해 주기 위함이다.
    새로운 알람을 생성에는 알람이 없기 때문에 null이 전달될 것이다.


📌 AlarmController 수정

 void editAlarm(AlarmModel alarm) {
    hour = alarm.hour;
    minute = alarm.minute;
  }
  • 시간에 따라 자동으로 오전/오후를 표시하기 위해, 화면이 그려지기 전에 alarm 모델을 통해 화면에서 사용되고 있는 hourminute 값을 수정해준다.

void saveAlarm({String? id}) {
  if (id != null) {
    final newAlarmList = alarmList.map((element) {
      if (element.id == id) {
        element.hour = hour;
        element.minute = minute;
      }
      return element;
    }).toList();
    alarmList = newAlarmList;
  } else {
    alarmList.add(AlarmModel(hour: hour, minute: minute));
  }
//
  update();
}
  • id 값의 유/무를 통해 수정을 할지 생성을 할지 로직이다.
    수정의 경우 여기서도 map을 사용했으며, 모든 요소를 return 해주고 있지만 id 값과 같은 알람의 경우 hour, minute를 갱신된 데이터로 변경해 주고 return 해주고 있음을 확인할 수 있다.


6-7 개별 알람에 활성화/비활성화 기능 적용

+++
✔ 홈 화면에 알람 요소별로 스위치 위젯이 있다.
해당 위젯을 켜고 / 끌 때 실제 알람 모델의 isOn 상태를 바꿔주면 된다.


7. 스레드 앱

📚 기능 명세

  • 스레드 등록 시 글은 반드시 입력되어야 한다.
  • 이미지는 선택은 옵셔널이다.
  • 스레드 등록이 완료되면 홈 화면에 등록된 피드를 확인할 수 있다.
  • 피드 등록 시간이 timeago 라이브러리를 통해 표시한다.
  • 피드 별 우측의 … 버튼을 통해 bottomsheet가 활성화되고 삭제 및 수정 버튼이 노출된다.
  • 삭제 버튼을 통해 피드를 삭제할 수 있다.
  • 수정 버튼을 누르면 수정 피드 페이지로 이동되어 수정이 가능하다.
  • 수정은 글과 이미지를 추가 및 삭제할 수 있다.

📚 기능명세에 따른 할일 목록

  • 스레드 등록 페이지 route 설정
  • 스레드 등록 화면 구성(위젯 구성)
  • 이미지 선택 라이브러리를 통한 이미지 선택 개발
  • 스레드 등록 기능 개발
  • 홈 화면 저장된 피드 리스트
  • timeago 라이브러리를 통한 피드 시간 표기
  • 피드 우측 버튼을 통해 bottomSheet 활성화 및 삭제/수정 버튼 배치
  • 피드 삭제 개발

7-1 GetX route 설정

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


Widget build(BuildContext context) {
  return GetMaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      scaffoldBackgroundColor: Colors.white,
      textTheme: TextTheme(
        bodyMedium: TextStyle(color: Colors.white),
      ),
      useMaterial3: true,
    ),
    home: Home(),
  );
}
  • MaterialApp에서 GetMaterialApp으로 변경한다.

📌 thread_write_page.dart 파일 소스 코드

import 'package:flutter/material.dart';
//
class ThreadWritePage extends StatelessWidget {
  const ThreadWritePage({super.key});
//
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('새로운 스레드'),
      ),
    );
  }
}
  • thread_write_page.dart 파일을 만들어 코드를 작성한다.

📌 Get route 방식을 통해 페이지 변환

return GestureDetector(
  onTap: () {
    Get.to(ThreadWritePage());
  },
  child: Column(
    children: [
      Row(
        children: [
          Image.asset(
            'assets/images/profile_image.png',
            width: 50,
          ),
          SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  'Kimsungduck',
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Color(0xff262626),
                  ),
                ),
                Text(
                  '새로운 소식이 있나요?',
                  style: TextStyle(color: Color(0xff9a9a9a), fontSize: 14),
                ),
              ],
            ),
          )
        ],
      ),
      SizedBox(height: 15),
      Row(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          SizedBox(width: 60),
          GestureDetector(
              child:
                  Image.asset('assets/images/photo_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/photo_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child: Image.asset('assets/images/gif_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child: Image.asset('assets/images/mic_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/align_icon.png', width: 30)),
        ],
      )
    ],
  ),
);
  • home.dart 파일에서 _quickFeedWriteView 위젯의 아무데나를 눌러도 AlarmWritePage로 이동되도록 수정한다.

7-2 스레드 등록 화면 구성(위젯)

📌 appbar를 통한 header 구성

 appBar: AppBar(
  leading: GestureDetector(
    onTap: Get.back,
    behavior: HitTestBehavior.translucent,
    child: Center(
      child: Text(
        '취소',
        style: TextStyle(
          fontSize: 20,
        ),
      ),
    ),
  ),
  backgroundColor: Colors.transparent,
  title: Text('새로운 스레드'),
  actions: [
    GestureDetector(
      onTap: () {},
      child: Padding(
        padding: const EdgeInsets.only(right: 15),
        child: Icon(
          Icons.more_horiz,
        ),
      ),
    )
  ],
),

💡 Appbar의 title이 iOS는 default로 중앙정렬이 되어있으나, android의 경우 좌측정렬로 보이게 된다. 이는 정상적인 상황이지만, 만일 안드로이드에서도 title이 중앙으로 위치시키기 원한다면, appbar의 옵션중 centerTitle이라는 옵션을 centerTitle : true,로 설정하면 안드로이드에서도 제목이 중앙으로 배치된다.


📌 body 영역 home 피드 상단 소스코드 활용

Padding(
  padding: const EdgeInsets.all(15.0),
  child: Column(
    children: [
      Row(
        children: [
          Image.asset(
            'assets/images/profile_image.png',
            width: 50,
          ),
          SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  'Kimsungduck',
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Color(0xff262626),
                  ),
                ),
                Text(
                  '새로운 소식이 있나요?',
                  style:
                      TextStyle(color: Color(0xff9a9a9a), fontSize: 14),
                ),
              ],
            ),
          )
        ],
      ),
      SizedBox(height: 15),
      Row(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          SizedBox(width: 60),
          GestureDetector(
              child:
                  Image.asset('assets/images/photo_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/photo_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/gif_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/mic_icon.png', width: 30)),
          SizedBox(width: 10),
          GestureDetector(
              child:
                  Image.asset('assets/images/align_icon.png', width: 30)),
        ],
      )
    ],
  ),
),
  • 바디 영역은 피드를 입력할 수 있는 창으로 구성해야 한다.
    그런데 보여지는 화면이 피드 홈 화면에 상단과 동일한 구성임을 알 수 있다.
    여기서 그 소스코드를 활용한다.

📌 body 영역 TextField로 변경

TextField(
  cursorHeight: 16,
  decoration: InputDecoration(
    isDense: true,
    hintText: '새로운 소식이 있나요?',
    hintStyle: TextStyle(
      color: Color(0xff9a9a9a),
      fontSize: 14,
    ),
    contentPadding: EdgeInsets.zero,
    border: InputBorder.none,
  ),
),
  • 새로운 소식이 없나요? 이 부분은 TextField로 변경한다.

📌 이미지 선택시 노출될 영역 개발

Row(
  children: [
    SizedBox(width: 50),
    Expanded(
      child: SizedBox(
        height: 250,
        child: PageView(
          padEnds: false,
          controller: PageController(viewportFraction: 0.4),
          children: [
            Padding(
              padding: const EdgeInsets.all(4.0),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Stack(children: [
                  Container(
                    color: Colors.grey.shade200,
                  ),
                  Positioned(
                    right: 5,
                    top: 5,
                    child: Icon(Icons.close),
                  )
                ]),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(4.0),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Stack(children: [
                  Container(
                    color: Colors.grey.shade200,
                  ),
                  Positioned(
                    right: 5,
                    top: 5,
                    child: Icon(Icons.close),
                  )
                ]),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(4.0),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Stack(children: [
                  Container(
                    color: Colors.grey.shade200,
                  ),
                  Positioned(
                    right: 5,
                    top: 5,
                    child: Icon(Icons.close),
                  )
                ]),
              ),
            ),
          ],
        ),
      ),
    ),
  ],
),

Padding(
  padding: const EdgeInsets.all(4.0),
  child: ClipRRect(
    borderRadius: BorderRadius.circular(8),
    child: Stack(children: [
      Container(
        color: Colors.grey.shade200,
      ),
      Positioned(
        right: 5,
        top: 5,
        child: Icon(Icons.close),
      )
    ]),
  ),
),
  • 이미지 선택의 개수만큼 반복되어 그려질 위젯 구성이다.
    Container 부분이 이미지로 변경될 부분이며, Stack으로 감싸준 이유는 이미지 선택 후에 삭제가 필요할 경우가 있기 때문에 Icons.close를 이미지 위에 올려서 Stack을 사용한 것이다.

    또한, 이미지 개수만큼 PageView를 사용한다.
    이 부분은 SinglechildScrollview를 사용해서 개발할 수도 있고, PageView로도 개발할 수 있다.
    PageView로 선택한 이유는 스크롤 시 자동으로 자석효과 스냅핑 되는 것을 해주기 위해서고, 만약 그것이 필요 없다면 SingleChildScrollView가 좀 더 사용이 편할 것이다.


7-3 이미지 선택 라이브러리를 통한 이미지 선택 개발

📌 Image_picker 라이브러리 설치

flutter pub add image_picker
  • 스레드 등록 시 이미지 선택이 가능해야하며, 이를 처리하기 위해서 라이브러리를 설치해야 한다.

📌 Image_picker 라이브러리 설치(iOS)

	<key>NSPhotoLibraryUsageDescription</key>
<string>이미지 컨텐츠를 등록할 목적으로 사진첩에 접근을 합니다.</string>
  • 설치하고 나면 안드로이드의 경우 별도의 설정이 없이 사용이 가능하지만, iOS의 경우 접근 권한에 대한 권한 요청을 해야한다.

    좌측 프로젝트 탐색기에서 ios 폴더 > Runner 폴더 > Info.plist 파일을 선택하여, 가장 하단부에 코드를 삽입한다.

    💡 반드시 <dict></dict> 사이에 권한 요청 소스코드를 넣어야 한다.
    📌 이미지 선택이벤트 연결
GestureDetector(
  onTap: (){
	  getImagePickerData();
  },
  child:
      Image.asset('assets/images/photo_icon.png', width: 30),
),

📌 getImagePickerData 연결

Future<void> getImagePickerData() async {
  final ImagePicker picker = ImagePicker();
  final List<XFile> images = await picker.pickMultiImage();
  print(images.length);
}
  • 정상적으로 사진첩에 접근이 되며, 여러 개의 이미지 선택이 가능해진다.

    💡 라이브러리를 설치한 후에는 반드시 앱을 종료 후에 다시 실행해야한다.


다음 시간에는 아래 내용을 실습할 예정이다.

7-4 스레드 등록 기능 개발
7-5 홈 화면 저장된 피드 리스트
7-6 timeago 라이브러리를 통한 피드 시간 표기
7-7 bottomSheet 활성화 및 삭제/수정 버튼 배치
7-8 피드 삭제 개발


실습 내용이 무척 어렵다. 초보가 아닌 다른 개발을 해봤던 사람한테 맞춰진 느낌이다.
짧은 시간에 여러 내용을 담기 위해, 코드 작성법이 아닌 코드 복붙을 활용해 이러한 이유 때문에 쓰였다는 느낌이다.

이 부분은 작성만 해두고, 단순히 이해하고 넘어가는 것으로 해야될 것 같다.
dart 파일 여러개를 활용하고, 코드를 붙여넣어서 수정하고, 이런 흐름이라 시간을 내어서 추가 공부가 더 필요해보인다.

profile
💻 [25.05.26~] Flutter 공부중⏳

0개의 댓글