[TIL] Day 31 Spacer & InheritedWidget & 블로그 앱 만들기 - CRUD 구현 & 마켓 앱 만들기 - UI 구현 & AI 리터러시 특강

현서·2026년 1월 7일

[TIL] Flutter 9기

목록 보기
43/65
post-thumbnail

📍 튜터님과 Widget 공부

✏️ Spacer

Row, Column, Flex 안에서 남는 공간을 차지하도록 설계된 위젯
빈 공간을 만들 때 사용
직접 width나 height를 지정하지 않고, 비율(flex)로 공간을 나눌 수 있음

사용법

Row(
  children: [
    Text('왼쪽'),
    Spacer(),       // 자동으로 남는 공간 차지
    Text('오른쪽'),
  ],
)

위 예제에서 왼쪽과 오른쪽 텍스트 사이에 Spacer가 남는 공간을 채움
결과: 텍스트가 양 끝으로 배치됨

flex 속성

flex를 지정하면 여러 Spacer가 있을 때 공간 비율을 조정 가능

Row(
  children: [
    Text('왼쪽'),
    Spacer(flex: 2), // 2/3 공간 차지
    Text('가운데'),
    Spacer(flex: 1), // 1/3 공간 차지
    Text('오른쪽'),
  ],
)

첫 번째 Spacer가 두 번째 Spacer보다 2배 넓은 공간을 차지함

정리

빈 공간만 차지하며, child를 가질 수 없음
주로 정렬, 간격 조정용으로 사용
Padding이나 SizedBox와 달리 비율 기반으로 공간을 나눌 수 있어 유연함

✏️ InheritedWidget

Flutter에서 위젯 트리 전체 또는 일부 위젯에 데이터를 전달할 때 사용하는 위젯
부모 → 자식 위젯으로 데이터를 효율적으로 공유할 수 있음
일반적으로 상태 관리의 기반으로 사용됨

Provider, Riverpod, Bloc 등 많은 상태 관리 패키지들이 내부적으로 InheritedWidget 사용

기본 구조

class MyInheritedWidget extends InheritedWidget {
  final int counter;

  MyInheritedWidget({
    required this.counter,
    required Widget child,
  }) : super(child: child);

  // 데이터 변경 시 재빌드 조건
  @override
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return oldWidget.counter != counter;
  }

  // 위젯 트리 어디서든 접근할 수 있게 하는 편의 메서드
  static MyInheritedWidget? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>();
  }
}

사용 방법

  1. 위젯 트리에서 Wrap
MyInheritedWidget(
  counter: 5,
  child: MyChildWidget(),
)
  1. 자식 위젯에서 접근
class MyChildWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final inherited = MyInheritedWidget.of(context);
    return Text('Counter: ${inherited?.counter}');
  }
}

dependOnInheritedWidgetOfExactType를 사용하면 값이 바뀌면 자동으로 rebuild됨.

특징

특징설명
데이터 전달위젯 트리 깊은 곳까지 데이터 전달 가능
재빌드 관리updateShouldNotify로 효율적 재빌드 가능
범위 제한 가능특정 위젯 트리 범위에만 데이터 적용 가능
불변성 권장값이 변경되면 새 위젯을 생성하여 전달

📝 블로그 앱 만들기

✏️ WriteViewModel 구현

1️⃣ 상태 클래스 (WriteState)

class WriteState {
  bool isWriting;
  WriteState(this.isWriting);
}
isWriting

글 작성/수정 중인지 여부
true → 로딩 상태
false → 입력 가능 상태

2️⃣ ViewModel (WriteViewModel)

class WriteViewModel extends Notifier<WriteState> {
  final Post? arg;

  WriteViewModel(this.arg);

  
  WriteState build() {
    return WriteState(false);
  }

Riverpod Notifier 기반 ViewModel

