[TIL] Day 30 Flexible & MediaQuery & 블로그 앱 만들기(UI 구현, Firebase CRUD)

현서·2026년 1월 6일

[TIL] Flutter 9기

목록 보기
42/102
post-thumbnail

📍 튜터님과 Widget 공부

✏️ Flexible

Flexible란?

Row, Column, Flex 안에서 사용
남은 공간을 유연하게 분배하는 위젯
자식 위젯의 크기를 강제하지 않음

기본 사용법

Flexible(
  child: Widget(),
)
Row(
  children: [
    Flexible(child: Container()),
    Flexible(child: Container()),
  ],
)

주요 속성

flex

남은 공간 분배 비율
기본값: 1

Flexible(
  flex: 2,
  child: Container(),
)

fit

공간을 채우는 방식 결정

설명
FlexFit.loose필요한 만큼만 사용 (기본값)
FlexFit.tight남은 공간을 꽉 채움
Flexible(
  fit: FlexFit.loose,
  child: Widget(),
)

vs Expanded

Expanded

남은 공간을 무조건 채움
내부적으로 Flexible + FlexFit.tight

Expanded(
  child: Widget(),
)

비교 정리

항목FlexibleExpanded
공간 강제
자식 크기 존중
기본 fitloosetight
사용 목적유연한 크기공간 채우기

✏️ MediaQuery

MediaQuery란?

현재 디바이스의 화면 정보를 가져오는 위젯
화면 크기, 방향, 안전 영역, 텍스트 배율 등 확인 가능
반응형 UI 만들 때 필수
👉 “이 기기가 어떤 화면 상태인지 알려주는 도구”

왜 쓸까?

기기마다 화면 크기가 다르기 때문
고정 px 사용 시 레이아웃 깨짐 방지
비율 기반 UI 설계 가능

기본 사용법

final size = MediaQuery.of(context).size;

context 기준으로 현재 화면 정보 가져옴
보통 build() 안에서 사용

자주 쓰는 속성들

화면 크기

MediaQuery.of(context).size.width
MediaQuery.of(context).size.height
속성설명
width화면 가로 길이
height화면 세로 길이

화면 방향

MediaQuery.of(context).orientation

Orientation.portrait : 세로
Orientation.landscape : 가로

텍스트 배율

textScaleFactor

MediaQuery.of(context).textScaleFactor

사용자가 시스템에서 설정한 글자 크기 비율
접근성 대응에 중요

  • 문제점:
    모든 텍스트가 일괄 비율 확대
    특정 텍스트만 예외 처리 어려움
    접근성 대응이 제한적

TextScaler

MediaQuery.of(context).textScaler

텍스트 크기 계산 로직을 객체로 관리
선형 / 비선형 스케일링 가능
위젯 단위 제어 가능

① 시스템 기본

TextScaler.noScaling

스케일링 ❌
접근성 무시 (주의)

② 선형 스케일링

TextScaler.linear(1.2)

기존 textScaleFactor와 동일한 개념
모든 텍스트를 동일 비율로 확대

③ MediaQuery 기반 (실전)

final textScaler = MediaQuery.of(context).textScaler;

접근성 대응 O
가장 안전한 방식

안전 영역 (노치, 상태바)

MediaQuery.of(context).padding

상태바, 노치, 홈 인디케이터 영역 포함


📝 블로그 앱 만들기

✏️ 프로젝트 소개 & 초기 세팅

목표

블로그 앱 UI 기초 구현
TextFormField 포함 레이아웃 구성
MVVM 구조 + Riverpod 기반 프로젝트 세팅

UI 구성 개요

페이지 구성
Home : 글 목록
Detail : 글 상세
Write : 글 작성 / 수정
→ 글쓰기 & 글수정은 구조 동일 → 하나의 페이지 재사용

레이아웃 구조

상단: AppBar
본문: 입력폼(TextFormField), 버튼 등
공통 구조는 위젯 분리해서 재사용

프로젝트 폴더 구조 (MVVM)

lib/
 ┣ data/
 ┃ ┣ model/        → 데이터 모델
 ┃ ┗ repository/   → 데이터 가공 및 변환
 ┣ ui/
 ┃ ┣ pages/
 ┃ ┃ ┣ home/
 ┃ ┃ ┣ detail/
 ┃ ┃ ┗ write/
 ┃ ┗ widgets/      → 공용 위젯
 ┗ main.dart

