[Flutter 트랙] 앱개발 종합반 4주차

Ss·2025년 2월 25일

지금까지 만들어본 프로젝트 데이터들은 종료하면 날라가는 휘발성 데이터다.
이러한 데이터들을 서버에 저장을해 데이터가 유지되어 유저에게 좋은 경험을 줄수있다.

파이어베이스

📚 제공되는 서비스

  • 실시간 데이터베이스

    • 개발자가 여러 장치 및 플랫폼에서 데이터를 저장하고 동기화할 수 있는 확장 가능한 실시간 NoSQL 클라우드 데이터베이스를 제공합니다.
  • 인증

    • 이메일/비밀번호, 전화번호, 소셜 미디어 계정과 같은 다양한 로그인 방법을 지원하여 사용자 인증을 관리하는 안전하고 안정적인 방법을 제공합니다.
  • 저장소

    • 이미지, 오디오 및 비디오 파일과 같은 사용자 생성 콘텐츠를 저장하고 제공하는 클라우드 기반 저장소 서비스입니다.
  • 푸시 메세지

    • 개발자가 여러 플랫폼에서 사용자에게 알림 및 메시지를 보낼 수 있는 메시징 서비스입니다.
  • 오류추적

    • 개발자가 애플리케이션의 안정성 문제를 식별하고 수정할 수 있도록 자세한 충돌 보고 및 분석을 제공합니다.
  • 실시간 사용자 통계

    • 사용자 행동 및 앱 사용에 대한 통찰력을 제공하여 개발자가 데이터 기반 의사 결정을 통해 애플리케이션을 최적화하도록 돕습니다.
  • Firebase Remote Config
    - 개발자는 사용자가 업데이트를 하지 않고도 앱의 기능 또는 구성을 업데이트할 수 있습니다.


    📚 Flutter 프로젝트에 Firebase가 적합한 이유

  • 개발 프로세스 가속화

  • 앱 기능 향상

    Firebase 서비스는 개발자에게 애플리케이션에 고급 기능을 추가할 수 있는 강력한 도구를 제공합니다.

  • 앱 성능 및 안정성 향상

    Firebase의 실시간 데이터 동기화, 비정상 종료 보고 및 성능 모니터링을 통해 개발자는 안정적이고 반응이 빠른 애플리케이션을 만들 수 있습니다.

  • 사용자 인증 간소화

    Firebase 인증은 다중 로그인 방법을 지원하므로 개발자가 사용자 계정을 보다 쉽게 관리하고 애플리케이션을 보호할 수 있습니다.

  • 손쉬운 확장

    Firebase의 인프라는 자동으로 확장되도록 설계되어 애플리케이션이 수동 개입 없이 대량의 데이터와 트래픽을 처리할 수 있습니다.

프로젝트 셋팅과 데이터베이스 만들기내용은 생략

파이어베이스 데이터베이스 설정

쓰레드 어플에서 사용할 데이터베이스 구조에 맞게 선언한다,


프로젝트 연동

프로젝트 단에서 사용할 파이어베이스 라이브러리 설치

flutter pub add firebase_core cloud_firestore

파이어베이스와 연동되기전 플러터 프레임워크가 모두 초기화 되엇는지를 체크해야한다. 따라서 runApp전 코드 한줄을 추가해준다

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

이후 파이어베이스 작업은 강의와 다르게 cli로 해보려한다.
https://firebase.google.com/docs/cli?hl=ko#windows-standalone-binary

firebase cli

이전에 셋팅해둔 내용이있어 문제없이 진행이 된것같다..


이제 프로젝트단으로 들어가서 firebase init을 타이핑하고 이 프로젝트에선 파이어스토어만 사용될 예정이라 체크하고 다음


체크후 생성햇던 프로젝트를 고르고

추가적인 질문에 엔터엔터 누르면 세팅 끝

await Firebase.initializeApp();

init이 되면 이렇게 코드를 작성하고 실행


이젠 익숙한 에러창인데 이번엔 조금다르다.
kotlin gradle plugin의 버전을 올려달란다...


setting.gradle가서 값을 확인하고..


에러 링크로 넘어가 최신버전으로 교체해주자

세팅이 되엇으니 파이어베이스 값 호출을 해보면

FirebaseFirestore.instance.collection('feeds').get().then((value) {
  print(value.docs.length);
});


완료!

save

피드 데이터를 가져오기 위해 컨트롤러에서 생성시점(onInit)에서 파이어스토어 접근

late CollectionReference feedsCollectionRef;


