[Flutter] flutter_carrot_market

만들면서 배우는 플러터 앱 프로그래밍
최재원's avatar
Jul 30, 2025
[Flutter] flutter_carrot_market

프로젝트 코드

디펜던시 추가

google_fonts: ^6.1.0 font_awesome_flutter: ^10.5.0 intl: ^0.18.1
  • intl → 숫자 포맷 라이브러리

폴더 구조

notion image
notion image

위젯

BottomNavigationBar

  • 하단에 주요 화면 간 이동을 위한 탭 네비게이션 바를 제공하는 위젯
기본 정렬
  • mainAxisAlignment: 없음 (자동 정렬)
  • crossAxisAlignment: 없음 (항목 간 동일 간격)
레이아웃
  • 가로 → 부모(Scaffold 등)의 전체 너비 사용
  • 세로 → 항목 수 및 스타일에 따라 높이 자동 결정 (보통 약 56~80px)
속성
  • itemsList<BottomNavigationBarItem> | 각 탭 항목 정의 (아이콘과 라벨 포함)
  • currentIndexint | 현재 선택된 항목 인덱스
  • onTapvoid Function(int)? | 항목을 탭했을 때 호출되는 콜백
  • typeBottomNavigationBarType | 탭 스타일(fixed, shifting)
  • selectedItemColorColor? | 선택된 항목의 색상
  • unselectedItemColorColor? | 선택되지 않은 항목의 색상
  • backgroundColorColor? | 바의 배경색
  • iconSizedouble | 아이콘 크기 (기본값: 24.0)
  • selectedLabelStyleTextStyle? | 선택된 항목 라벨의 스타일
  • unselectedLabelStyleTextStyle? | 선택되지 않은 항목 라벨의 스타일
  • showSelectedLabelsbool | 선택된 항목 라벨 표시 여부 (기본값: true)
  • showUnselectedLabelsbool | 선택되지 않은 항목 라벨 표시 여부 (기본값: false)
  • elevationdouble | 바의 그림자 깊이 (기본값: 8.0)
notion image
notion image

BottomNavigationBarItem

  • BottomNavigationBar에서 사용할 개별 탭 항목을 정의하는 위젯
기본 정렬
  • 자체 정렬 개념 없음 (BottomNavigationBar에서 위치 결정)
  • 아이콘과 라벨은 세로로 정렬됨 (Column 구조)
레이아웃
  • 가로 → BottomNavigationBar에서 균등 분배
  • 세로 → 아이콘과 라벨 크기에 따라 자동 결정
속성
  • iconWidget | 기본 상태에서 표시할 아이콘
  • labelString | 아이콘 아래에 표시되는 텍스트 라벨
  • tooltipString? | 아이템을 길게 눌렀을 때 보여줄 설명 텍스트
  • activeIconWidget? | 선택된 상태에서 사용할 아이콘 (기본값: icon)
  • backgroundColorColor? | BottomNavigationBarType.shifting에서 배경색 지정
notion image
notion image

IndexedStack

  • 여러 자식을 겹쳐 놓되, 하나의 자식만 보여주는 스택 레이아웃 위젯
  • 네비게이션 바에서 인덱스를 바꿔도 rebuild 를 하지 않으면 화면이 변하지 않는다 tapbar랑 다르다
  • 한번에 모든 스택이 빌드 된다 → 성능 저하
기본 정렬
  • mainAxisAlignment: 없음
  • crossAxisAlignment: 없음
    • (Stack 기반이므로 자식은 겹쳐지고, 정렬보다 위치 지정이 중요)
레이아웃
  • 가로 → 부모의 가로 제약을 그대로 따름
  • 세로 → 부모의 세로 제약을 그대로 따름
  • 모든 자식이 존재하지만, 인덱스에 해당하는 자식만 보임
  • 보이지 않는 자식도 레이아웃에는 참여함 (메모리에 유지됨)
속성
  • indexint | 표시할 자식 위젯의 인덱스
  • childrenList<Widget> | 겹쳐서 놓을 자식 위젯 리스트
  • alignmentAlignmentGeometry | 자식의 정렬 위치 (기본값: Alignment.topStart)
  • textDirectionTextDirection? | 정렬 시 텍스트 방향 고려
  • clipBehaviorClip | 자식이 영역을 넘었을 때 잘라낼지 여부 (기본값: Clip.hardEdge)