  • arg
    • null → 새 글 작성
    • Post 존재 → 기존 글 수정
    • 초기 상태는 isWriting = false

insert 메서드 (Create / Update 공통)

Future<bool> insert({
  required String writer,
  required String title,
  required String content,
}) async {
  1. 로딩 시작
state = WriteState(true);
  1. Post가 null이면 → 새 글 작성 (Create)
if (arg == null) {
  final result = await postRepository.insert(
    title: title,
    content: content,
    writer: writer,
    imageUrl: "http://picsum.potos/300/300",
  );
  state = WriteState(false);
  return result;
}
  1. Post가 있으면 → 글 수정 (Update)
else {
  final result = await postRepository.update(
    id: arg!.id,
    title: title,
    content: content,
    writer: writer,
    imageUrl: "https://picsum.photos/200/300",
  );
  state = WriteState(false);
  return result;
}
  1. 처리 완료 후 로딩 종료
state = WriteState(false);

3️⃣ ViewModel Provider

final writeViewModelProvider = NotifierProvider.autoDispose
    .family<WriteViewModel, WriteState, Post?>((arg) {
  return WriteViewModel(arg);
});
  • family - Post?를 인자로 전달 가능
  • autoDispose - 페이지 벗어나면 ViewModel 자동 제거
  • 화면마다 다른 Post 기준 상태 관리 가능

정리

  • WriteViewModel
    Post == null → 글 작성
    Post != null → 글 수정

  • WriteState.isWriting
    작성/수정 중 UI 제어용 상태

최신 Riverpod에서 AutoDisposeFamilyNotifier 대체 구조

✏️ Firebase Firestore 실시간 CRUD 적용 정리

왜 실시간 처리가 필요할까?

CRUD가 발생한 뒤 화면을 바로 갱신하는 방법은 두 가지

1. CRUD 이후 다른 ViewModel을 직접 갱신

  • 수동
  • 의존성 복잡해짐

2. Firestore 실시간 스트림 사용 (권장)

  • DB 변경 즉시 화면 반영
  • 상태 관리 단순
  • 여러 화면 자동 동기화
    → Firestore는 실시간 DB이기 때문에 Stream을 사용하는 것이 정석

PostRepository – Stream 추가

1️⃣ 게시글 목록 스트림

Stream<List<Post>> postListStream() {
  final fireStore = FirebaseFirestore.instance;
  final collectionRef = fireStore.collection('posts');

  final stream = collectionRef.snapshots();
  final newStream = stream.map((event) {
    return event.docs.map((e) {
      return Post.fromJson({'id': e.id, ...e.data()});
    }).toList();
  });

  return newStream;
}

역할
posts 컬렉션 변경 감지
추가 / 수정 / 삭제 발생 시마다 자동으로 List<Post> 방출

  final collectionRef = fireStore
        .collection('posts')
        .orderBy('createAt', descending: true);

orderBy 정렬 적용 가능

2️⃣ 단일 게시글 스트림

Stream<Post?> postStream(String id) {
  final fireStore = FirebaseFirestore.instance;
  final collectionRef = fireStore.collection('posts');
  final docRef = collectionRef.doc(id);

  final stream = docRef.snapshots();

  final newStream = stream.map((e) {
    if (e.data() == null) {
      return null;
    }
    return Post.fromJson({'id': e.id, ...e.data()!});
  });
  return newStream;
}

역할
특정 게시글 하나만 실시간 감시
삭제되면 null 반환

HomeViewModel – 실시간 목록 반영

class HomeViewModel extends Notifier<List<Post>> {
  
  List<Post> build() {
    getAllPost();
    return [];
  }

  void getAllPost() async {
    final postRepo = PostRepository();
    // final posts = await postRepo.getAll();
    // state = posts ?? [];
    final stream = postRepo.postListStream();
    final streamSubScription = stream.listen((posts) {
      state = posts;
    });
    // 중요!!
    ref.onDispose(() {
      streamSubScription.cancel();
    });
  }
}

listen()으로 스트림 구독
Firestore 변경 → state 자동 업데이트
onDispose에서 반드시 구독 해제 (메모리 안전)

DetailViewModel – 게시글 단일 실시간 반영

  void listenStream() {
    final stream = postRepository.postStream(arg.id);
    final streamSub = stream.listen((data) {
      if (data != null) {
        state = data;
      }
    });
    ref.onDispose(() {
      streamSub.cancel();
    });
  }

역할
게시글 상세 화면 실시간 동기화
수정 시 자동 반영
삭제 시 state == null

Firebase Storage 이미지 업로드

사용 패키지

flutter pub add firebase_storage
flutter pub add image_picker

firebase_storage : 이미지 파일 업로드
image_picker : 갤러리에서 이미지 선택

iOS 권한 설정

ios/Runner/info.plist

<key>NSPhotoLibraryUsageDescription</key>
<string>사진 업로드를 위한 라이브러리 권한을 허용해 주세요</string>

info.plist란?

iOS 앱 설정 파일
앱 이름, 권한 요청 문구 등 정의
권한 없으면 앱 실행 중 바로 크래시

image_picker 기본 사용법

onTap: () async {
                  // 1. 이미지 피커 객체 생성
                  ImagePicker imagePicker = ImagePicker();

                  // 2. 이미지 피커 객체의 pickImage 메서드 호출
                  XFile? xFile = await imagePicker.pickImage(
                    source: ImageSource.gallery,
                  );
                  print('${xFile?.path}');
                },
  • 갤러리 열기
  • 선택 안 하면 null
  • 반환 타입: XFile
    • path : 파일 경로
    • readAsBytes() : 바이트 데이터

WriteViewModel 구조

build()

 
  WriteState build() {
    return WriteState(false, arg?.imageUrl);
  }

수정 화면이면 기존 이미지 URL 유지
신규 작성이면 null

Firebase Storage 이미지 업로드

  void uploadImage(XFile xFile) async {
    try {
      // firebase store 사용법
      // 1. 객체 가지고 오기
      final storage = FirebaseStorage.instance;
      // 2. 스토리지 참조 만들기
      Reference ref = storage.ref();
      // 3. 파일 참조 만들기
      Reference fileRef = ref.child(
        '${DateTime.now().microsecondsSinceEpoch}_${xFile.path}',
      );
      // 4. 쓰기
      await fileRef.putFile(File(xFile.path));
      // 5. 파일에 접근할 수 있는 url 만들기
      String imageUrl = await fileRef.getDownloadURL();
      state = WriteState(state.isWriting, imageUrl);
    } catch (e) {
      print(e);
    }
  }

파일명 중복 방지 → timestamp 사용
putFile()로 업로드
getDownloadURL()로 접근 가능한 URL 획득
업로드 후 상태 갱신 → UI 즉시 반영

이러면 블로그 만들기 끝..!!!!


📝 마켓 앱 만들기 – 프로젝트 세팅

1️⃣ 프로젝트 개요

앱 이름: flutter_market_app
아키텍처: MVVM
상태 관리: Riverpod
화면 구성: 여러 페이지로 구성, 각 페이지 별 레이아웃 구현 예정

2️⃣ 프로젝트 폴더 구조 (기초 뼈대)

flutter_market_app/
├─ lib/
│  ├─ main.dart
│  ├─ ui/
│  │  ├─ pages/
│  │  │  ├─ welcome/
│  │  │  │  └─ welcome_page.dart
│  │  │  ├─ login/
│  │  │  │  └─ login_page.dart
│  │  │  ├─ join/
│  │  │  │  └─ join_page.dart
│  │  │  ├─ home/
│  │  │  │  └─ home_page.dart
│  │  │  ├─ address/
│  │  │  │  └─ address_search_page.dart
│  │  │  ├─ chat/
│  │  │  │  └─ chat_detail_page.dart
│  │  │  ├─ product/
│  │  │  │  ├─ product_detail_page.dart
│  │  │  │  └─ product_write_page.dart
│  └─ ...

3️⃣ 패키지 설치

Riverpod 설치

flutter pub add flutter_riverpod

Riverpod 사용을 위해 ProviderScope로 앱 감싸기
시작 화면: WelcomePage

import 'package:flutter/material.dart';
import 'package:flutter_market_app/ui/pages/welcome/welcome_page.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: WelcomePage(),
    );
  }
}