void onInit() {
  super.onInit();
  feedsCollectionRef = FirebaseFirestore.instance.collection('feeds');
}

save 함수 수정

void save() {
  var feedModel = FeedModel(
    contents: contents,
    images: selectedImages?.map<File>((e) => File(e.path)).toList() ?? [],
  );
  feedsCollectionRef.add(feedModel);

  Get.back(result: feedModel);
}

해당 에러 호출. 이유는 Map형식으로 저장이 아닌 feedmodel 객체 그대로 저장하려해서이다.
map형식으로 바꾸는 코드를 추가한다.

import 'dart:io';
import 'package:uuid/uuid.dart';

class FeedModel {
  String id;
  String contents;
  List<File> images;
  DateTime createdAt;

  FeedModel({
    required this.contents,
    required this.images,
  })  : id = Uuid().v4(),
        createdAt = DateTime.now();

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'contents': contents,
      'createdAt': createdAt.toIso8601String(),
    };
  }
}

저장까지 완료가 되었다.

Read

데이터를 조회해 보자.

late CollectionReference feedsCollectionRef;


void onInit() {
  super.onInit();
  feedsCollectionRef = FirebaseFirestore.instance.collection('feeds');
  loadAllFeeds();
}

void loadAllFeeds() async {
  var feedData = await feedsCollectionRef.get();
  feedList = feedData.docs
      .map<FeedModel>(
          (data) => FeedModel.fromJson(data.data() as Map<String, dynamic>))
      .toList();
  update();
}

onInit함수로 초기에 파베에 접근하고
값을 요청

이때 값역시 Map형식으로 오기때문에 바꿔주는 코드를 작성해야한다

import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:uuid/uuid.dart';

class FeedModel {
  String id;
  String contents;
  List<File> images;
  DateTime createdAt;

  FeedModel({
    String? id,
    required this.contents,
    required this.images,
    DateTime? createdAt,
  })  : id = id ?? Uuid().v4(),
        createdAt = createdAt ?? DateTime.now();

  factory FeedModel.fromJson(Map<String, dynamic> json) {
    return FeedModel(
      id: json['id'],
      contents: json['contents'],
      images: [],
      createdAt: DateTime.parse(json['createdAt']),
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'contents': contents,
      'images': images,
      'createdAt': createdAt.toIso8601String(),
    };
  }
}

에러발생...
혹시몰라 값을 출력해봣는데 한적없는 timestamp가 있엇다
생각을 해보니 초기에 만들어준 값에서 들어간 데이터인것같다. 임의로 넣은 값이기에 지우고 다시실행하자.


완료.

reload

addfeed 함수는 저장만을 진행했기때문에 저장후 리로드가 필요하다.

void reload() {
  feedList.clear();
  loadAllFeeds();
}
...
if (result != null) {
  Get.find<HomeFeedListcontroller>().reload();
}

호출하는 위치까지 수정해주면 다시 호출이 되어 계속 최신화가 되게된다.

update

수정했을때 서버에 값을 보내서 수정이 되게 해야한다.
첫번째로 수정페이지로 갈때 모델을 통채로 보내주게 수정한다

GestureDetector(
  onTap: () {
    _showCupertinoActionSheet(model);
  },
  child: Icon(
    Icons.more_horiz,
    color: Color(0xff999999),
  ),
)

모달로 만들었던 화면에서도 모델을 파라미터로 전달

void _showCupertinoActionSheet(FeedModel feedModel) {
  showCupertinoModalPopup(
    context: Get.context!,
    builder: (BuildContext context) => CupertinoActionSheet(
      actions: <CupertinoActionSheetAction>[
        CupertinoActionSheetAction(
          onPressed: () async {
            
          },
          child: Text('수정'),
        ),
        CupertinoActionSheetAction(
          onPressed: () {
            Navigator.pop(context);
            Get.find<HomeFeedListcontroller>().removeFeed(feedModel.id);
          },
          isDestructiveAction: true,
          child: Text('삭제'),
        ),
      ],
      cancelButton: CupertinoActionSheetAction(
        onPressed: () {
          Navigator.pop(context);
        },
        child: Text('취소'),
      ),
    ),
  );
}

수정을 하기위해 버튼클릭시 이벤트도 수정

CupertinoActionSheetAction(
  onPressed: () async {
    var result = await Get.to<FeedModel?>(ThreadWritePage(),
        binding: BindingsBuilder(() {
      Get.put(ThreadFeedWriteController(editFeedModel: feedModel));
    }));
    if (result != null) {
      Get.find<HomeFeedListcontroller>().reload();
    }
  },
  child: Text('수정'),
),

