[25.06.13 TIL] 5주차 강의(메모 앱)

김영민·2025년 6월 13일

[Flutter 7기] 사전캠프

목록 보기
13/13

사전캠프 마지막 날
오늘은 5주차 강의 실전 메모 앱에 기능을 더했고, 애드몹 연결, 배포까지 진행해보았다.
마지막 날이었는데 5주차 강의까지 모두 완료했다.


5주차 강의(메모 앱)

4. 메모 기능 개발

4-1 메모 등록 기능

📌 MemoWriteContoller 초기화

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

memo_write_controller.dart 파일을 만든다.


📌 MemoWriteController 의존성 주입

floatingActionButton: FloatingActionButton(
  onPressed: () {
    Get.to(MemoWritePage(), binding: BindingsBuilder(() {
      Get.put(MemoWriteController());
    }));
  },
  backgroundColor: Color(0xffF7C354),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(50),
  ),
  child: Image.asset('assets/images/plus.png'),
),

MemoWriteController를 메모 등록 페이지 진입 시에 사용할 수 있도록 Get.to 라우팅할 때 의존성을 주입한다.


📌 MemoWriteContoller 파이어베이스 데이터베이스 초기화

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:get/get.dart';
//
class MemoWriteController extends GetxController {
  late CollectionReference memoCollectionRef;
//
  
  void onInit() {
    super.onInit();
    memoCollectionRef = FirebaseFirestore.instance.collection('memo');
  }
}

✔ 데이터베이스를 초기화한다.


📌 MemoWriteContoller 상태관리 구현

class MemoWriteController extends GetxController {
  late CollectionReference memoCollectionRef;
//
  String title = '';
  String memo = '';
  DateTime? memoDate;
//
  
  void onInit() {
    super.onInit();
    memoCollectionRef = FirebaseFirestore.instance.collection('memo');
    memoDate = DateTime.now();
  }
//
  void setTitle(String title) {
    this.title = title;
    update();
  }
//
  void setMemo(String memo) {
    this.memo = memo;
    update();
  }
}

📌 MemoWritePage 상태관리 연결 및 이벤트 등록

body: Padding(
  padding: const EdgeInsets.symmetric(horizontal: 25),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      TextField(
        decoration: InputDecoration(
          border: InputBorder.none,
          hintText: '제목을 입력해주세요.',
        ),
        style: TextStyle(
          fontSize: 27,
          height: 1.5,
          fontWeight: FontWeight.bold,
          letterSpacing: -1,
        ),
        maxLines: null,
        onChanged: (value) {
          Get.find<MemoWriteController>().setTitle(value);
        }, // 여러 줄 입력 가능
      ),
      Text(
        Get.find<MemoWriteController>().memoDate!.toString(),
        style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)),
      ),
      Expanded(
        child: TextField(
          decoration: InputDecoration(
            border: InputBorder.none,
            hintText: '내용을 입력해주세요.',
          ),
          style: TextStyle(
            fontSize: 15,
            height: 1.5,
            color: Color(0xff848484),
            letterSpacing: -1,
          ),
          maxLines: null, // 여러 줄 입력 가능
          onChanged: (value) {
            Get.find<MemoWriteController>().setMemo(value);
          },
        ),
      ),
    ],
  ),
),

✔ 화면에서 제목과 메모가 수정될 때, update 함수를 연결한다.


📌 날짜 포멧을 도와주는 라이브러리 설치 (intl)

flutter pub add intl

✔ 포멧팅을 도와주는 라이브러리를 설치한다.


📌 MemoDataUtils 클래스 날짜 포멧 함수 구현

class MemoDataUtils {
  static String formatDate(String format, DateTime date) {
    return DateFormat(format).format(date);
  }
}

data_utils.dart 파일을 만든다.


📌 MemoWritePage날짜 포멧팅

Text(
  MemoDataUtils.formatDate(
      'yyyy-MM-dd', Get.find<MemoWriteController>().memoDate!),
  style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)),
),

✔ 날짜를 원하는 포맷으로 변경하게 도와주는 함수를 유틸 클래스에 정의한다.


📌 MemoWritePage 완료 버튼 이벤트 연결

GestureDetector(
  onTap: () {
    Get.find<MemoWriteController>().save();
  },
  child: Padding(
    padding: const EdgeInsets.symmetric(horizontal: 25.0),
    child: Text(
      '완료',
      style: TextStyle(fontSize: 17, color: Color(0xffE3AC34)),
    ),
  ),
),

✔ 완료 버튼을 통해 메모 데이터를 서버인 파이어 베이스 데이터베이스로 저장한다.


📌 MemoWriteController 완료 이벤트 만들기

void save() {}

📌 Memo모델 설계

import 'package:uuid/uuid.dart';
//
class MemoModel {
  final String id;
  final String title;
  final String memo;
  final DateTime createdAt;
//
  MemoModel({
    String? id,
    required this.title,
    required this.memo,
    DateTime? createdAt,
  })  : id = Uuid().v4(),
        createdAt = DateTime.now();
//
  factory MemoModel.fromJson(Map<String, dynamic> json) {
    return MemoModel(
      id: json['id'],
      title: json['title'],
      memo: json['memo'],
      createdAt: DateTime.parse(json['createdAt']),
    );
  }
//
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'title': title,
      'memo': memo,
      'createdAt': createdAt.toIso8601String(),
    };
  }
}