Page : UI 담당
ViewModel : 상태 & 로직 담당
widgets/ : 페이지 전용 / 공용 위젯 분리

정리

  • MVVM 구조로 UI / 로직 분리
  • 글쓰기 & 수정 페이지 재사용 설계
  • Riverpod으로 상태 관리 준비 완료
  • 이후 단계에서 Firebase + CRUD 연결 예정

✏️ HomePage UI 구현

HomePage 기본 레이아웃

Scaffold
 ├ AppBar
 └ Padding
    └ Column
       ├ Text ("최근 글")
       └ Expanded (ListView)

Column 안에서 ListView 사용 시 반드시 Expanded 필요

✏️ DetailPage UI 구현

DetailPage 기본 레이아웃

ListView
 ├ Image.network
 ├ SizedBox
 └ Padding
    └ Column
       ├ 제목
       ├ 작성자
       ├ 날짜
       └ 본문

전체 스크롤 → ListView
이미지에는 패딩 ❌ → 텍스트 영역만 Padding 적용
padding: EdgeInsets.only(bottom: 300)
하단 여유 공간 확보 (추후 버튼/입력 UI 대비)

✏️ WritePage UI 구현

TextFormField

TextFormField란?

TextField + 유효성 검사 기능
validator 함수로 검증

validator 규칙

❌ 실패 → 에러 문자열 반환
⭕ 성공 → null 반환

Form + GlobalKey

왜 Form으로 감싸야 하나?
여러 TextFormField를 한 번에 검증

  • 핵심 구조
Form
 └ ListView
    └ TextFormField들
  • GlobalKey 역할

특정 Form 위젯의 상태(FormState)에 접근
formKey.currentState?.validate() 호출 가능

  • 유효성 검사 실행
formKey.currentState!.validate();

내부의 모든 TextFormFieldvalidator 실행
실패 시 에러 메시지 표시

WritePage Body 구조

GestureDetector
 └ Scaffold
    ├ AppBar
    └ Form
       └ Padding
          └ ListView
             ├ 작성자
             ├ 제목
             ├ 내용(멀티라인)
             └ 이미지 업로드 영역

입력 필드 구성

작성자 / 제목
단일 라인 입력
TextInputAction.done

내용
여러 줄 입력
maxLines: null
expands: true
keyboardType: multiline

✏️ Firebase 소개 & 세팅

Firebase란?

서버 구현 없이 앱 개발을 도와주는 구글의 백엔드 플랫폼
모바일 / 웹 앱에서 로그인, 데이터 저장, 파일 업로드 등을 쉽게 구현 가능

Firebase를 쓰는 이유

서버 직접 구현 ❌
설정 + 앱 코드만으로 실제 서비스 구조 구현 가능

Firebase 주요 서비스

Authentication

로그인 / 회원가입 구현용

Firestore

앱 데이터 저장용 데이터베이스

Storage

이미지, 파일 저장소

📦 Firestore란

NoSQL 기반 데이터베이스
JSON 형태 (key : value)로 데이터 저장
실시간 동기화 지원
오프라인에서도 동작 가능
다양한 조건 검색 가능

Firestore 구조

Collection : 문서들의 묶음
Document : 실제 데이터 단위 (JSON 형태)

블로그 앱 예시

Collection : Posts
Document : 각각의 블로그 글

💾 Firebase Storage

이미지 / 동영상 / 파일 저장소
Firestore에는 파일 URL만 저장
실제 파일은 Storage에 저장

이유

Firestore는 파일 저장 용량 제한 (약 1MB)
Storage는 파일 전용

Firebase Firestore 연동 절차 (Flutter)

1️⃣ Firebase 프로젝트 생성

Firebase Console → 프로젝트 만들기
Analytics 비활성화
Cloud Firestore 생성 (지역: 서울)

2️⃣ Firebase CLI 설치

Firebase 설정을 쉽게 해주는 도구

firebase login

3️⃣ FlutterFire CLI 설치

dart pub global activate flutterfire_cli