📝 WelcomePage, LoginPage UI 구현

✏️ WelcomePage UI

화면 전체를 Column + SizedBox.expand로 구성
이미지, 텍스트, 버튼 배치
버튼 클릭 시 다음 페이지로 이동
시작하기 → AddressSearchPage
로그인 → LoginPage

ElevatedButton Theme

main.dart
앱 전반 버튼 디자인 통일

      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.redAccent.shade100),
        highlightColor: Colors.redAccent.shade100,
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ButtonStyle(
            foregroundColor: WidgetStatePropertyAll(Colors.white),
            backgroundColor: WidgetStatePropertyAll(Colors.redAccent.shade100),
            minimumSize: WidgetStatePropertyAll(Size.fromHeight(50)),
            textStyle: WidgetStatePropertyAll(
              TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            shape: WidgetStatePropertyAll(
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
            ),
          ),
        ),
      ),

✏️ LoginPage UI

레이아웃

키보드 등장 시 화면 축소 문제 해결: ListView 사용
TextFormField + 유효성 검사
로그인 버튼 클릭 시 onLoginClick() 호출

유효성 검사 유틸

validator_utils.dart

class ValidatorUtil {
  static String? validatorId(String? value) {
    if (value?.trim().isEmpty ?? true) {
      return "아이디를 입력해주세요";
    }

    if (value!.length < 2) {
      return "아이디는 2글자 이상이여야 합니다";
    }
  }