notion image
notion image

Card

  • 모서리가 둥근 그림자 박스로 구성된 머티리얼 디자인 컨테이너 위젯
  • 자식 위젯을 감싸 시각적으로 강조할 때 사용
  • 내용이 많아져도 내부 스크롤은 제공하지 않으며, padding이 없어 직접 감싸야 함
기본 정렬
  • mainAxisAlignment: 없음
  • crossAxisAlignment: 없음
    • (단일 child를 받기 때문에 정렬 속성 없음, 내부에 Column 등 써야 정렬 가능)
레이아웃
  • 가로 → 부모의 가로 제약을 그대로 따름
  • 세로 → 자식 높이에 맞게 늘어남
  • 내부 padding 없음 → 직접 Padding 위젯으로 감싸야 적절한 여백 생김
속성
  • childWidget? | 카드 안에 표시할 위젯
  • colorColor? | 배경색 (기본값: 테마의 cardColor)
  • elevationdouble | 그림자 깊이 (기본값: 1.0)
  • marginEdgeInsetsGeometry? | 카드 외부 여백 (기본값: EdgeInsets.all(4.0))
  • shapeShapeBorder? | 외곽 모양 (기본값: RoundedRectangleBorder)
  • clipBehaviorClip | 넘치는 영역을 자를지 여부 (기본값: Clip.none)
notion image
notion image

Visibility

  • 위젯을 조건에 따라 보이거나 숨기는 조건부 렌더링 위젯
  • visible이 false이면 위젯이 사라짐 (기본적으로 공간도 사라짐)
  • 옵션을 조합하면 상태, 크기, 애니메이션, 인터랙션 등을 유지한 채 숨길 수 있음
기본 정렬
  • mainAxisAlignment: 없음
  • crossAxisAlignment: 없음
    • (단일 child 위젯을 감싸므로 정렬은 내부 위젯에 따라 결정됨)
레이아웃
  • 가로 → 자식 위젯의 너비에 따름
  • 세로 → 자식 위젯의 높이에 따름
  • visible: false이면 기본적으로 완전히 레이아웃에서 제거됨
  • 단, maintainSize 등 옵션을 켜면 공간만 유지하거나 상태도 유지 가능
속성
  • visiblebool | 위젯 표시 여부 (true 시 보임, 기본값: true)
  • childWidget | 표시 대상 위젯
  • replacementWidget | visible이 false일 때 대신 보여줄 위젯 (기본: SizedBox.shrink())
  • maintainStatebool | 숨겨져도 State를 유지할지 (기본값: false)
  • maintainAnimationbool | 숨겨져도 애니메이션 상태 유지 (기본값: false)
  • maintainSizebool | 숨겨져도 공간을 차지할지 여부 (기본값: false)
  • maintainSemanticsbool | 숨겨져도 접근성 정보 유지 (기본값: false)
  • maintainInteractivitybool | 숨겨져도 클릭 등 상호작용 가능 여부 (기본값: false)

TextField

진행

1. 틀 잡기

MainScreens → const 사용법