4️⃣ Flutter 프로젝트에 Firebase 연결

flutterfire configure

Firebase 프로젝트 선택
플랫폼 선택 (Android / iOS)
패키지명 입력
예: com.example.flutter_blog_app

✔️ 결과
firebase_options.dart 생성
android / ios 설정 파일 자동 추가

5️⃣ Firebase 초기화 코드 추가

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const ProviderScope(child: MyApp()));
}

6️⃣ iOS 설정 (중요)

ios/Podfile
iOS 최소 버전 15 이상으로 변경

7️⃣ 패키지 추가

flutter pub add firebase_core
flutter pub add cloud_firestore

✏️ Firebase Firestore CRUD

CRUD 개념

CREATE : 생성
READ : 조회
UPDATE : 수정
DELETE : 삭제

Firestore 사용 기본 흐름

  • FirebaseFirestore.instance 가져오기
  • collection('posts') → 컬렉션 참조
  • doc() 또는 doc(id) → 문서 참조
  • get / set / delete 실행 시 실제 통신 발생

전체 문서 조회 (READ - List)

Firestore → Collection 참조 → get() → QuerySnapshot → docs → data()

핵심
collectionRef.get()
결과 타입: QuerySnapshot
문서 목록: snapshot.docs

특정 문서 조회 (READ - One)

Firestore → Collection → doc(id)get() → DocumentSnapshot

핵심
문서 ID 필수
결과 타입: DocumentSnapshot
data()로 실제 데이터 접근

문서 생성 (CREATE)

Collection → doc() → set(data)

핵심
doc() → 자동 ID 생성
set(Map) 호출 시 실제 저장
날짜는 보통 DateTime.toIso8601String() 사용

문서 수정 (UPDATE)

Collection → doc(id)set(data)

주의
set()은 기존 데이터 덮어씀
일부 필드만 수정하려면 update() 사용 가능

문서 삭제 (DELETE)

Collection → doc(id)delete()

Firebase Firestore 블로그 CRUD 구현

전체 포스트 조회 (READ - List)

Firestore → posts 컬렉션 → get()
→ QuerySnapshot → docs
→ Map 가공 → Post.fromJson → List<Post>
final docs = result.docs;

   return docs.map((doc) {
      final map = doc.data();
      final newMap = {'id': doc.id, ...map};
      return Post.fromJson(newMap);
    }).toList();

Firestore 문서 구조

Firestore 문서에는 두 덩어리가 있음

doc
 ├─ doc.id        ← 문서 고유 ID
 └─ doc.data()    ← 실제 데이터 (Map)

doc.data() 안에는 id가 없음

Post.fromJson이 원하는 형태

Post.fromJson({
  'id': '문서ID',
  'title': '제목',
  'content': '내용',
});

👉 id + 데이터가 하나의 Map으로 필요

정리

final map = doc.data();
Firestore 문서 안의 데이터만 꺼냄
타입: Map<String, dynamic>

final newMap = {'id': doc.id, ...map};
doc.id 👉 Firestore 문서 고유 ID
...map 👉 기존 데이터 전부 펼쳐서 합치기

.toList()
map() 결과는 아직 Iterable
.toList()List<Post>로 변환

doc.iddata에 없으므로 직접 추가
map() → toList()로 변환

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_firebase_blog_app/data/model/post.dart';

class PostRepository {
  Future<List<Post>?> getAll() async {
    try {
      // 1. 파이어스토어 인스턴스 가져오기
      final firestore = FirebaseFirestore.instance;
      // 2. 컬렉션 참조 가져오기
      final collectionRef = firestore.collection('posts');
      // 3. 값 불러오기
      final result = await collectionRef.get();

      final docs = result.docs;
      return docs.map((doc) {
        final map = doc.data();
        final newMap = {'id': doc.id, ...map};
        return Post.fromJson(newMap);
      }).toList();
    } catch (e) {
      print(e);
      return null;
    }
  }