  static String? validatorNickname(String? value) {
    if (value?.trim().isEmpty ?? true) {
      return "닉네임를 입력해주세요";
    }

    if (value!.length < 2) {
      return "닉네임은 2글자 이상이여야 합니다";
    }
  }

  static String? validatorPassword(String? value) {
    if (value?.trim().isEmpty ?? true) {
      return "비밀번호를 입력해주세요";
    }

    if (value!.length < 2) {
      return "비밀번호는 2글자 이상이여야 합니다";
    }
  }
}

TextFormField 전역 위젯

아이디, 비밀번호, 닉네임 각각 별도 위젯으로 정의

import 'package:flutter/material.dart';
import 'package:flutter_market_app/core/validator_utils.dart';

class IdTextFormField extends StatelessWidget {
  const IdTextFormField({super.key, required this.controller});
  final TextEditingController controller;
  
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(hintText: "아이디를 입력해주세요"),
      validator: ValidatorUtil.validatorId,
      controller: controller,
    );
  }
}

TextFormField Theme

v3.26 이후 MaterialStateOutlineInputBorder.resolveWith 대신 WidgetStateInputBorder.resolveWith 사용

main.dart


        inputDecorationTheme: InputDecorationTheme(
          hintStyle: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
            color: Colors.grey,
          ),
          // TextField의 내부 여백
          contentPadding: EdgeInsets.symmetric(vertical: 16, horizontal: 20),
          // TextField의 상태가 변경(포커스 받았을때, 아닐때, 유효성 검사 에러 등) 시 호출
          border: WidgetStateInputBorder.resolveWith((states) {
            // 1. states Set 안에 WidgetState.focus가 포함 되어 있을 때
            if (states.contains(WidgetState.focused)) {
              return OutlineInputBorder(
                borderRadius: BorderRadius.circular(8),
                borderSide: BorderSide(color: Colors.grey, width: 2),
              );
              // 2. states Set 안에 WidgetState.error 포함 되어 있을 때
            } else if (states.contains(WidgetState.error)) {
              return OutlineInputBorder(
                borderRadius: BorderRadius.circular(8),
                borderSide: BorderSide(color: Colors.red[100]!, width: 2),
              );
            }
            // 3. 기본 값
            return OutlineInputBorder(
              borderRadius: BorderRadius.circular(8),
              borderSide: BorderSide(color: Colors.grey[300]!, width: 1),
            );
          }),
        ),

📝 AddressSearchPage, JoinPage UI 구현

✏️ AddressSearchPage UI

레이아웃

사용자가 동네(주소)를 검색/선택하는 화면
주소 선택 후 → JoinPage로 이동

AppBar에 TextField 배치
키보드 외 영역 터치 시 키보드 닫기 → GestureDetector
주소 목록은 ListView.builder
버튼 1개만 높이 줄이기 → SizedBox

구현 정리

  • AppBar 안에 TextField를 넣어 검색 UI 자연스럽게 구현
  • ListView를 Expanded로 감싸 overflow 방지
  • Container + transparent color로 터치 영역 확보
  • 주소 선택 시 다음 단계(회원가입)로 이동

✏️ JoinPage UI

레이아웃

주소 선택 후 회원가입 정보 입력
아이디 / 비밀번호 / 닉네임 입력

입력 필드가 많으므로 ListView 사용
기존에 만든 공통 TextFormField 위젯 재사용
Form + validator 사용

📝 HomePage BottomNav UI 구현

✏️ HomePage 탭 레이아웃 구현

BottomNavigationBar 개념 정리

앱 하단의 탭 네비게이션
탭 선택 시 인덱스 변경 → 화면 전환

핵심 속성

  • currentIndex : 현재 선택된 탭 인덱스
  • onTap : 탭 클릭 시 호출 (상태 변경)
  • items : BottomNavigationBarItem 목록

HomePage 탭 상태 관리

탭 인덱스만 관리 → int 사용

📁 lib/ui/pages/home/home_view_model.dart

class HomeViewModel extends Notifier<int> {
  
  int build() {
    return 0;
  }

  void onIndexChanged(int newIndex) {
    state = newIndex;
  }
}