notion image
notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/chatting/chatting_screen.dart'; import 'package:flutter_carrot_market/screens/home/home_screen.dart'; import 'package:flutter_carrot_market/screens/my_carrot/my_carrot_screen.dart'; import 'package:flutter_carrot_market/screens/near_me/near_me_screen.dart'; import 'package:flutter_carrot_market/screens/neighborhood_life/neighborhood_life_screen.dart'; class MainScreens extends StatefulWidget { @override State<MainScreens> createState() => _MainScreensState(); } class _MainScreensState extends State<MainScreens> { int selectedIndex = 0; List<int> loadPages = [0]; // 배열의 크기 1 void selectedBottomMenu(int index) { if (!loadPages.contains(index)) { loadPages.add(index); // [0, 1] } selectedIndex = index; setState(() {}); } @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: selectedIndex, children: [ loadPages.contains(0) ? const HomeScreen() : Container(), loadPages.contains(1) ? const NeighborhoodLifeScreen() : Container(), loadPages.contains(2) ? const NearMeScreen() : Container(), loadPages.contains(3) ? const ChattingScreen() : Container(), loadPages.contains(4) ? const MyCarrotScreen() : Container(), ], ), bottomNavigationBar: _bottomNavigationBar(), ); } BottomNavigationBar _bottomNavigationBar() { return BottomNavigationBar( type: BottomNavigationBarType.fixed, // showUnselectedLabels: false, -> 레이블 글자 가리기 // showSelectedLabels: false, selectedItemColor: Colors.orange, unselectedItemColor: Colors.black54, selectedFontSize: 12, unselectedFontSize: 12, currentIndex: selectedIndex, onTap: selectedBottomMenu, items: [ BottomNavigationBarItem(label: "홈", icon: Icon(CupertinoIcons.home)), BottomNavigationBarItem(label: "동네생활", icon: Icon(CupertinoIcons.square_on_square)), BottomNavigationBarItem(label: "내 근처", icon: Icon(CupertinoIcons.placemark)), BottomNavigationBarItem(label: "채팅", icon: Icon(CupertinoIcons.chat_bubble_2)), BottomNavigationBarItem(label: "나의 당근", icon: Icon(CupertinoIcons.person)), ], ); } }
  • IndexedStack → 기본적으로 이 위젯은 children에 있는 모든 위젯을 한번에 build 한다
    • 따라서 성능 저하가 발생할 수 있다
    • 일단, 위와 같은 방법으로 (const) 위젯을 재사용하면 성능 저하를 막을 수 있다
      • const → 메모리에 있는 데이터를 재사용 하는 방법
 

MainScreens → 배열 사용에서 사용하는 방법

import 'package:carrot_market/screens/chatting/chatting_screen.dart'; import 'package:carrot_market/screens/home/home_screen.dart'; import 'package:carrot_market/screens/my_carrot/my_carrot_screen.dart'; import 'package:carrot_market/screens/near_me/near_me_screen.dart'; import 'package:carrot_market/screens/neighborhood_life/neighborhood_life_screen.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class MainScreens extends StatefulWidget { @override State<MainScreens> createState() => _MainScreensState(); } class _MainScreensState extends State<MainScreens> { int selectedIndex = 0; final List<Widget?> _screens = List.filled(5, null); @override void initState() { super.initState(); _screens[0] = HomeScreen(); // 처음은 미리 생성 } void selectBottomMenu(int index) { if (_screens[index] == null) { _screens[index] = _buildScreen(index); // 처음 클릭 시 생성 } setState(() { selectedIndex = index; }); } Widget _buildScreen(int index) { switch (index) { case 0: return HomeScreen(); case 1: return NeighborhoodLifeScreen(); case 2: return NearMeScreen(); case 3: return ChattingScreen(); case 4: return MyCarrotScreen(); default: return Container(); } } @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: selectedIndex, children: _screens.map((screen) => screen ?? Container()).toList(), ), bottomNavigationBar: _bottomNavigationBar(), ); } BottomNavigationBar _bottomNavigationBar() { return BottomNavigationBar( type: BottomNavigationBarType.fixed, selectedFontSize: 12.0, unselectedFontSize: 12.0, selectedItemColor: Colors.orange, unselectedItemColor: Colors.black54, currentIndex: selectedIndex, onTap: selectBottomMenu, items: const [ BottomNavigationBarItem(label: "홈", icon: Icon(CupertinoIcons.home)), BottomNavigationBarItem(label: "동네생활", icon: Icon(CupertinoIcons.square_on_square)), BottomNavigationBarItem(label: "내근처", icon: Icon(CupertinoIcons.placemark)), BottomNavigationBarItem(label: "채팅", icon: Icon(CupertinoIcons.chat_bubble_2)), BottomNavigationBarItem(label: "나의당근", icon: Icon(CupertinoIcons.person)), ], ); } }

2. 홈 화면