📌 MemoWriteController 완료 이벤트 구현

void save() {
  var memoModel = MemoModel(title: title, memo: memo);
  memoCollectionRef.add(memoModel.toMap());
  Get.back(result: memoModel);
}


4-2 메모 리스트

📌 MemoListController 생성

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

memo_list_controller.dart 파일을 만든다.


📌 MemoListController 의존성 주입


Widget build(BuildContext context) {
  return GetMaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      useMaterial3: true,
    ),
    initialBinding: BindingsBuilder(() {
      Get.put(MemoListController());
    }),
    home: Home(),
  );
}

✔ 앱 실행 시 MemoListController 의존성 주입을 통해 홈 화면에서 바로 사용할 수 있도록 처리한다.

💡 이전까지는 main.dart 파일의 build 함수 시작 부분에 의존성을 주입했다.
이번에는 GetMaterialApp의 옵션 중 initBinding이라는 옵션에 BindingBuilder를 사용하여, 의존성을 주입하도록 처리했다.
원래가 이렇게 주입을 해줘야한다. 다양한 방식으로 의존성을 맺을 수 있음을 보여준 것이며, 앞으로 프로젝트 진행 시 initalBinding에 의존성을 주입하면 된다.


📌 MemoListController 데이터 베이스 연동하여 데이터 로드 하기

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_memo_app/memo_model.dart';
import 'package:get/get.dart';
//
class MemoListController extends GetxController {
  late CollectionReference memoCollectionRef;
  List<MemoModel> memoList = [];
//
  
  void onInit() {
    super.onInit();
    memoCollectionRef = FirebaseFirestore.instance.collection('memo');
    loadAllMemos();
  }
//
  void loadAllMemos() async {
    var memoData = await memoCollectionRef.get();
    memoList = memoData.docs
        .map<MemoModel>(
            (data) => MemoModel.fromJson(data.data() as Map<String, dynamic>))
        .toList();
    update();
  }
}

✔ 보여줄 데이터를 연동한다.


📌 홈화면 MemoListController를 통한 메모 리스트 로드 body

body: SafeArea(
  child: SingleChildScrollView(
    padding: const EdgeInsets.symmetric(horizontal: 25),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text(
          '메모',
          style: TextStyle(
            fontSize: 35,
            fontWeight: FontWeight.bold,
          ),
        ),
        _searchBar(),
        GetBuilder<MemoListController>(builder: (controller) {
          return _monthlyMemoGroup(controller.memoList);
        }),
      ],
    ),
  ),
),

 
📌 홈화면 MemoListController를 통한 메모 리스트 로드 _monthlyMemoGroup

Widget _monthlyMemoGroup(List<MemoModel> memoList) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      SizedBox(height: 30),
      Text(
        '8월',
        style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
      ),
      SizedBox(height: 10),
      Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(8),
          color: Colors.white,
        ),
        padding: const EdgeInsets.only(left: 25),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: List.generate(
            memoList.length,
            (i) {
              return Container(
                padding: const EdgeInsets.symmetric(vertical: 10),
                decoration: BoxDecoration(
                  border: Border(
                    bottom: BorderSide(
                      color: Color(0xffECECEC),
                    ),
                  ),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Text(
                      memoList[i].title,
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 17,
                      ),
                    ),
                    Text(
                      memoList[i].memo,
                      style:
                          TextStyle(fontSize: 14, color: Color(0xff848484)),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      )
    ],
  );
}

✔ 우선 월별 그룹 없이 나열하는 것으로 개발한 뒤에 그룹화한다.


📌 MemoListController memoList 그룹화 작업

class MemoListController extends GetxController {
  late CollectionReference memoCollectionRef;
  List<MemoModel> memoList = [];
  Map<String, List<MemoModel>> memoGroup = {};
//
  
  void onInit() {
    super.onInit();
    memoCollectionRef = FirebaseFirestore.instance.collection('memo');
    loadAllMemos();
  }
//
  void loadAllMemos() async {
    var memoData = await memoCollectionRef.get();
    memoList = memoData.docs
        .map<MemoModel>(
            (data) => MemoModel.fromJson(data.data() as Map<String, dynamic>))
        .toList();
    var monthkey = -1;
    memoList.map((memo) {
      monthkey = memo.createdAt.month;
      var group = memoGroup[monthkey.toString()];
      if (group == null) {
        group = [memo];
      } else {
        group.add(memo);
      }
      memoGroup[monthkey.toString()] = group;
    }).toList();
    print(memoGroup);
    update();
  }
}

✔ 이제 몇 가지 메모를 추가하여 월별로 나눠서 보이는 것을 개발한다.


📌 MemoModel 생성자 수정

MemoModel({
    String? id,
    required this.title,
    required this.memo,
    DateTime? createdAt,
  })  : id = id ?? Uuid().v4(),
        createdAt = createdAt ?? DateTime.now();

✔ 그룹별 리스트를 갖도록 설계한다. 각 그룹의 키로 월을 사용한다. 이제 모든 리스트에 loop를 돌면서 monthKey 별로 분기를 통해 나눠지도록 처리했다.

✔ 그런데 디버그로 돌려서 print를 찍어보면 다음과 같이 7월 메모 1 데이터는 분명 7월 11로 수정을 했는데 여전히 8월 11일로 데이터가 생성되어 있는 것을 볼 수 있다.

   이것은 모델을 생성할 때 createdAt을 항상 현 시간으로 표기를 하고 있어서 발생되는 버그로, 수정해준다.