final homeViewModel = NotifierProvider.autoDispose<HomeViewModel, int>(() {
  return HomeViewModel();
});

✔ 현재 선택된 탭 인덱스를 전역 상태로 관리
✔ BottomNavigationBar, FAB, 화면 전환에서 공통 사용

BottomNavigationBar 위젯 분리

위젯 분리 이유
HomePage 코드 단순화
상태 관리 로직과 UI 분리

📁 home_bottom_navigation_bar.dart

class HomeBottomNavigationBar extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, ref, child) {
        final index = ref.watch(homeViewModel);
        final vm = ref.read(homeViewModel.notifier);

        return BottomNavigationBar(
          currentIndex: index,
          onTap: vm.onIndexChanged,
          iconSize: 28,
          selectedLabelStyle: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
          unselectedLabelStyle: TextStyle(fontSize: 12),
          items: const [
            BottomNavigationBarItem(
              icon: Icon(Icons.home_outlined),
              activeIcon: Icon(Icons.home),
              label: "홈",
            ),
            BottomNavigationBarItem(
              icon: Icon(CupertinoIcons.chat_bubble_2),
              activeIcon: Icon(CupertinoIcons.chat_bubble_2_fill),
              label: "채팅",
            ),
            BottomNavigationBarItem(
              icon: Icon(CupertinoIcons.person),
              activeIcon: Icon(CupertinoIcons.person_fill),
              label: "나의 마켓",
            ),
          ],
        );
      },
    );
  }
}

IndexedStack

여러 위젯을 겹쳐두고, 그중 하나만 보여주는 위젯

IndexedStack(
  index: 0,
  children: [
    WidgetA(),
    WidgetB(),
    WidgetC(),
  ],
)

index에 해당하는 위젯만 화면에 보임
나머지 위젯들도 전부 유지된 상태로 존재함

FloatingActionButton 구현

홈 탭(index = 0)일 때만 노출
다른 탭에서는 숨김
FloatingActionButton.extended 이용해 label 들어간 버튼

📁 home_floating_action_button.dart

class HomeFloatingActionButton extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, ref, child) {
        if (ref.watch(homeViewModel) != 0) {
          return const SizedBox();
        }

        return FloatingActionButton.extended(
          label: Text("상품 등록", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          icon: Icon(Icons.add),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => ProductWritePage()),
            );
          },
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(100),
          ),
          backgroundColor: Theme.of(context).highlightColor,
          foregroundColor: Colors.white,
        );
      },
    );
  }
}

✏️ HomeTab · ChatTab · MyTab UI 구현

HomeTab UI 구현

HomeTab 구성 파일

home_tab/
 ├─ home_tab.dart
 └─ widgets/
     ├─ home_tab_app_bar.dart
     ├─ home_tab_list_view.dart
     └─ product_list_item.dart

레이아웃 구조

Column
 ├─ HomeTabAppBar
 └─ HomeTabListView (상품 리스트)

HomeTabAppBar 스낵바

📁 lib/core/snackbar_utils.dart

import 'package:flutter/material.dart';

class SnackbarUtils {
  static void showSnackBr(BuildContext context, String text) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(text),
        behavior: SnackBarBehavior.floating,
        showCloseIcon: true,
      ),
    );
  }
}

스낵바 따로 분리

onTap: () {
            SnackbarUtils.showSnackBr(context, "아직 준비중입니다!");
          },

ProductListItem (상품 리스트 아이템)

레아아웃 구조

상품 이미지
제목 / 시간 / 가격
관심(하트) 아이콘
터치 시 → ProductDetailPage 이동

Row
 ├─ 상품 이미지
 └─ Expanded
     ├─ 제목
     ├─ 시간
     ├─ 가격
     └─ 좋아요 수

HomeTabListView

ListView.separated 사용
Divider로 아이템 간 구분
padding 적용

ListView.separated(
        itemCount: 10,
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
        separatorBuilder: (context, index) => const Divider(height: 20),
        itemBuilder: (context, index) {
          return ProductListItem();
        },
      ),

ChatTab UI 구현

공통 위젯: UserProfileImage

📁 lib/ui/widgets/user_profile_image.dart

ChatTab / MyTab 모두에서 프로필 이미지 필요
null 이미지 대응

class UserProfileImage extends StatelessWidget {
  const UserProfileImage({super.key, this.dimension, this.imgSrc});