HomeScreen

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/product.dart'; import 'package:flutter_carrot_market/screens/home/components/list_item.dart'; import 'package:flutter_carrot_market/screens/home/detail/home_detail_screen.dart'; class HomeScreen extends StatelessWidget { const HomeScreen(); @override Widget build(BuildContext context) { print("HomeScreen build!"); return Scaffold( appBar: _appBar(), body: ListView.separated( itemBuilder: (context, index) { Product p = productList[index]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: InkWell( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => HomeDetailScreen())); }, child: ListItem(p: p), ), ); }, separatorBuilder: (context, index) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Divider(thickness: .5, color: Colors.grey), ), itemCount: productList.length, ), ); } AppBar _appBar() { return AppBar( title: Row( children: [ Text("좌동"), SizedBox(width: 4), Icon(CupertinoIcons.chevron_down), ], ), actions: [ IconButton(icon: const Icon(CupertinoIcons.search), onPressed: () {}), IconButton(icon: const Icon(CupertinoIcons.list_dash), onPressed: () {}), IconButton(icon: const Icon(CupertinoIcons.bell), onPressed: () {}), ], bottom: PreferredSize( preferredSize: Size(double.infinity, .5), child: Divider(thickness: .5, color: Colors.grey), ), ); } }

ListItem

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/_core/my_util.dart'; import 'package:flutter_carrot_market/models/product.dart'; class ListItem extends StatelessWidget { const ListItem({required this.p}); final Product p; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Container( height: 115, child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.network( "https://picsum.photos/id/237/200/300", width: 115, fit: BoxFit.cover, ), ), SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ Text("${p.title}", style: TextStyle(fontWeight: FontWeight.bold)), Text("${p.address}${p.publishedAt}", style: TextStyle(color: Colors.grey, fontSize: 12)), Text("${p.price.toMoney()}원", style: TextStyle(fontWeight: FontWeight.bold)), Spacer(), Row( mainAxisAlignment: MainAxisAlignment.end, spacing: 3, children: [ Icon(CupertinoIcons.chat_bubble_2, color: Colors.grey, size: 16), Text("${p.commentsCount}", style: TextStyle(color: Colors.grey)), SizedBox(width: 3), InkWell( onTap: () {}, child: Icon(CupertinoIcons.heart, color: Colors.grey, size: 16), ), Text("${p.heartCount}", style: TextStyle(color: Colors.grey)), ], ), ], ), ), ], ), ), ); } }

숫자 포매터

import 'package:intl/intl.dart'; // 그냥 함수로 빼서 사용하는 방법 String formatToMoney(String price) { final formatter = NumberFormat('#,###'); return formatter.format(int.parse(price)); } // String 클래스의 내장 함수처럼 사용하는 방 extension MoneyFormatter on String { /// Created By JAEWON, 2025.06.02 /// /// email : won8070@naver.com /// /// tip : 콤마(,) 붙여줌 String toMoney() { final formatter = NumberFormat('#,###'); return formatter.format(int.parse(this)); } }

3. 나의 당근

MyCarrotScreen

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/icon_menu.dart'; import 'package:flutter_carrot_market/screens/my_carrot/components/card_icon_menu.dart'; import 'package:flutter_carrot_market/screens/my_carrot/components/my_carrot_header.dart'; class MyCarrotScreen extends StatelessWidget { const MyCarrotScreen(); @override Widget build(BuildContext context) { print("MyCarrotScreen build!"); return Scaffold( appBar: _appBar(), body: ListView( children: [ MyCarrotHeader(), SizedBox(height: 8), CardIconMenu(iconMenuList: iconMenu1), SizedBox(height: 8), CardIconMenu(iconMenuList: iconMenu2), SizedBox(height: 8), CardIconMenu(iconMenuList: iconMenu3), ], ), ); } AppBar _appBar() { return AppBar( title: Text("나의 당근"), actions: [IconButton(onPressed: () {}, icon: Icon(Icons.settings))], bottom: PreferredSize( preferredSize: Size(double.infinity, .5), child: Divider(thickness: .5, color: Colors.grey), ), ); } }