  // 1. Create: 데이터 쓰기
  Future<bool> insert({
    required String title,
    required String content,
    required String writer,
    required String imageUrl,
  }) async {
    try {
      // 1. 파이어스토어 인스턴스 가져오기
      final firestore = FirebaseFirestore.instance;
      // 2. 컬렉션 참조 가져오기
      final collectionRef = firestore.collection('posts');
      // 3. 문서 참조 만들기
      final docRef = collectionRef.doc();
      // 4. 값 쓰기
      await docRef.set({
        'title': title,
        'content': content,
        'writer': writer,
        'imageUrl': imageUrl,
        'cretrAt': DateTime.now().toIso8601String(),
      });
      return true;
    } catch (e) {
      print(e);
      return false;
    }
  }

  // 2. Read: 하나의 Doc 가져오기

  Future<Post?> getOne(String id) async {
    try {
      // 1. 파이어스토어 인스턴스 가져오기
      final firestore = FirebaseFirestore.instance;
      // 2. 컬렉션 참조 가져오기
      final collectionRef = firestore.collection('posts');
      // 3. 문서 참조 만들기
      final docRef = collectionRef.doc(id);
      // 4. 데이터 가져오기
      final doc = await docRef.get();
      Post.fromJson({'id': doc.id, ...doc.data()!});
    } catch (e) {
      print(e);
      return null;
    }
  }

  // 3. Udate: 도큐먼트 수정

  Future<bool> update({
    required String id,
    required String title,
    required String content,
    required String writer,
    required String imageUrl,
  }) async {
    try {
      // 1. 파이어스토어 인스턴스 가져오기
      final firestore = FirebaseFirestore.instance;
      // 2. 컬렉션 참조 가져오기
      final collectionRef = firestore.collection('posts');
      // 3. 문서 참조 만들기
      final docRef = collectionRef.doc(id);
      // 4. 값을 업데이트 해주기 (set메서드)
      // 업데이트 할 값 Map 형태로 넣어주기 :id에 해당하는 문서가 없을 때 새로 생성
      // docRef.set(data);
      await docRef.update({
        'title': title,
        'content': content,
        'writer': writer,
        'imageUrl': imageUrl,
      });
      return true;
    } catch (e) {
      print(e);
      return false;
    }
  }

  // 4. Delete: 도큐먼트 삭제

  Future<bool> delete(String id) async {
    try {
      // 1. 파이어스토어 인스턴스 가져오기
      final firestore = FirebaseFirestore.instance;
      // 2. 컬렉션 참조 가져오기
      final collectionRef = firestore.collection('posts');
      // 3. 문서 참조 만들기
      final docRef = collectionRef.doc(id);
      // 4. 삭제
      await docRef.delete();
      return true;
    } catch (e) {
      print(e);
      return false;
    }
  }
}

HomeViewModel 역할

상태(State) : List<Post>
역할 : Firestore에서 전체 포스트 조회 → 상태 갱신

Firestore → Repository → ViewModel → UI(ListView)
ViewModel은 상태만 관리, UI는 상태를 그리기만 한다

DetailViewModel (업데이트된 Riverpod 방식)

arg를 build()에서 받지 않고
Notifier 생성자에서 직접 받는 방식으로 변경됨

DetailViewModel 역할

상태(State) : Post?
입력값(arg) : Post

기능
상세 화면에서 사용할 Post 유지
해당 Post 삭제 (Firestore)

class DetailViewModel extends Notifier<Post?> {
  final Post arg;

  DetailViewModel(this.arg);

  
  Post? build() {
    return arg;
  }

  Future<bool> deletePost() async {
    final postRepository = PostRepository();
    return await postRepository.delete(arg.id);
  }
}

arg는 선택된 Post
build()에서 해당 Post를 그대로 state로 설정
삭제 시 arg.id 사용

Provider 등록 (family + autoDispose)

final detailViewModelProvider =
    NotifierProvider.autoDispose.family<DetailViewModel, Post?, Post>(
  (arg) {
    return DetailViewModel(arg);
  },
);
요소의미
familyPost마다 다른 ViewModel 생성
autoDisposeDetailPage 종료 시 자동 해제
<DetailViewModel, Post?, Post>ViewModel / 상태 / 전달 인자

기존 방식과 차이

이전 방식현재 방식
AutoDisposeFamilyNotifierNotifier + family
build(arg)생성자에서 arg 받음
arg를 내부 필드로 사용더 직관적

0개의 댓글