기존 피드와 수정된 피드를 분리해가며 수정모드를 체크한다. 그러기위해 값을 추가

class ThreadFeedWriteController extends GetxController {
  ThreadFeedWriteController({this.editFeedModel});
  final FeedModel? editFeedModel;

onInit함수에서 넘겨온 피드의 상태에 따라 분기처리

수정될 값을 화면에 진입시 세팅해주기위해 화면단 수정

class ThreadFeedWriteController extends GetxController {
  ThreadFeedWriteController({this.editFeedModel});
  final TextEditingController contentsTextController = TextEditingController();```

```dart
 TextField(
    controller: Get.find<ThreadFeedWriteController>()
        .contentsTextController,
    cursorHeight: 16,
    decoration: InputDecoration(
      isDense: true,
      hintText: '새로운 소식이 있나요?',
      hintStyle: TextStyle(
        color: Color(0xff9a9a9a),
        fontSize: 14,
      ),
      contentPadding: EdgeInsets.zero,
      border: InputBorder.none,
    ),
    onChanged: (value) {
      Get.find<ThreadFeedWriteController>()
          .setContent(value);
    },
),

이제 컨트롤러쪽에서 파베로 넘겨주는 save함수를 수정

void save() async {
  var feedModel = FeedModel(
    id: editFeedModel?.id,
    contents: contents,
    images: selectedImages?.map<File>((e) => File(e.path)).toList() ?? [],
  );
  if (editFeedModel != null) {
    var doc =
        await feedsCollectionRef.where('id', isEqualTo: feedModel.id).get();
    feedsCollectionRef.doc(doc.docs.first.id).update(feedModel.toMap());
  } else {
    feedsCollectionRef.add(feedModel.toMap());
  }

  Get.back(result: feedModel);
}

화면 넘어갈시 바텀시트는 닫게 수정
과제할때 했던거같은데..

onPressed: () async {
  var result = await Get.to<FeedModel?>(ThreadWritePage(),
      binding: BindingsBuilder(() {
    Get.put(ThreadFeedWriteController(editFeedModel: feedModel));
  }));
  Get.back(); // 추가 
  if (result != null) {
    Get.find<HomeFeedListcontroller>().reload();
  }
},

delete

기존의 삭제 프로세스에서 디비에 접근하는걸 접목시키면 된다.

void removeFeed(String feedId) async {
  var doc = await feedsCollectionRef.where('id', isEqualTo: feedId).get();
  feedsCollectionRef.doc(doc.docs.first.id).delete();
  feedList.removeWhere((feed) => feed.id == feedId);
  update();
}

API

Application Programming Interface의 약자로 소프트웨어끼리 통신을 하기위해 서로 정해놓은 문법이다.

📚 API 구성 요소와 개념

  1. Methods

    GET(데이터 검색), POST(새 데이터 생성), PUT(기존 데이터 업데이트), DELETE(데이터 제거) 등 API를 사용하여 수행할 수 있는 작업입니다.

  2. Request

    요청은 클라이언트(일반적으로 애플리케이션)가 API에 요청하여 원하는 메서드, 엔드포인트 및 필요한 데이터를 지정합니다. 이것은 클라이언트가 의도를 API에 전달하는 방법입니다.

  3. Response

    요청을 처리한 후 API는 응답을 다시 클라이언트로 보냅니다. 이 응답에는 일반적으로 요청된 데이터, 성공 또는 실패를 나타내는 상태 코드 및 관련 오류 메시지가 포함됩니다.

  4. 데이터 형식

    API는 종종 클라이언트와 API 간에 데이터를 교환하기 위해 JSON(JavaScript Object Notation) 또는 XML(eXtensible Markup Language)과 같은 표준 데이터 형식을 사용합니다.

Api를 활용한 프로젝트

api 통신을 위해 라이브러리 하나를 추가
https://pub.dev/packages/http

예시 프로젝트 생성

  import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'API Communication Sample'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '홈',
            ),
          ],
        ),
      ),
    );
  }
}

futurebuilder

future의 형식을 갖고있는 빌더
future값일때와 결과값이 왔을때의 화면을 컨트롤할수있다.

class _MyHomePageState extends State<MyHomePage> {
Future<String> fetchData() async {
  await Future.delayed(const Duration(milliseconds: 2000));
  return '데이터 로드 완료';
}


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      title: Text(widget.title),
    ),
    body: Center(
      child: FutureBuilder<String>(
        future: fetchData(),
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
          if (snapshot.hasData) {
            return Text(snapshot.data!);
          } else {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    ),
  );
}
}