  final double? dimension;
  final String? imgSrc;

  
  Widget build(BuildContext context) {
    if (imgSrc == null) {
      return Container(
        width: dimension,
        height: dimension,
        decoration: BoxDecoration(color: Colors.grey, shape: BoxShape.circle),
      );
    }
    return SizedBox.square(
      dimension: dimension,
      child: AspectRatio(
        aspectRatio: 1,
        child: ClipRRect(
          borderRadius: BorderRadius.circular(100),
          child: Image.network(imgSrc!, fit: BoxFit.cover),
        ),
      ),
    );
  }
}

ChatListView

채팅 목록 리스트
터치 시 → ChatDetailPage 이동

Row
 ├─ 프로필 이미지
 └─ Expanded
     ├─ 닉네임 + 시간
     └─ 마지막 메시지

ListView.separated 사용
Divider로 구분

MyTab UI 구현

SafeArea
 └─ ListView
     ├─ MyProfileBox
     ├─ Section Label
     ├─ Menu Item
     ├─ Divider
     └─ ...

MyProfileBox

프로필 이미지
닉네임
프로필 수정 버튼
Row
 ├─ UserProfileImage
 ├─ 닉네임
 └─ 프로필수정 버튼

✏️ ProductDetailPage UI

레이아웃

Scaffold
 ├─ AppBar
 │   └─ ProductDetailActions
 ├─ body (ListView)
 │   ├─ ProductDetailPicture
 │   └─ ProductDetailBody
 └─ bottomSheet
     └─ ProductDetailBottomShee

상단 이미지 영역 (ProductDetailPicture)

PageView.builder

PageView.builder(
  itemCount: 3,
  itemBuilder: ...
)

특징
좌우 스와이프 가능
ListView.builder와 사용법 거의 동일
상품 이미지 여러 장 표현에 적합

BottomSheet (ProductDetailBottomSheet)

bottomSheet 특징

Scaffold.bottomSheet
항상 하단 고정
키보드 올라와도 사라지지 않음

SafeArea 처리

bottomPadding: MediaQuery.of(context).padding.bottom

❗ Scaffold 기준 context에서만 정확
아이폰 홈 인디케이터 영역 대응
height: 50 + bottomPadding

VerticalDivider

VerticalDivider는 Row 안에서 쓰는 세로 구분선

VerticalDivider(
  width: 20,      // 구분선이 차지하는 가로 공간
  indent: 10,     // 위쪽 여백
  endIndent: 10,  // 아래쪽 여백
  color: Colors.grey, // 선 색상
)

버튼이나 텍스트 사이를 깔끔하게 나눌 때 사용

✏️ ProductWritePage UI

레이아웃

Scaffold
 ├─ AppBar (내 물건 팔기)
 └─ Body
    └─ Form
       └─ ListView
          ├─ ProductWritePictureArea
          ├─ ProductCategoryBox
          ├─ 상품명 TextFormField
          ├─ 가격 TextFormField
          ├─ 내용 TextFormField
          └─ 작성 완료 버튼

이미지 등록 영역 (ProductWritePictureArea)

핵심 역할

가로 스크롤 이미지 리스트
이미지 추가 버튼 UI

ListView(
  scrollDirection: Axis.horizontal,
)

주요 위젯

AspectRatio(1) → 정사각형 유지
ClipRRect → 이미지 둥근 모서리
GestureDetector → 이미지 추가 버튼

카테고리 선택 (ProductCategoryBox)

PopupMenuButton란?

클릭 시 팝업 메뉴 표시
선택된 값은 onSelected로 전달

기본 구조

PopupMenuButton<String>(
  onSelected: (value) {}, // 선택되었을 때 일어날 이벤트
  itemBuilder: (context) => [...], // 아이템 각 항목
  child: ... // 팝업 누르기 전 보여질 상태
)

스타일 커스터마이징

shape: RoundedRectangleBorder(
  borderRadius: BorderRadius.circular(16),
),
position: PopupMenuPosition.under, //child 아래로 보여지게
color: Colors.white,

메뉴 아이템 분리

 PopupMenuItem<String> categoryItem(String text, bool isSelected) {
    return PopupMenuItem<String>(
      value: text,
      child: Text(
        text,
        style: TextStyle(
          fontWeight: isSelected ? FontWeight.bold : null,
          color: isSelected ? Colors.black : Colors.grey,
        ),
      ),
    );
  }

categoryItem('디지털 가전', false),

현재 선택된 카테고리 강조 가능
child는 버튼처럼 보이는 UI

입력 필드 설정 포인트

가격 입력 필드