📌 홈화면 memo 그룹별 화면 처리

 GetBuilder<MemoListController>(builder: (controller) {
  List<String> keys = [];
  List<List<MemoModel>> values = [];
  controller.memoGroup.forEach((key, value) {
    keys.add(key);
    values.add(value);
  });
  return Column(
    children: List.generate(keys.length, (i) {
      return _monthlyMemoGroup(keys[i], values[i]);
    }),
  );
}),

📌 홈화면 _monthlyMemoGroup 월표기 위한 파라미터 추가

Widget _monthlyMemoGroup(String monthString, List<MemoModel> memoList) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      SizedBox(height: 30),
      Text(
        '$monthString월',
        style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
      ),
      SizedBox(height: 10),
      Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(8),
          color: Colors.white,
        ),
        padding: const EdgeInsets.only(left: 25),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: List.generate(
            memoList.length,
            (i) {
              return Container(
                padding: const EdgeInsets.symmetric(vertical: 10),
                decoration: BoxDecoration(
                  border: Border(
                    bottom: BorderSide(
                      color: Color(0xffECECEC),
                    ),
                  ),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Text(
                      memoList[i].title,
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 17,
                      ),
                    ),
                    Text(
                      memoList[i].memo,
                      style:
                          TextStyle(fontSize: 14, color: Color(0xff848484)),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      )
    ],
  );
}

✔ 이제 화면에서 각 분기별로 나눠서 그려주면 된다.


4-3 메모 수정

📌 메모 수정페이지 라우팅 처리 _monthlyMemoGroup 함수

return GestureDetector(
  onTap: () {
    Get.to(MemoWritePage(), binding: BindingsBuilder(() {
      Get.put(MemoWriteController(memoModel: memoList[i]));
    }));
  },
  child: Container(
    padding: const EdgeInsets.symmetric(vertical: 10),
    decoration: BoxDecoration(
      border: Border(
        bottom: BorderSide(
          color: Color(0xffECECEC),
        ),
      ),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text(
          memoList[i].title,
          style: TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 17,
          ),
        ),
        Text(
          memoList[i].memo,
          style:
              TextStyle(fontSize: 14, color: Color(0xff848484)),
        ),
      ],
    ),
  ),
);

✔ 페이지 라우팅할 때 등록과는 달리 메모 정보를 함께 전달하도록 수정한다.


📌 MemoWriteController 수정

MemoWriteController({this.memoModel});
MemoModel? memoModel;

MemoWriteController의 생성자에 MemoModel을 옵셔널로 받을 수 있도록 수정한다.


📌 MemoWriteController onInit 수정


void onInit() {
  super.onInit();
  memoCollectionRef = FirebaseFirestore.instance.collection('memo');
  if (memoModel != null) {
    title = memoModel!.title;
    memo = memoModel!.memo;
    memoDate = memoModel!.createdAt;
  } else {
    memoDate = DateTime.now();
  }
}

onInit 함수에서 memoModel이 있는지에 따라 titlememo에 데이터를 넣어준다.


📌 title,memo TextField에 초깃값세팅

final TextEditingController titleTextController = TextEditingController();
final TextEditingController memoTextController = TextEditingController();
//

void onInit() {
  super.onInit();
  memoCollectionRef = FirebaseFirestore.instance.collection('memo');
  if (memoModel != null) {
    title = memoModel!.title;
    memo = memoModel!.memo;
    memoDate = memoModel!.createdAt;
    titleTextController.text = title;
    memoTextController.text = memo;
  } else {
    memoDate = DateTime.now();
  }
}

✔ 화면은 날짜를 제외하고 모두 textField이기 때문에 초깃값을 세팅하기 위해서 TextEditingController가 필요하다.


📌 TextEditingController 주입

body: Padding(
  padding: const EdgeInsets.symmetric(horizontal: 25),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      TextField(
        controller: Get.find<MemoWriteController>().titleTextController,
        decoration: InputDecoration(
          border: InputBorder.none,
          hintText: '제목을 입력해주세요.',
        ),
        style: TextStyle(
          fontSize: 27,
          height: 1.5,
          fontWeight: FontWeight.bold,
          letterSpacing: -1,
        ),
        maxLines: null,
        onChanged: (value) {
          Get.find<MemoWriteController>().setTitle(value);
        }, // 여러 줄 입력 가능
      ),
      Text(
        MemoDataUtils.formatDate(
            'yyyy-MM-dd', Get.find<MemoWriteController>().memoDate!),
        style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)),
      ),
      Expanded(
        child: TextField(
          controller: Get.find<MemoWriteController>().memoTextController,
          decoration: InputDecoration(
            border: InputBorder.none,
            hintText: '내용을 입력해주세요.',
          ),
          style: TextStyle(
            fontSize: 15,
            height: 1.5,
            color: Color(0xff848484),
            letterSpacing: -1,
          ),
          maxLines: null, // 여러 줄 입력 가능
          onChanged: (value) {
            Get.find<MemoWriteController>().setMemo(value);
          },
        ),
      ),
    ],
  ),
),

📌 MemoWriteController save 함수 수정