MyCarrotHeader

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class MyCarrotHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Card( elevation: .5, margin: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), child: Padding( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), child: Column( spacing: 30, children: [ _buildProfileRow(), _buildProfileButton(), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildRoundTextButton(title: "판매내역", iconData: CupertinoIcons.square_list_fill), _buildRoundTextButton(title: "구매내역", iconData: CupertinoIcons.bag_fill), _buildRoundTextButton(title: "관심목록", iconData: CupertinoIcons.heart), ], ), ], ), ), ); } Container _buildRowIconItem() { return Container( height: 50, child: Row( spacing: 20, children: [ Icon(Icons.place, size: 17), Text("내 동네 설정"), ], ), ); } Container _buildProfileButton() { return Container( width: double.infinity, child: OutlinedButton( onPressed: () {}, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text("프로필 보기"), ), style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ), ); } Row _buildProfileRow() { return Row( children: [ Stack( children: [ Container( width: 65, height: 65, child: CircleAvatar( backgroundImage: NetworkImage("https://picsum.photos/id/237/200/300"), ), ), Positioned( bottom: 0, right: 0, child: Container( width: 20, height: 20, decoration: BoxDecoration(borderRadius: BorderRadius.circular(15), color: Colors.white), child: Icon(Icons.camera_enhance, size: 15), ), ), ], ), SizedBox(width: 16), Container( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("developer", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), SizedBox(height: 10), Text("좌동 #00912", style: TextStyle(color: Colors.grey, fontSize: 12)), ], ), ), ], ); } InkWell _buildRoundTextButton({required String title, required IconData iconData}) { return InkWell( onTap: () {}, child: Column( children: [ Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.amber, borderRadius: BorderRadius.circular(999), border: Border.all(color: Colors.grey, width: .5), ), child: Icon(iconData, color: Colors.orange, size: 20), ), SizedBox(height: 10), Text(title), ], ), ); } }

CardIconMenu

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/icon_menu.dart'; class CardIconMenu extends StatelessWidget { final List<IconMenu> iconMenuList; CardIconMenu({required this.iconMenuList}); @override Widget build(BuildContext context) { return Card( elevation: .5, margin: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), child: Padding( padding: const EdgeInsets.all(16), child: Column( children: List.generate( iconMenuList.length, (index) => _buildRowIconItem(title: iconMenuList[index].title, iconData: iconMenuList[index].iconData), ), ), ), ); } Container _buildRowIconItem({required String title, required IconData iconData}) { return Container( height: 50, child: Row( spacing: 20, children: [ Icon(iconData, size: 17), Text(title), ], ), ); } }

4. 채팅

appBar

notion image
import 'package:flutter/material.dart'; class ChattingScreen extends StatelessWidget { const ChattingScreen(); @override Widget build(BuildContext context) { print("ChattingScreen build!"); return Scaffold(appBar: _appBar(), body: ListView()); } AppBar _appBar() => AppBar( title: Text("채팅"), bottom: PreferredSize( preferredSize: Size(double.infinity, .5), child: Divider( thickness: .5, color: Colors.grey, ), ), ); }

ImageContainer

notion image
import 'package:flutter/material.dart'; class ImageContainer extends StatelessWidget { final double borderRadius; final String imageUrl; final double width; final double height; ImageContainer({ required this.borderRadius, required this.imageUrl, required this.width, required this.height, }); @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: Image.network( imageUrl, width: width, height: height, fit: BoxFit.cover, ), ); } }

ChatContainer

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/chat_message.dart'; import 'package:flutter_carrot_market/screens/_components/image_container.dart'; class ChatContainer extends StatelessWidget { final ChatMessage chatMessage; ChatContainer({required this.chatMessage}); @override Widget build(BuildContext context) { return Container( height: 100, decoration: BoxDecoration( border: Border(bottom: BorderSide(color: Colors.grey, width: .5)), ), child: Padding( padding: const EdgeInsets.all(20), child: Row( spacing: 16, children: [ ImageContainer( borderRadius: 999, height: 50, width: 50, imageUrl: chatMessage.profileImage, ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ RichText( text: TextSpan( children: [ TextSpan(text: "${chatMessage.sender} "), TextSpan( text: "${chatMessage.location} ", style: TextStyle(color: Colors.grey, fontSize: 12), ), TextSpan( text: "• ", style: TextStyle(color: Colors.grey, fontSize: 12), ), TextSpan( text: "${chatMessage.sendDate}", style: TextStyle(color: Colors.grey, fontSize: 12), ), ], ), ), Text( "${chatMessage.message}", overflow: TextOverflow.ellipsis, ), ], ), ), Visibility( visible: chatMessage.imageUri != null, child: ImageContainer( borderRadius: 8, height: 50, width: 50, imageUrl: chatMessage.imageUri ?? "", ), ), ], ), ), ); } }