keyboardType: TextInputType.number,
inputFormatters: [
  FilteringTextInputFormatter.digitsOnly
],

숫자 키보드 + 숫자만 입력 허용

✏️ ChatDetailPage UI

레이아웃

Scaffold
 ├ AppBar
 ├ body (Column)
 │   ├ ChatDetailProductArea   // 상단 상품 정보
 │   └ ChatDetailListView      // 채팅 리스트
 └ bottomSheet
     └ ChatDetailBottomSheet   // 메시지 입력 영역

ChatListReceiveItem (받은 메시지)

상대방 메시지 UI

구조 요약

Row
 ├ 프로필 이미지 (또는 빈 공간)
 └ 메시지 + 시간

showProfile이 false면 SizedBox(width: 50)
→ 메시지 정렬 유지

말풍선:
Container
BorderRadius.circular(16)
시간은 작은 폰트 + 흐린 색
📌 연속 메시지에서 프로필 숨기는 패턴 구현

ChatListSendItem (보낸 메시지)

내가 보낸 메시지 UI

차이점

오른쪽 정렬

crossAxisAlignment: CrossAxisAlignment.end

프로필 이미지 없음
구조는 ReceiveItem과 거의 동일
📌 좌/우 정렬만 다르고 재사용 구조는 동일

ChatDetailBottomSheet (입력창)

메시지 입력 + 전송 버튼
키보드 영역 대응

StatefulWidget
→ TextEditingController 관리
MediaQuery.of(context).padding.bottom
→ 아이폰 홈 인디케이터 영역 대응

전송 시:
텍스트 출력
컨트롤러 초기화

DefaultTextStyle

하위에 있는 모든 Text 위젯에 기본 텍스트 스타일을 한 번에 적용하는 위젯

  • Text마다 style: 반복하기 귀찮을 때
  • 같은 영역의 텍스트 스타일을 통일하고 싶을 때

사용 예시

DefaultTextStyle(
  style: TextStyle(
    fontSize: 13,
    fontWeight: FontWeight.bold,
    color: Colors.black,
  ),
  child: Column(
    children: [
      Text('제목'),
      Text('날짜'),
    ],
  ),
);

중요한 포인트

color는 반드시 지정해야 함 (안 하면 에러 날 수 있음)
개별 Text에서 style 주면 그게 우선

🤖 AI 리터러시 특강 (@이주원 튜터님)

서론

AI는 이제 단순한 도구가 아니라 협업의 대상으로 확장됨

  • AI를 어떻게 사용해야 하는가?
  • 어디까지 믿어도 되는가?

강의 목표

AI가 만든 코드를 검증해야 하는 이유를 이해한다
AI 사용 가능 영역과 위험 영역을 구분하는 기준을 갖는다
Flutter 개발에서 AI를 생산성 도구로 활용하는 질문법을 익힌다

⚠️ 기존 방식의 문제점: 왜 AI를 경계해야 했나?

1. 기술적 부채 및 책임 문제

유지보수 불가: 내가 이해하지 못한 코드는 수정이 불가능함
해결 능력 저하: 에러 발생 시 질문만 반복하며 스스로 문제를 정의하는 힘이 약해짐
책임 소재: AI는 코드에 대해 책임을 지지 않음. 책임은 오로지 개발자의 몫

2. 성장 정체

코드 복사-붙여넣기 위주의 개발은 지식 축적을 방해함
AI 의존성: AI 없이는 개발을 시작조차 할 수 없는 '고착 상태' 발생

3. 환각(Hallucination) 현상

존재하지 않는 위젯, 라이브러리, API를 당당하게 제안함
구 버전 API 제안: 이미 사라진(Deprecated) 방식을 최신인 것처럼 설명함
그럴듯하지만 틀린 코드를 구별하지 못함

💡 언제부터 AI가 '무기'가 되는가?

코드를 읽고 흐름을 설명할 수 있을 때
특정 구조를 선택한 이유를 논리적으로 설명할 수 있을 때
AI가 작성한 코드의 잘못된 부분을 지적하고 수정을 요구할 수 있을 때

AI는 정답을 알려주는 선생님이 아니라
지시에 따라 도면을 그리는 숙련된 작업자

🔍 AI 리터러시의 본질

1. 정의

AI의 기능, 한계, 오류 가능성을 이해하고 실무에 비판적으로 활용할 수 있는 능력

2. AI의 작동 원리와 한계