void save() async {
  var newMemoModel = MemoModel(
    id: memoModel?.id,
    title: title,
    memo: memo,
    createdAt: DateTime.now(),
  );
  if (memoModel != null) {
    var doc =
        await memoCollectionRef.where('id', isEqualTo: memoModel!.id).get();
    memoCollectionRef.doc(doc.docs.first.id).update(newMemoModel.toMap());
  } else {
    memoCollectionRef.add(newMemoModel.toMap());
  }
  Get.back(result: newMemoModel);
}

✔ 수정까지 잘 저장되는 것을 데이터베이스를 통해 확인하였다. 하지만 수정이 완료되고 난 뒤에 새로 메모 갱신이 되고 있지 않다.


📌 메모 등록/수정 페이지에서 돌아온 home 이벤트 처리

var result = await Get.to(MemoWritePage(),
    binding: BindingsBuilder(() {
  Get.put(MemoWriteController(memoModel: memoList[i]));
}));
if (result != null) {
  Get.find<MemoListController>().reload();
}

📌 MemoListController reload함수 개발

void reload() {
  memoGroup = {};
  loadAllMemos();
}

MemoWriteController에서 save가 완료되고 나면 return으로 result 값을 전달해 주기 때문에, 해당 값이 있다면 갱신이 필요한 것이므로 MemoListControllerreload 함수를 호출한다.


📌 메모 등록 이벤트 callBack부분 reload 처리

floatingActionButton: FloatingActionButton(
  onPressed: () async {
    var result =
        await Get.to(MemoWritePage(), binding: BindingsBuilder(() {
      Get.put(MemoWriteController());
    }));
    if (result != null) {
      Get.find<MemoListController>().reload();
    }
  },
  backgroundColor: Color(0xffF7C354),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(50),
  ),
  child: Image.asset('assets/images/plus.png'),
),

reload는 수정뿐만 아니라 메모 등록 이벤트 돌아온 후에도 처리해야 한다.


4-4 메모 삭제

📌 메모 등록 페이지 삭제 버튼 배치 및 이벤트 연결

bottomNavigationBar: Padding(
  padding: const EdgeInsets.all(15),
  child: GestureDetector(
    onTap: () {
      Get.find<MemoWriteController>().delete();
    },
    child: Container(
      height: 50,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        color: Color(0xffF81717),
      ),
      child: Center(
        child: Text(
          '삭제',
          style: TextStyle(fontSize: 17, color: Colors.white),
        ),
      ),
    ),
  ),
),

✔ 하단에 배치하기 위한 가장 쉬운 방법은 ScaffoldbottomNavigationBar 이다.
   물론 목적은 bottomNavigationBar를 사용할 목적으로 설계되었지만, 확장성이 가능하도록 Widget으로 받아 주고 있어서, 반드시 BottomNavigationBar를 사용하지 않아도 된다.


📌 메모 삭제 로직 개발

void delete() async {
  var doc =
      await memoCollectionRef.where('id', isEqualTo: memoModel!.id).get();
  memoCollectionRef.doc(doc.docs.first.id).delete();
  Get.back(result: true);
}

resulttrue를 전달해 주는 이유는 이미 메모 등록/수정 페이지로부터 돌아올 때 result 값이 null 이 아니라면 reload 처리하도록 개발했기 때문이다.
   Get.back() 만 보내면 새로고침이 처리되지 않아서 삭제한 메모가 그대로 남아 있는 것처럼 보이게 되는데, 이것을 방지하기 위해서 resulttrue 를 담아서 보낸다.


📌 기존 삭제 위젯 onTap 이벤트

bottomNavigationBar: Padding(
  padding: const EdgeInsets.all(15),
  child: GestureDetector(
    onTap: () {
      _showDeleteConfirmDialog(context);
    },
    child: Container(
      height: 50,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        color: Color(0xffF81717),
      ),
      child: Center(
        child: Text(
          '삭제',
          style: TextStyle(fontSize: 17, color: Colors.white),
        ),
      ),
    ),
  ),
),

📌 삭제 Confirm 메세지

void _showDeleteConfirmDialog(BuildContext context) {
  showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        title: Text('메모 삭제'),
        content: Text('메모를 삭제하시겠습니까?'),
        actions: <Widget>[
          TextButton(
            onPressed: () {
              Get.back();
            },
            child: Text('취소'),
          ),
          TextButton(
            onPressed: () {
              Get.back();
              Get.find<MemoWriteController>().delete();
            },
            child: Text(
              '삭제',
              style: TextStyle(color: Colors.red),
            ),
          ),
        ],
      );
    },
  );
}


4-5 메모 리스트 필터

📚 검색 개발의 두 가지 방식


📕 사용자가 검색을 할 때 서버(파이어 베이스) 쿼리 조회로 검색

[ 장점 ]
✔ 메모 데이터를 모두 가져올 필요가 없이 검색하는 키워드만 조회할 수 있다.
✔ 실시간 데이터를 모두 반영할 수 있다.

[ 단점 ]
✔ 검색할 때마다 비용이 발생한다.
✔ 인터넷이 연결되어 있어야만 검색이 가능하다.


📘 앱 내로 모든 메모 정보를 로드 후 외부 리소스 비용 없이 앱 내에서 검색

[ 장점 ]
✔ 매우 빠른 검색이 가능하다.
✔ offline mode에서도 검색이 가능하다.
✔ 키보드 입력별로 실시간 검색 처리가 가능하다