ChattingScreen

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/chat_message.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; import 'package:flutter_carrot_market/screens/chatting/components/chat_container.dart'; class ChattingScreen extends StatelessWidget { const ChattingScreen(); @override Widget build(BuildContext context) { print("ChattingScreen build!"); return Scaffold( appBar: _appBar(), body: ListView( children: List.generate( chatMessageList.length, (index) => ChatContainer(chatMessage: chatMessageList[index]), ), ), ); } AppBar _appBar() => AppBar( title: Text("채팅"), bottom: appBarBottomLine(), ); }

5. 동네생활

appBar

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; class NeighborhoodLifeScreen extends StatelessWidget { const NeighborhoodLifeScreen(); @override Widget build(BuildContext context) { print("NeighborhoodLifeScreen build!"); return Scaffold( appBar: _appBar(), body: Center(child: Text("neighborhood life")), ); } AppBar _appBar() { return AppBar( title: Text("동네생활"), actions: [ IconButton(onPressed: () {}, icon: Icon(CupertinoIcons.search)), IconButton(onPressed: () {}, icon: Icon(CupertinoIcons.plus_rectangle_on_rectangle)), IconButton(onPressed: () {}, icon: Icon(CupertinoIcons.bell)), ], bottom: appBarBottomLine(), ); } }

LifeHeader

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/image_container.dart'; class LifeHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Card( elevation: .5, margin: EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), child: Padding( padding: const EdgeInsets.all(16), child: Row( spacing: 16, children: [ ImageContainer( borderRadius: 6, imageUrl: "https://picsum.photos/id/780/200/100", width: 45, height: 45, ), Expanded( child: Text( "이웃과 함께 만드는 봄 간식 지도 마음까지 따뜻해지는 봄 간식을 만나보아요", maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } }

LifeBody

class LifeBody extends StatelessWidget { const LifeBody({ super.key, }); @override Widget build(BuildContext context) { return Column( children: [ _buildTop(), _buildWriter(), _buildWriting(), _buildImage(), Divider(), _buildTail(), ], ); } ... }
_buildTop()
notion image
Widget _buildTop() { return Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.black12, borderRadius: BorderRadius.circular(4), ), child: Text("우리동네질문", style: textTheme().bodyMedium), ), Text("3시간전", style: textTheme().bodyMedium), ], ), ); }
_buildWriter()
notion image
Widget _buildWriter() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ ImageContainer( width: 30, height: 30, borderRadius: 999, imageUrl: 'https://picsum.photos/id/780/200/100', ), Text.rich( TextSpan( children: [ TextSpan(text: ' 헬로비비'), TextSpan(text: ' 좌동'), TextSpan(text: ' 인증 3회'), ], ), ), ], ), ); }
_buildWriting()
notion image
Widget _buildWriting() { return Padding( padding: const EdgeInsets.all(16), child: Align( alignment: Alignment.centerLeft, child: Text( '예민한 개도 미용할 수 있는 곳이나 동물 병원 어디 있을까요? 내일 유기견을 데려오기로 했는데 아직 성향을 잘 몰라서 걱정이 돼요ㅜㅜ', maxLines: 3, overflow: TextOverflow.ellipsis, ), ), ); }
_buildImage()
notion image
Widget _buildImage() { return Visibility( visible: true, child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), child: Image.network( 'https://picsum.photos/id/780/200/100', height: 200, width: double.infinity, fit: BoxFit.cover, ), ), ); }
_buildTail()
notion image
Widget _buildTail() { return Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Icon(FontAwesomeIcons.smile), SizedBox(width: 8), Text('공감하기'), SizedBox(width: 22), Icon(FontAwesomeIcons.commentAlt), SizedBox(width: 8), Text('댓글쓰기 11'), ], ), ); }

NeighborhoodLifeScreen