확률 기반: 코드를 이해하는 것이 아니라, 패턴과 확률에 따라 '다음 토큰'을 예측함
맥락 부족: 전체 아키텍처나 복잡한 비즈니스 요구사항을 완전히 파악하지 못하고, 주어진 Context 일부만 보고 판단함

3. 주요 발생 문제

문법 에러는 없지만 로직이 틀린 코드 생성
프로젝트의 일관성을 해치는 부적절한 구조
플러터 특유의 생명 주기(Lifecycle) / BuildContext 관련 오류

AI가 자주 틀리는 Flutter 영역

  • 최신 Flutter / 라이브러리 API
// ❌
Color.withOpacity(0.5)
// ✅
Color.withValues(alpha: 0.5)
  • async / await 이후 BuildContext 사용
  • initState에서 context 접근
  • build() 메소드 내부에 로직 처리
  • 불필요한 setState() 호출

AI를 잘 사용하는 3가지 핵심 전략

Divide & Conquer (모듈 단위 요청)

거대한 기능을 한 번에 요청하지 않기
최소 단위로 쪼개어 요청
설계자가 통제 가능한 코드 양 유지

"로그인 기능을 만들 거야. [1. 유효성 검사], [2. 뷰모델 함수], [3. DB 연결부] 순으로 짤 거야. 우선 1번부터 시작하자."

최적화 & 리팩토링 요청

이미 동작하는 코드를 기반으로 개선 요청
성능 / 가독성 / 확장성 관점에서 비교
AI를 코드 리뷰어로 활용

이 루프문의 시간 복잡도가 너무 높아
공간 복잡도를 희생하더라도 속도를 높이는 방향 (HashMap 활용)으로 개선해줘

논리적 디버깅 프로세스

에러 범위 제한 → 원인 후보 → 대안 비교 → 선택
❌ “안 돼요 고쳐주세요”
❌ 사고 과정을 AI에게 맡기기

  1. 범위 제한: 에러가 발생할 가능성이 있는 파일 나열 및 이유 설명 요청
  2. 정밀 진단: 코드 구조와 데이터 흐름상의 모순점 파악 요청
  3. 대안 평가: 해결책 후보 3가지를 추천받고 장단점 비교
  4. 정밀 타격: 선택한 대안을 바탕으로 특정 부분만 수정 반영

모르는 상태에서도 AI를 활용하는 방법

비유와 시각화를 통한 이해

추상 개념을 일상적인 비유로 요청
구조를 머릿속에 그릴 수 있도록 도움

StatelessWidget과 StatefulWidget의 차이를 일상생활에 비유해서 설명해줘

소크라테스식 질문

이해 안 되는 개념을 단계적으로 파고들기
존재 이유, 대체 가능성 질문

TextField의 controller는 뭐야?
안 쓰면 어떤 문제가 생겨?
다른 해결 방법은 없어?

8.3 역설계 학습법

결과 코드 → 필요한 개념 추적
이론과 구현을 분리하지 않음

구글 로그인 기능 구현에 필요한
기술 스택을 나열하고 표준 코드 작성해줘

AI를 잘못 사용하는 대표 사례

  • “Flutter로 로그인 + 상태관리 + API 연동 전체 코드 작성해줘”
  • 에러 로그 없이 “고쳐줘”
  • “이게 맞아?” “정답이야?”
  • API 호출 후 화면 이동 시 context 생명주기 무시
  • initState에서 context 접근

최악의 상황

  • AI 코드가 공식 문서와 일치하는지 판단 불가
  • 이해하지 못한 상태로 배포 / 머지
  • 기술 부채를 AI가 아닌 개발자가 책임짐

🚨 자가진단 테스트

  • AI가 작성한 코드의 흐름을 설명할 수 없다
  • AI가 왜 이 위젯을 선택했는지 이유를 모른다
  • 에러는 사라졌지만 잘 작성된 코드인지 불안하다
  • 공식 문서를 한 번도 확인하지 않았다
  • AI 없이 이전 상태로 원복해서 다시 짤 자신이 없다

마무리

AI는 학습을 돕는 도구이자 생산성 도구 사고를 대신해주지는 않는다
AI가 만든 코드를 책임지는 사람은 항상 100% 개발자 본인

공부 소감

오늘은 블로그 어플 crud 구현 끝나고 마켓앱 ui 구현만 하루종일 했다.. 내일 완강하고 금요일에 개인 과제 시작하는 게 목표인데 할 수 있으까...??

0개의 댓글