fetchDate의 코드를 조굼 수정하면

Future<String> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
print(response.body);
return '데이터 로드 완료';
}

로딩후 이렇게 값이 나오는걸 볼수있다.

Post

대부분은 get으로 통신하지 않는다.
post 요청을 받아보게 코드를 수정해보자.
모델 설계를 먼저해보자.

class Post {
final int id;
final int userId;
final String title;
final String body;

Post({required this.id, required this.userId, required this.title, required this.body});

factory Post.fromJson(Map<String, dynamic> json) {
  return Post(
    id: json['id'],
    userId: json['userId'],
    title: json['title'],
    body: json['body'],
  );
}
}

받는쪽 코드도 수정

Future<List<Post>?> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
if (response.statusCode == 200) {
  List jsonResponse = json.decode(response.body);
  return jsonResponse.map((post) => Post.fromJson(post)).toList();
}
}

알람앱과 날씨 api

날씨 데이터를 주는 사이트에서 회원가입후 api key를 받았다.
https://openweathermap.org/

요청하는 방법은 이 주소로 넘기면되는데
lat과 lon은 좌표 apikey는 위에서 받앗던 키를 하면 된다.

https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={API key}
                          
{
   "coord": {
      "lon": 7.367,
      "lat": 45.133
   },
   "weather": [
      {
         "id": 501,
         "main": "Rain",
         "description": "moderate rain",
         "icon": "10d"
      }
   ],
   "base": "stations",
   "main": {
      "temp": 284.2,
      "feels_like": 282.93,
      "temp_min": 283.06,
      "temp_max": 286.82,
      "pressure": 1021,
      "humidity": 60,
      "sea_level": 1021,
      "grnd_level": 910
   },
   "visibility": 10000,
   "wind": {
      "speed": 4.09,
      "deg": 121,
      "gust": 3.47
   },
   "rain": {
      "1h": 2.73
   },
   "clouds": {
      "all": 83
   },
   "dt": 1726660758,
   "sys": {
      "type": 1,
      "id": 6736,
      "country": "IT",
      "sunrise": 1726636384,
      "sunset": 1726680975
   },
   "timezone": 7200,
   "id": 3165523,
   "name": "Province of Turin",
   "cod": 200
}                    
                        

주는 json의 값에 맞춰 모델을 생성해주고 이 값에 맞춰 보여주기 위해 통신 관련 코드와 _wakeUpAlarm 위젯도 수정해준다

Future<WeatherModel?> loadWeatherApi() async {
  final url = Uri.parse(
      'https://api.openweathermap.org/data/2.5/weather?lat=37.564214&lon=127.001699&appid=[본인apikey]');
  final response = await http.get(url);
  if (response.statusCode == 200) {
    return WeatherModel.fromJson(json.decode(response.body));
  }
  return null;
}
Widget _wakeUpAlarm() {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 10),
    child: FutureBuilder<WeatherModel?>(
      future: loadWeatherApi(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                '서울 날씨',
                style: TextStyle(fontSize: 40),
              ),
              SizedBox(height: 15),
              Row(
                children: [
                  Image.network(
                    'https://openweathermap.org/img/wn/${snapshot.data!.icon}@2x.png',
                    width: 100,
                    height: 100,
                  ),
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        '${snapshot.data!.temp!.toStringAsFixed(1)}°',
                        style: TextStyle(fontSize: 25, letterSpacing: -1),
                      ),
                      Text(
                        '체감기온 : ${snapshot.data!.feelsLike!.toStringAsFixed(1)}°',
                        style: TextStyle(fontSize: 17, letterSpacing: -1),
                      ),
                    ],
                  ),
                ],
              ),
              Row(
                children: [
                  Text(
                    '풍속 : ${snapshot.data!.speed!.toStringAsFixed(1)}m/s',
                    style: TextStyle(
                      color: Color(0xff8d8d93),
                      fontSize: 20,
                      letterSpacing: -1,
                    ),
                  ),
                  SizedBox(width: 20),
                  Text(
                    '기압 : ${snapshot.data!.pressure!.toStringAsFixed(1)}hPa',
                    style: TextStyle(
                      color: Color(0xff8d8d93),
                      fontSize: 20,
                      letterSpacing: -1,
                    ),
                  ),
                ],
              )
            ],
          );
        }
        return Center(
          child: CircularProgressIndicator(),
        );
      },
    ),
  );
}

0개의 댓글