class NeighborhoodLifeScreen extends StatelessWidget { const NeighborhoodLifeScreen(); @override Widget build(BuildContext context) { print("NeighborhoodLifeScreen build!"); return Scaffold( appBar: _appBar(), body: ListView( children: [ LifeHeader(), ...List.generate( 10, (index) => Padding( padding: const EdgeInsets.only(bottom: 8), child: LifeBody(), ), ), ], ), ); } ... }
  • List.generate 생성자는 length 만큼 반복문을 돌면서 List 형의 자료구조를 생성함
  • (…) 스프레드 연산자를 사용해서 하나씩 꺼내서 출력함

6. 내 근처 화면 만들기

appBar

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; class NearMeScreen extends StatelessWidget { const NearMeScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: _appBar(), body: Center(child: Text("near me")), ); } AppBar _appBar() { return AppBar( title: Text('내 근처'), actions: [ IconButton(onPressed: () {}, icon: const Icon(CupertinoIcons.pencil)), IconButton(onPressed: () {}, icon: const Icon(CupertinoIcons.bell)), ], bottom: appBarBottomLine(), ); } }

SearchTextField

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class SearchTextField extends StatelessWidget { const SearchTextField({ super.key, }); @override Widget build(BuildContext context) { return Container( child: TextField( cursorColor: Colors.grey, decoration: InputDecoration( disabledBorder: _buildOutLineInputBorder(), enabledBorder: _buildOutLineInputBorder(), focusedBorder: _buildOutLineInputBorder(), fillColor: Colors.black12, filled: true, prefixIcon: const Icon(CupertinoIcons.search, color: Colors.grey), contentPadding: const EdgeInsets.symmetric(vertical: 15), hintText: '좌동 주변 가게를 찾아 보세요', hintStyle: TextStyle(fontSize: 18), ), ), ); } OutlineInputBorder _buildOutLineInputBorder() { return OutlineInputBorder( borderSide: BorderSide(width: .5, color: Colors.black26), borderRadius: BorderRadius.circular(8), ); } }

NearMeScreen

import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; import 'package:flutter_carrot_market/screens/near_me/components/search_text_field.dart'; class NearMeScreen extends StatelessWidget { const NearMeScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: _appBar(), body: ListView( children: [ const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SearchTextField(), ), ], ), ); } ... }
  • 부모가 패딩을 준다

RoundBorderText

notion image
import 'package:flutter/material.dart'; class RoundBorderText extends StatelessWidget { final String title; final int position; const RoundBorderText({ required this.title, required this.position, }); @override Widget build(BuildContext context) { var paddingValue = position == 0 ? 16.0 : 8.0; return Padding( padding: EdgeInsets.only(left: paddingValue), child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all(width: .5, color: Colors.grey), ), child: Text( '학원', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), ), ); } }

NearMeScreen

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; import 'package:flutter_carrot_market/screens/near_me/components/round_border_text.dart'; import 'package:flutter_carrot_market/screens/near_me/components/search_text_field.dart'; class NearMeScreen extends StatelessWidget { const NearMeScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: _appBar(), body: ListView( children: [ const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SearchTextField(), ), _buildHorizonScroll(), Divider(thickness: 10, color: Colors.grey), ], ), ); } SizedBox _buildHorizonScroll() { return SizedBox( height: 66, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 8, itemBuilder: (context, index) { return Center( child: RoundBorderText(title: 'title', position: index), ); }, ), ); } ... }
  • center 를 사용하면 예쁘게 모양이 잡힌다

bottomTitleIcon

notion image
import 'package:flutter/cupertino.dart'; class BottomTitleIcon extends StatelessWidget { const BottomTitleIcon({ super.key, }); @override Widget build(BuildContext context) { return Container( width: 80, child: Column( children: [ Icon(CupertinoIcons.person, size: 30), Padding( padding: const EdgeInsets.only(top: 8), child: Text( '구인구직', style: TextStyle(fontSize: 14), ), ), ], ), ); } }