[ 단점 ]
✔ 메모 양이 방대해질 경우 모든 데이터를 한 번에 불러오기가 버거울 수 있다.
✔ 실시간 데이터를 반영할 수 없다.


✅ 메모 앱의 경우 다른 사람들과의 메모 공유가 필요 없기 때문에, 두 번째 방식을 선택한다.

💡 키보드 입력에 따라 외부 api를 사용하거나, 네트워크를 이용할 경우는 debounce라는 기술을 통해 빈번하지 않게 적당한 시점에 요청하는 방식이 있다.


📌 홈 화면 검색 onChange 이벤트 연결

 TextField(
  decoration: InputDecoration(
    border: InputBorder.none,
    hintText: '검색',
    hintStyle: TextStyle(
      color: Color(0xff888888),
      fontSize: 15,
    ),
  ),
  onChanged: (value) {
    Get.find<MemoListController>().search(value);
  },
),

✔ 키보드 입력에 따라 이벤트 처리를 하기 위해서, 검색 TextField 에 `onChanged 함수를 사용한다.


📌 MemoListController 검색 구현

void search(String searchKeyword) {
  var searchResult = memoList.where((memo) {
    return memo.title.contains(searchKeyword) ||
        memo.memo.contains(searchKeyword);
  }).toList();
  memoGroup = {};
  var monthkey = -1;
  searchResult.map((memo) {
    monthkey = memo.createdAt.month;
    var group = memoGroup[monthkey.toString()];
    if (group == null) {
      group = [memo];
    } else {
      group.add(memo);
    }
    memoGroup[monthkey.toString()] = group;
  }).toList();
  update();
}

📌 검색 input 우측 remove 아이콘 배치 및 이벤트 연결

GestureDetector(
    onTap: () {
      Get.find<MemoListController>().clearSearchKeyword();
    },
    child: Icon(
      Icons.close,
      color: Color(0xff888888),
    ),
),

✔ 검색이 완료되고 검색 키워드를 한 번에 지울 수 있는 버튼이 우측에 아이콘으로 있으면 좋은 사용자 경험을 줄 수 있기 때문에 추가해준다.


📌 MemoListController 검색 초기화

TextEditingController searchKeywordController = TextEditingController();
//
void clearSearchKeyword() {
  searchKeywordController.text = '';
  reload();
}

📌 Home 검색 input controller 연결

TextField(
  controller:
      Get.find<MemoListController>().searchKeywordController,
  decoration: InputDecoration(
    border: InputBorder.none,
    hintText: '검색',
    hintStyle: TextStyle(
      color: Color(0xff888888),
      fontSize: 15,
    ),
  ),
  onChanged: (value) {
    Get.find<MemoListController>().search(value);
  },
),

📌 키보드 비활성화 처리


Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Color(0xffEBEBEB),
    body: GestureDetector(
      behavior: HitTestBehavior.translucent,
      onTap: () {
        FocusScope.of(context).unfocus(); // 추가 
      },
      child: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 25),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                '메모',
                style: TextStyle(
                  fontSize: 35,
                  fontWeight: FontWeight.bold,
                ),
              ),
              _searchBar(),
              GetBuilder<MemoListController>(builder: (controller) {
                List<String> keys = [];
                List<List<MemoModel>> values = [];
                controller.memoGroup.forEach((key, value) {
                  keys.add(key);
                  values.add(value);
                });
                return Column(
                  children: List.generate(keys.length, (i) {
                    return _monthlyMemoGroup(keys[i], values[i]);
                  }),
                );
              }),
            ],
          ),
        ),
      ),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: () async {
        var result =
            await Get.to(MemoWritePage(), binding: BindingsBuilder(() {
          Get.put(MemoWriteController());
        }));
        if (result != null) {
          Get.find<MemoListController>().reload();
        }
      },
      backgroundColor: Color(0xffF7C354),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(50),
      ),
      child: Image.asset('assets/images/plus.png'),
    ),
  );
}

✔ 검색을 하기 위해 키보드가 활성화되었는데 input 영역이 아닌 다른 영역을 눌러 키보드가 비활성화 되도록 수정한다.


5. 애드몹

구글 애드몹

5-1 애드몹 계정 생성 및 앱 생성

✅ 로그인 후 결제 설정 미완료로 뜨는 문제 해결 버튼을 눌러 이동한다.
✅ 결제 계정 추가 버튼을 눌러 입력하고 제출 버튼을 누르면 인증 프로세스가 진행된다.


5-2 안드로이드 애드몹 ID 연결

✅ 좌측 앱 추가 버튼을 눌러 플랫폼 > Android로 진행한다.
✅ 앱 등록 전이기 때문에 등록되어 있냐는 문항에 아니오를 클릭하고 계속한다.
✅ 앱 이름은 자유롭게 등록이 가능하지만, 구글에서는 앱 이름을 앱 스토어 등록정보와 일치시키는 것이 좋다고 가이드 되어있다.
✅ 완료하고 나면 광고 단위를 추가할 수 있는데, 그중 배너를 클릭하고, 광고 단위 이름을 어느 위치에 둘지 작성한다.
✅ 광고 단위 아이디를 통해 광고를 연결할 예정으로, 언제든 확인이 가능하기 때문에 완료 버튼을 눌러준다.


5-3 플러터 애드몹 위젯 생성 및 배너 적용

구글 공식 라이브러리 문서

📌 google_mobile_ads 설치

flutter pub add google_mobile_ads

✔ Terminal에 입력하여 설치한다.


📌 AndroidManifest.xml 에 광고 단위 추가

<meta-data
      android:name="com.google.android.gms.ads.APPLICATION_ID"
    android:value="ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"/>

AndroidManifest.xml에 광고 id를 등록해야 한다.
</application> 위에 위치 시키고, ca-app-pub-xxxxxxxxxxxxxxx~yyyyyyyy 이것을 그대로 입력하는 것이 아닌 ad mob 콘솔에 앱 ID를 넣어줘야 한다.


5-4 모바일 광고 컴포넌트 개발

📌 구글 광고 SDK 초기화

 MobileAds.instance.initialize();

✔ 앱이 실행되는 main 함수에 WidgetsFlutterBinding.ensureInitialized(); 이후 소스코드를 추가한다.


📌 AdmobBanner 소스 초기화

import 'package:flutter/widgets.dart';
//
class AdmobBanner extends StatefulWidget {
  const AdmobBanner({super.key});
//
  
  State<AdmobBanner> createState() => _AdmobBannerState();
}
//
class _AdmobBannerState extends State<AdmobBanner> {
  
  Widget build(BuildContext context) {
    return Container();
  }
}

admob_banner.dart 파일을 만든다.


📌 initState 광고 로드

class _AdmobBannerState extends State<AdmobBanner> {
  AdManagerBannerAd? _bannerAd;
  bool _isLoaded = false;
//
  final adUnitId = '/21775744923/example/adaptive-banner';
//
  void loadAd() {
    _bannerAd = AdManagerBannerAd(
      adUnitId: adUnitId,
      request: const AdManagerAdRequest(),
      sizes: [AdSize.banner],
      listener: AdManagerBannerAdListener(
        onAdLoaded: (ad) {
          debugPrint('$ad loaded.');
          setState(() {
            _isLoaded = true;
          });
        },
        onAdFailedToLoad: (ad, err) {
          ad.dispose();
        },
      ),
    )..load();
  }
//
  
  void initState() {
    super.initState();
    loadAd();
  }
//
  
  Widget build(BuildContext context) {
    if (_bannerAd != null && _isLoaded) {
      return SizedBox(
        width: _bannerAd!.sizes.first.width.toDouble(),
        height: _bannerAd!.sizes.first.height.toDouble(),
        child: AdWidget(ad: _bannerAd!),
      );
    }
    return Container(height: 1);
  }
}

StatefulWidget으로 개발하는 이유는 광고 로드는 한 번만 불러와져야 하기 때문으로, statefulWidget의 라이프 사이클의 initState 때 광고 로드 함수를 호출한다.

✔ 광고가 로드되기 전에는 AdWidget을 사용하면 안되기 때문에 로드 여부에 따라 빈 Container를 반환한다.
   여기서 height : 1을 설정한 이유는 광고가 보통 bottomNavigationBar 위치에 하게 되는데 Height가 없는 Container를 사용하면 화면 전체를 덮게 되는 현상이 있다.


⚠️ 광고 단위 adUnitId '/21775744923/example/adaptive-banner';

💡이렇게 설정한 이유?

✔ 구글에서 가이드 한 방식으로 테스트 광고를 사용할 때 사용하게 된다.
   보통 앱을 개발할 때 디버그 모드로 돌리기 때문에 그때는 라이브 광고를 사용하게 될 경우 부정 광고라고 광고가 막힐 수가 있으니 주의해야 한다.
  라이브로 할 때 이 광고 단위를 바꿔줘야 한다.


테스트 광고가 노출되게 되는데, 실제 광고 단위를 설정할 때는 광고 단위를 다음과 같이 설정한다.

final adUnitId = 'ca-app-pub-xxxxxxxxx/yyyyyyy';


광고 단위 확인하는 방법
✔ 구글 애드몹 콘솔에 접속하여 광고 단위 탭에서 확인한다.


6. 안드로이드 앱 배포

6-1 앱 배포 과정 정리

📚 안드로이드 앱 배포를 위한 사전준비


📕 앱 아이콘 만들기 (512*512)
📙 앱 출시 정보 준비하기
   ✔ 이름
   ✔ 설명(간단 설명, 자세한 설명)
   ✔ 키워드
   ✔ 앱을 소개하는 대표 이미지
     - 1024 500 그래픽 이미지
     - 320
3840 스크린샷 2~8장
📒 jks 키 만들기
📗 배포를 위한 gradle 세팅


6-2 앱 로고 만들기

✅ 원하는 앱 로고를 512*512 사이즈에 맞게 제작한다.


6-3 앱 로고 적용하기

📌 flutter_launcher_icons 설치

flutter pub add --dev flutter_launcher_icons

flutter_launcher_icons는 프로젝트에서 앱의 아이콘을 쉽게 생성하고 설정할 수 있도록 도와주는 라이브러리로, 다양한 해상도와 플랫폼(iOS, Android)에 맞는 앱 아이콘을 자동으로 생성하고 적용할 수 있다. Terminal을 열어 설치한다.

✔ 자동 앱 사이즈별 에셋을 만들기 위해 위에서 만들어 놓은 앱 로고를 assets/images/icon/폴더를 만들어 넣는다.


📌 flutter_launcher_icons 스크립트를 위한 세팅

flutter_icons:
  android: "launcher_icon"
  ios: false
  image_path: "assets/images/icon/memo_app_logo.png"

✔ 스크립트를 통해 앱 아이콘들 만들게 되는데, 그것을 위해서 pubspec.yaml 파일 하단부에 관련 정보를 제공해야 한다.


📌 flutter_launcher_icons 스크립트 실행 명령

flutter pub run flutter_launcher_icons

flutter_launcher_icons에서 참조하는 정보
android : 안드로이드 로고 이름
ios : ios 앱 아이콘 생성 유무
image_path : 앱 아이콘으로 만들 앱 로고 애셋 위치


6-4 앱 출시 정보 준비 및 jks 키 만들기

📚 출시 정보

📕 이름 : 마이메모
📙 간단 설명 : “마이메모”는 빠르고 간편하게 메모를 작성하고 월별로 정리하여 손쉽게 관리할 수 있는 메모 앱입니다.
📒 자세한 설명 : 마이메모는 일상 속에서 메모가 필요할 때, 빠르고 간편하게 메모를 기록하고 이를 체계적으로 관리할 수 있도록 도와주는 앱입니다. 사용자 인터페이스가 직관적이고 사용하기 쉬워, 복잡한 설정 없이 바로 메모를 작성할 수 있습니다.
📗 키워드
메모 앱, 빠른 메모,메모 관리,월별 메모,간편한 노트,메모 정리,일상 기록,개인 노트,메모장,메모 자동 저장
📘 앱 소개하는 대표 이미지 320*3840


📚 JKS 파일이 Android 배포에서 필요한 이유

📕 앱 무결성 보장
✔ 앱이 JKS 파일을 사용하여 서명되면, 사용자는 앱이 개발자에 의해 인증되었으며, 다운로드 이후에 변경되지 않았다는 것을 확신할 수 있다.
✔ 서명되지 않은 앱은 설치가 불가능하며, 이미 설치된 앱이 서명되지 않은 버전으로 교체될 수 없다. 이는 사용자에게 보안과 신뢰를 제공한다.

📙 업데이트 관리
✔ 앱이 악의적으로 변경되지 않도록 하며, 동일한 개발자가 배포하는 공식 업데이트임을 보장한다.

📒 Google Play 등록
✔ Google Play는 이 서명을 사용하여 개발자의 신원을 확인하고, 사용자에게 안전한 앱을 제공한다.

📗 APK파일 서명
✔ KS 파일에는 개인 키(private key)와 공인된 인증서(certificate)가 포함되어 있으며, 이들이 APK 파일에 서명을 추가하는 데 사용된다.


📚 jks 키 만들기

📕 안드로이드 스튜디오 열기


📙 Generate Signed Bundle / APK 메뉴 선택


📒 Android App Bundle


📗 Key store 생성
💡 만일 키 스토어가 있다면 Choose existing… 을 선택해서 기존의 키 스토어를 이용한다.


📘 Key Store 입력
✔ Alias, 비밀번호는은 중요해서 잊지 말아야 한다. 이후에 앱 배포시 key.properties로 사용된다.


6-5 build.gradle 설정

key.properties 만들기

📌 key.properties파일

storeFile=jks파일 위치 절대경로
storePassword=jks 생성시 설정한 store 비밀번호
keyAlias=jks 생성시 설정한 alias
keyPassword=jks 생성시 설정한 key 비밀번호

✔ android 폴더 내부에 New File을 해주고 key.properties라는 파일을 만든다. 이 파일은 jks 파일의 정보를 담는다.


app/build.gradle 설정

📌 key.properties파일 로드

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

✔ 이제 key.properties 파일을 읽어들여서 build 시 사용할 수 있도록 설정한다.


📌 keystoreProperties변수 사용 signingConfig 설정

signingConfigs {
    release {
        keyAlias keystoreProperties['keyAlias']
        keyPassword keystoreProperties['keyPassword']
        storeFile file(keystoreProperties['storeFile'])
        storePassword keystoreProperties['storePassword']
    }
}

📌 buildTypes 설정

 buildTypes {
    release {
        signingConfig signingConfigs.release
//
        minifyEnabled true
        shrinkResources true
//
        proguardFiles getDefaultProguardFile(
                'proguard-android-optimize.txt'),
                'proguard-rules.pro'
    }
    debug {
        signingConfig signingConfigs.debug
    }
}

📚 buildTypes 블록은 Android 프로젝트의 build.gradle 파일에서 빌드 구성을 정의하는 부분이다. 이 블록을 통해 releasedebug 와 같은 빌드 타입에 대한 설정을 정의할 수 있다.

📘 release:
✔ 릴리즈 빌드 타입을 정의하며, 이 빌드 타입은 주로 앱을 최종 사용자에게 배포할 때 사용된다. 릴리즈 빌드에서는 보통 최적화가 활성화되고, 코드 및 리소스의 크기를 줄이기 위한 여러 가지 작업이 수행된다.

📗 debug:
✔ 디버그 빌드 타입을 정의하며, 이 빌드 타입은 주로 개발 중에 사용되고, 디버깅을 용이하게 하기 위해 최적화가 비활성화되고 디버그 정보를 포함한다.


📚 release 내 설정값의 의미

📘 signingConfig
✔ 이 설정은 해당 빌드 타입에서 사용하는 서명 구성을 지정한다.
   signingConfigs.release는 릴리즈 서명 구성, signingConfigs.debug는 디버그 서명 구성을 의미한다.

📗 minifyEnabled
true로 설정되면 ProGuard 또는 R8에 의해 코드 난독화 및 최적화가 수행된다.
   이 설정은 앱의 크기를 줄이고 코드 보안을 강화하는 데 사용된다.

📒 shrinkResources
true로 설정되면 사용되지 않는 리소스(이미지, XML 등)가 빌드 중에 제거된다.
   minifyEnabledtrue로 설정되어 있어야 효과가 있다.

📙 proguardFiles
✔ 이 설정은 ProGuard 또는 R8에서 사용할 구성 파일을 지정한다.

📕 getDefaultProguardFile('proguard-android-optimize.txt') : 기본 ProGuard 최적화 파일을 가져온다.

📔 'proguard-rules.pro' : 프로젝트의 루트 디렉토리에 위치한 사용자 정의 ProGuard 규칙 파일을 지정한다.


6-6 앱 배포

배포 파일 만들기

📌 build 명령어

flutter build  appbundle

✔ 배포 명령어를 통해 bundle 파일을 만들어야 한다.


✔ 명령어를 치면 versionCode를 알 수 없다는 오류가 뜬다.
✔ versionCode에 대한 값이 어떻게 설정되어 있는지 build.gradle 파일을 살펴보면, 두 개의 값을 참조하지 못하고 있다.
✔ 이 값을 하드코딩으로 변경해도 되지만, 그럼 매번 수정을 해줘야 하는 불편함이 있어 반 자동화가 될 수 있도록 설정해줘야 한다.


📌 local.properties 참조

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}
//
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}
//
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

local.properties 파일내부를 확인해보면 플러터 버전 네임과 코드가 있는 것을 볼 수 있다.
✔ 이 파일은 build 될 때 가장 먼저 실행되어 자동으로 파일을 만들어준다.


📌 versionName과 versionCode 설정

defaultConfig {
    // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
    applicationId = "com.devman.flutter_memo_app"
    // You can update the following values to match your application needs.
    // For more information, see: https://flutter.dev/to/review-gradle-config.
    minSdk = flutter.minSdkVersion
    targetSdk = flutter.targetSdkVersion
    versionCode = flutterVersionCode
    versionName = flutterVersionName
}

✔ 값을 참조해서 사용하면 문제가 해결된다.


⚠️ minSdkVersion 오류

✔ 오류가 발생한다면 minSdkVersion을 21로 설정한다.


📌 app/build.gradle 파일의 minSdkVersion 21로 설정

defaultConfig {
    // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
    applicationId = "com.devman.flutter_memo_app"
    // You can update the following values to match your application needs.
    // For more information, see: https://flutter.dev/to/review-gradle-config.
    minSdk = 21
    targetSdk = flutter.targetSdkVersion
    versionCode = flutterVersionCode
    versionName = flutterVersionName
}


구글 플레이스토어 앱 생성

🔹 Google for Developers
구글 개발자 콘솔로 접속하여 구글 로그인으로 연동한다.

🔹 Google Play > Play Console 로그인 클릭

🔹 개발자 계정을 만든다.


개발자 등록

🔹 개인 > 시작하기

🔹 25달러 최초 한 번 결제

🔹 개발자 이름 생성(변경 가능)

🔹 결제 프로필 만들기 또는 선택 입력 후 저장

🔹 본인 인증 > 이메일 주소 인증

🔹 입력 항목 작성 후 다음

🔹 앱 배포 계획 항목 선택 > 연락 정보 입력 > 약관 동의 > 결제


앱 생성

🔹 앱 만들기

🔹 좌측 메뉴 앱 정보>기본 스토어 등록정보

🔹 등록 정보 입력


테스트 모드 앱 배포

🔹 좌측 메뉴 테스트 > 내부 테스트 메뉴

🔹 테스터 선택

🔹 이메일 추가(최대 100명까지 참여 가능)

🔹 새 버전 만들기 > 서명 키 선택 > Google에서 생성한 키 사용

🔹 vsCode에서 flutter build를 통해 생성된 app-release.aab 파일 등록

🔹 다음 > 저장 및 출시


테스트 앱 다운로드 받아 테스트

🔹 좌측 메뉴 내부 테스트 > 테스터 > 링크 복사

🔹 링크 접속 > Accept Invite > download it on Google Play


역시 메모앱에 기능을 더하는 것은 어려웠다.
소스 코드 짜는 법이나, 함수에 대한 것 보다는 기능 연결에 집중되어 있다보니, 중급자를 위한 강의란 생각이 들었다.

확실히 앱 배포까지 완료해보니 모든 내용을 완벽하게 이해하지는 못했지만, 흐름을 파악하는데 좋았던 것 같다.
다만 그 과정이 길고 복잡하다 보니 역시 앱 하나 만들고 배포하는데에는 많은 공수가 든다는 생각이 든다.

다음주부터는 본 캠프로 수업 시간은 배로 늘고, 강의도 시작하고, 조 프로젝트도 진행하게 될 텐데 이해를 잘 하고, 수행할 수 있으면 좋겠다.

profile
💻 [25.05.26~] Flutter 공부중⏳

0개의 댓글