NearMeScreen

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; import 'package:flutter_carrot_market/screens/near_me/components/bottom_title_icon.dart'; import 'package:flutter_carrot_market/screens/near_me/components/round_border_text.dart'; import 'package:flutter_carrot_market/screens/near_me/components/search_text_field.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class NearMeScreen extends StatelessWidget { const NearMeScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: _appBar(), body: ListView( children: [ const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SearchTextField(), ), _buildHorizonScroll(), Divider(thickness: 10, color: Colors.grey), Padding( padding: const EdgeInsets.only(left: 16, top: 30), child: Wrap( alignment: WrapAlignment.start, spacing: 22.0, runSpacing: 30, children: [ const BottomTitleIcon(title: '구인구직', iconData: FontAwesomeIcons.user), const BottomTitleIcon(title: '과외/클래스', iconData: FontAwesomeIcons.edit), const BottomTitleIcon(title: '농수산물', iconData: FontAwesomeIcons.appleAlt), const BottomTitleIcon(title: '부동산', iconData: FontAwesomeIcons.hotel), const BottomTitleIcon(title: '중고차', iconData: FontAwesomeIcons.car), const BottomTitleIcon(title: '전시/행사', iconData: FontAwesomeIcons.chessBishop), ], ), ), SizedBox(height: 50), ], ), ); } ... }

StoreItem

notion image
import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/recommend_store.dart'; import 'package:flutter_carrot_market/theme.dart'; class StoreItem extends StatelessWidget { final RecommendStore recommendStore; const StoreItem({ super.key, required this.recommendStore, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(width: 0.3, color: Colors.grey), ), width: 289, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _buildClipRRect(topLeft: 10), const SizedBox(width: 2), _buildClipRRect(topRight: 10), ], ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text.rich( TextSpan( children: [ TextSpan(text: '${recommendStore.storeName} ', style: textTheme().displayLarge), TextSpan(text: '${recommendStore.location}'), ], ), ), const SizedBox(height: 8), Text( '${recommendStore.description}', maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme().titleMedium, ), const SizedBox(height: 8), Text.rich( TextSpan( children: [ TextSpan( text: '후기 ${recommendStore.commentCount}', style: TextStyle(fontSize: 15, color: Colors.blue), ), TextSpan( text: ' • 관심 ${recommendStore.likeCount}', style: textTheme().titleMedium, ), ], ), ), ], ), ), Expanded( child: Container( margin: const EdgeInsets.only(left: 16, right: 16, bottom: 16), padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(10)), child: Text.rich( TextSpan( children: [ TextSpan( text: '${recommendStore.commentUser},', style: TextStyle(fontSize: 13, color: Colors.black, fontWeight: FontWeight.bold), ), TextSpan( text: '${recommendStore.comment}', style: TextStyle(fontSize: 12, color: Colors.black), ), ], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ), ], ), ); } ClipRRect _buildClipRRect({double topLeft = 0, double topRight = 0}) { return ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(topLeft), topRight: Radius.circular(topRight), ), child: Image.network( 'https://picsum.photos/id/780/200/100', width: 143, height: 100, fit: BoxFit.cover, ), ); } }

NearMeScreen

notion image
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_carrot_market/models/recommend_store.dart'; import 'package:flutter_carrot_market/screens/_components/appbar_preferred_size.dart'; import 'package:flutter_carrot_market/screens/near_me/components/bottom_title_icon.dart'; import 'package:flutter_carrot_market/screens/near_me/components/round_border_text.dart'; import 'package:flutter_carrot_market/screens/near_me/components/search_text_field.dart'; import 'package:flutter_carrot_market/screens/near_me/components/store_item.dart'; import 'package:flutter_carrot_market/theme.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class NearMeScreen extends StatelessWidget { const NearMeScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: _appBar(), body: ListView( children: [ ... SizedBox(height: 50), Padding( padding: const EdgeInsets.only(left: 16), child: Text('이웃들의 추천 가게', style: textTheme().displayMedium), ), SizedBox(height: 20), Container( height: 300, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 2, itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.only(left: 16), child: StoreItem( recommendStore: recommendStoreList[index], ), ); }, ), ), SizedBox(height: 40), ], ), ); } SizedBox _buildHorizonScroll() { return SizedBox( height: 66, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 8, itemBuilder: (context, index) { return Center( child: RoundBorderText(title: 'title', position: index), ); }, ), ); } AppBar _appBar() { return AppBar( title: Text('내 근처'), actions: [ IconButton(onPressed: () {}, icon: const Icon(CupertinoIcons.pencil)), IconButton(onPressed: () {}, icon: const Icon(CupertinoIcons.bell)), ], bottom: appBarBottomLine(), ); } }
 
Share article

jjack1