코딩하는 제리

[Flutter/Project](Instagram Clone) follow한 유저의 포스트를 피드에 출력하기 본문

Flutter/Project_InstaClone(완)

[Flutter/Project](Instagram Clone) follow한 유저의 포스트를 피드에 출력하기

JerryCho 2021. 3. 4. 17:11

test3으로 로그인하여 test1, test2 계정을 follow
test2 계정 unfollow


pub.dev/packages/rxdart

 

rxdart | Dart Package

RxDart is an implementation of the popular reactiveX api for asynchronous programming, leveraging the native Dart Streams api.

pub.dev

repo/post_network_repository.dart -> with Transformers 추가
repo/post_network_repository.dart
repo/helper/transformers.dart
screens/feed_screen.dart -> StreamProvider.value(child: Consumer(return Scaffold())) 추가
screens/feed_screen.dart -> ListView.builder 수정
home_page.dart -> FeedScreen을 불러오기 전 null check한 후 followings 리스트를 받음.
widget/post.dart -> _lastComment 위젯 추가
widget/post.dart -> 포스트 헤더 텍스트 설정
widget/post.dart -> 포스트 imageUrl 설정
widget/post.dart -> 포스트 likes 설정
widget/post.dart -> 포스트 caption 설정


소스코드 및 pubspec.yaml

rxdart: ^0.24.1 추가

// repo/post_network_repository.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_project_IJ/constants/firestore_keys.dart';
import 'package:flutter_project_IJ/models/firestore/post_model.dart';
import 'package:flutter_project_IJ/repo/helper/transformers.dart';
import 'package:rxdart/rxdart.dart';

/*
포스트 데이터 업로드 과정
1. post reference를 가져옴(생성)
2. 어떤 포스트가 해당 유저에 포함되어 있는지 user reference를 가져옴
3. post collection과 user collection 두가지를 업데이트 해야하기에 transaction 사용.
4. user data에 postKey를 업데이트
5. post reference를 통해 post data를 업로드
*/

class PostNetworkRepository with Transformers {
  Future<void> createNewPost(
      String postKey, Map<String, dynamic> postData) async {
    // 1. post reference를 가져옴(생성)
    final DocumentReference postRef =
        FirebaseFirestore.instance.collection(COLLECTION_POSTS).doc(postKey);
    // post reference를 통해 해당 document의 snapshot을 가져옴.
    final DocumentSnapshot postSnapshot = await postRef.get();
    // 2. 어떤 포스트가 해당 유저에 포함되어 있는지 user reference를 가져옴
    final DocumentReference userRef = FirebaseFirestore.instance
        .collection(COLLECTION_USERS)
        .doc(postData[KEY_USERKEY]);

    // 3. transaction -> 데이터 업로드에 하나라도 실패하면 이전의 데이터로 바꿔줌.
    return FirebaseFirestore.instance.runTransaction((Transaction tx) async {
      // 해당 postSnapshot이 존재하지 않으면
      if (!postSnapshot.exists) {
        // 4. 포스트 데이터 업로드
        tx.set(postRef, postData);
        // 5. user의 mypost를 업데이트
        // user reference에 postKey 추가
        tx.update(userRef, {
          KEY_MYPOSTS: FieldValue.arrayUnion([postKey])
        });
      }
    });
  }

  // postLink를 가져와서 저장
  Future<void> updatePostImageUrl({String postImg, String postKey}) async {
    // 1. post reference를 가져옴(생성)
    final DocumentReference postRef =
        FirebaseFirestore.instance.collection(COLLECTION_POSTS).doc(postKey);

    // post reference를 통해 해당 document의 snapshot을 가져옴.
    final DocumentSnapshot postSnapshot = await postRef.get();

    // postSnapshot이 존재하면 업데이트
    if (postSnapshot.exists) {
      // KEY_POSTIMG에 받아온 postImg를 저장.
      await postRef.update({KEY_POSTIMG: postImg});
    }
  }

  // follow한 유저의 포스트 가져오기
  Stream<void> getPostsFromSpecificUser(String userKey) {
    // 파라미터로 받아온 userKey와 KEY_USERKEY와 일치하는 포스트만 가져옴.
    return FirebaseFirestore.instance
        .collection(COLLECTION_POSTS)
        .where(KEY_USERKEY, isEqualTo: userKey)
        .snapshots()
        .transform(toPosts);
  }

  /* RxDart - CombineLatestStream
  * 1. Get collection reference
  * 2. Create stream for every following users
  * 3. Put all the post stream into the list
  * 4. using RxDart, combine all the stream
  * */
  Stream<List<PostModel>> fetchPostsFromAllFollowers(List<dynamic> followers) {
    // 1. COLLECTION_POSTS reference를 가져옴.
    final CollectionReference collectionReference =
        FirebaseFirestore.instance.collection(COLLECTION_POSTS);
    // 2. stream들을 저장하는 List 생성
    List<Stream<List<PostModel>>> streams = [];

    // 3. List 변수 streams에 하나씩 넣음
    // where() -> 조건과 일치하는 document만 가져옴
    // followers 리스트를 받아와 변수 follower에 하나씩 넣음.
    // userKey와 follower가 일치하는 document를 가져옴.
    for (final follower in followers) {
      streams.add(collectionReference
          .where(KEY_USERKEY, isEqualTo: follower)
          .snapshots()
          .transform(toPosts));
    }
    /*
    * 4. Cmobine은 stream을 9개까지 받아올 수 있음
    * list<List<PostModel>> -> List<List<PostModel>>로 받아옴.
    * transform(combineListOfPosts)를 거쳐 List<PostModel>로 변경 후
    * 최근 peed가 가장 상단에 와야하기에 transform(latestToTop)을 이용해 정렬함.
    * */
    return CombineLatestStream.list<List<PostModel>>(streams)
        .transform(combineListOfPosts)
        .transform(latestToTop);
  }
}

PostNetworkRepository postNetworkRepository = PostNetworkRepository();
// repo/helper/transformers.dart

import 'dart:async';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_project_IJ/models/firestore/post_model.dart';
import 'package:flutter_project_IJ/models/firestore/user_model.dart';

class Transformers {
  // DocumentSnapshot을 UserModel로 바꿈.
  // fromHandlers -> 생성자
  final toUser = StreamTransformer<DocumentSnapshot, UserModel>.fromHandlers(
      handleData: (snapshot, sink) async {
    // 변환된 snapshot을 sink에 넣어서 데이터를 보냄.
    sink.add(UserModel.fromSnapshot(snapshot));
  });

  // QuerySnapshot을 List<UserModel>로 바꿈.
  // fromHandlers -> 생성자
  final toUsersExceptMe =
      StreamTransformer<QuerySnapshot, List<UserModel>>.fromHandlers(
          handleData: (snapshot, sink) async {
    // QuerySnapshot이 도착할때마다 유저 리스트를 만들어 sink로 내보냄.

    // 유저들을 담을 리스트 생성
    List<UserModel> users = [];

    User _user = FirebaseAuth.instance.currentUser;

    snapshot.docs.forEach((documentSnapshot) {
      if (_user.uid != documentSnapshot.id)
        // 접속한 유저와 다른 유저만 리스트에 추가
        // 해당 snapshot을 UserModel로 변경하여 List에 추가
        users.add(UserModel.fromSnapshot(documentSnapshot));
    });

    // 변환된 snapshot을 sink에 넣어서 데이터를 보냄.
    sink.add(users);
  });

  // QuerySnapshot을 List<PostModel>로 바꿈.
  // fromHandlers -> 생성자
  final toPosts =
      StreamTransformer<QuerySnapshot, List<PostModel>>.fromHandlers(
          handleData: (snapshot, sink) async {
    // 포스터를 담을 리스트 생성
    List<PostModel> posts = [];

    snapshot.docs.forEach((documentSnapshot) {
      // 해당 snapshot을 PostModel 변경하여 List에 추가
      posts.add(PostModel.fromSnapshot(documentSnapshot));
    });
    // 변환된 snapshot을 sink에 넣어서 데이터를 보냄.
    sink.add(posts);
  });

  // List<List<PostModel>>을 List<PostModel>로 바꿈.
  final combineListOfPosts =
      StreamTransformer<List<List<PostModel>>, List<PostModel>>.fromHandlers(
          handleData: (listOfPosts, sink) async {
    // 포스터를 담을 리스트 생성
    List<PostModel> posts = [];

    // 모든 포스트를 꺼내서 하나의 리스트에 넣어줌
    for (final postList in listOfPosts) {
      posts.addAll(postList);
    }

    sink.add(posts);
  });

  final latestToTop =
      StreamTransformer<List<PostModel>, List<PostModel>>.fromHandlers(
          handleData: (posts, sink) async {
    // a와 b를 비교해서 큰 값을 위로 올림.
    posts.sort((a, b) => b.postTime.compareTo(a.postTime));

    sink.add(posts);
  });
}
// widget/post.dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_project_IJ/constants/common_size.dart';
import 'package:flutter_project_IJ/constants/screen_size.dart';
import 'package:flutter_project_IJ/models/firestore/post_model.dart';
import 'package:flutter_project_IJ/repo/image_network_repository.dart';
import 'package:flutter_project_IJ/widgets/comment.dart';
import 'package:flutter_project_IJ/widgets/rounded_avatar.dart';
import 'my_progress_indicator.dart';

class Post extends StatelessWidget {
  final PostModel postModel;

  Post(
    this.postModel, {
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _postHeader(),
        _postImage(),
        _postActionsButton(),
        _postLikes(),
        _postCaption(),
        _lastComment(),
      ],
    );
  }

  Padding _postCaption() {
    return Padding(
      padding: const EdgeInsets.symmetric(
        horizontal: common_gap, // 가로 양끝
        vertical: common_xxs_gap, // 세로 양끝.
      ),
      child: Comment(
        showImage: false,
        username: postModel.username,
        text: postModel.caption,
      ),
    );
  }

  Padding _lastComment() {
    return Padding(
      padding: const EdgeInsets.symmetric(
        horizontal: common_gap, // 가로 양끝
        vertical: common_xxs_gap, // 세로 양끝.
      ),
      child: Comment(
        showImage: false,
        username: postModel.lastCommentor,
        text: postModel.lastComment,
      ),
    );
  }

  Padding _postLikes() {
    return Padding(
      padding: const EdgeInsets.only(left: common_gap),
      child: Text(
        '${postModel.numOfLikes == null ? 0 : postModel.numOfLikes.length} likes',
        style: TextStyle(fontWeight: FontWeight.bold),
      ),
    );
  }

  Row _postActionsButton() {
    return Row(
      children: <Widget>[
        IconButton(
          icon: ImageIcon(AssetImage('assets/images/bookmark.png')),
          onPressed: null,
          color: Colors.black87,
        ),
        IconButton(
          icon: ImageIcon(AssetImage('assets/images/comment.png')),
          onPressed: null,
          color: Colors.black87,
        ),
        IconButton(
          icon: ImageIcon(AssetImage('assets/images/direct_message.png')),
          onPressed: null,
          color: Colors.black87,
        ),
        Spacer(), // 위젯을 제외한 나머지 여백을 모두 차지
        IconButton(
          icon: ImageIcon(AssetImage('assets/images/heart.png')),
          onPressed: null,
          color: Colors.black87,
        ),
      ],
    );
  }

  Widget _postHeader() {
    return Row(
      children: [
        Padding(
          padding: const EdgeInsets.all(common_xxs_gap),
          child: RoundedAvatar(),
        ),
        Expanded(
          // 다른 위젯을 제외한 나머지 여백을 모두 차지
          child: Text(postModel.username),
        ),
        IconButton(
          icon: Icon(
            Icons.more_horiz,
            color: Colors.black87,
          ),
          onPressed: null,
        ),
      ],
    );
  }

  Widget _postImage() {
    Widget progress = MyProgressIndicator(containerSize: size.width);

    return CachedNetworkImage(
      // CachedNetworkImage() 받아온 이미지를 캐시파일로 저장해 재사용.
      imageUrl: postModel.postImg,
      placeholder: (BuildContext context, String url) {
        return progress;
      },
      imageBuilder: (BuildContext context, ImageProvider imageProvider) {
        return AspectRatio(
          aspectRatio: 1 / 1 /* 1대 1의 비율로 이미지 생성 */,
          child: Container(
            decoration: BoxDecoration(
              image: DecorationImage(
                image: imageProvider /* CachedNetworkImage 의 imageUrl */,
                fit: BoxFit.cover,
              ),
            ),
          ),
        );
      },
    );
  }
}
// screens/feed_screen.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_project_IJ/models/firestore/post_model.dart';
import 'package:flutter_project_IJ/repo/post_network_repository.dart';
import 'package:flutter_project_IJ/repo/user_network_repository.dart';
import 'package:flutter_project_IJ/widgets/my_progress_indicator.dart';
import 'package:flutter_project_IJ/widgets/post.dart';
import 'package:provider/provider.dart';

class FeedScreen extends StatelessWidget {
  // FeedScreen 생성할 때 변수 생성
  final List<dynamic> followings;

  const FeedScreen(this.followings, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StreamProvider<List<PostModel>>.value(
      value: postNetworkRepository.fetchPostsFromAllFollowers(followings),
      child: Consumer<List<PostModel>>(
        builder: (BuildContext context, List<PostModel> posts, Widget child) {
          if (posts == null || posts.isEmpty)
            return MyProgressIndicator();
          else {
            return Scaffold(
              /*
          안드로이드 + 아이폰에 똑같은 appBar를 적용하기 위해서
          CupertinoNavigationBar 사용
          */
              appBar: CupertinoNavigationBar(
                // leading: 네비게이션 바의 좌측 시작부분
                leading: IconButton(
                  onPressed: null,
                  icon: Icon(
                    Icons.camera_alt,
                    color: Colors.black87,
                  ),
                ),
                // middle: 네비게이션 바의 중간부분
                middle: Text(
                  'jerrystagram',
                  style: TextStyle(
                      fontFamily: 'VeganStyle', color: Colors.black87),
                ),
                // trailing: 네비게이션 바의 우측 끝부분
                trailing: Row(
                  /*
              Row() 가로 배치. 왼쪽부터 하나씩 쌓임.
              Row()는 디폴트로 그 자리를 전부 차지하게 되어있다.
              */
                  // mainAxisSize: Row의 사이즈를 최소한으로 작게함.
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    IconButton(
                      onPressed: () {},
                      icon: ImageIcon(
                        AssetImage('assets/images/actionbar_camera.png'),
                        color: Colors.black87,
                      ),
                    ),
                    IconButton(
                      onPressed: () {
                        userNetworkRepository
                            .getAllUsersWithoutMe()
                            .listen((users) {
                          print(users);
                        });
                      },
                      icon: ImageIcon(
                        AssetImage('assets/images/direct_message.png'),
                        color: Colors.black87,
                      ),
                    ),
                  ],
                ),
              ),
              body: ListView.builder(
                itemBuilder: (context, index) =>
                    feedListBuilder(context, posts[index]),
                itemCount: posts.length,
              ),
            );
          }
        },
      ),
    );
  }

  Widget feedListBuilder(BuildContext context, PostModel postModel) {
    // PostModel을 받아와서 Post에 던져줌.
    return Post(postModel);
  }
}
// home_page.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_project_IJ/models/user_model_state.dart';
import 'package:flutter_project_IJ/screens/camera_screen.dart';
import 'package:flutter_project_IJ/screens/feed_screen.dart';
import 'package:flutter_project_IJ/screens/profile_screen.dart';
import 'package:flutter_project_IJ/screens/search_screen.dart';
import 'package:flutter_project_IJ/widgets/my_progress_indicator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';

import 'constants/screen_size.dart';

class HomePage extends StatefulWidget {
  HomePage({
    Key key,
  }) : super(key: key);

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<BottomNavigationBarItem> btmNavItems = [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: ""),
    BottomNavigationBarItem(icon: Icon(Icons.search), label: ""),
    BottomNavigationBarItem(icon: Icon(Icons.add), label: ""),
    BottomNavigationBarItem(icon: Icon(Icons.healing), label: ""),
    BottomNavigationBarItem(icon: Icon(Icons.account_circle), label: ""),
  ];

  // SnackBar 사용을 위한 인스턴스. Scaffold의 상태가 필요하다.
  GlobalKey<ScaffoldState> _key = GlobalKey<ScaffoldState>();

  // 바텀네비게이션 바의 인덱스(위치)
  int _selectedIndex = 0;

  // _selectedIndex 값에 따른 화면 구현

  static List<Widget> _screens = <Widget>[
    Consumer<UserModelState>(
      builder:
          (BuildContext context, UserModelState userModelState, Widget child) {
        if (userModelState == null ||
            userModelState.userModel == null ||
            userModelState.userModel.followings == null ||
            userModelState.userModel.followings.isEmpty)
          return MyProgressIndicator();
        else
          return FeedScreen(userModelState.userModel.followings);
      },
    ),
    SearchScreen(),
    Container(
      color: Colors.cyanAccent,
    ),
    Container(
      color: Colors.deepOrangeAccent,
    ),
    ProfileScreen(),
  ];



  @override
  Widget build(BuildContext context) {
    // 해당 디바이스의 화면 사이즈를 가져옴
    if (size == null) size = MediaQuery.of(context).size;
    return Scaffold(
      key: _key,
      /*
      body: _screen[_selectedIndex],
      IndexedStack과 똑같이 구현되지만
      _screen의 화면이 계속 생성된다. 메모리 관리에 매우 취약
      IndexedStack 사용이 올바르다
      */
      body: IndexedStack(
        index: _selectedIndex,
        children: _screens,
      ),
      bottomNavigationBar: BottomNavigationBar(
        unselectedItemColor: Colors.grey,
        selectedItemColor: Colors.black,
        showSelectedLabels: false /* 레이블 비활성화 */,
        items: btmNavItems /* List<BottomNavigationBarItem> 리스트 타입의 데이터 */,
        currentIndex: _selectedIndex,
        onTap: _onBtmItemClick,
      ),
    );
  }

  void _onBtmItemClick(int index) {
    switch (index) {
      case 2:
        _openCamera();
        break;
      default:
        setState(() {
          // onTap으로 상태가 변경될 때 마다 setState로 상태를 새로 불러온다.
          _selectedIndex = index;
        });
    }
  }

  void _openCamera() async {
    // Future가 도착할 때 까지 기다림
    if (await checkedIfPermissionGranted(context))
      Navigator.of(context)
          .push(MaterialPageRoute(builder: (context) => CameraScreen()));
    else {
      SnackBar snackBar = SnackBar(
        content: Text('카메라, 마이크 접근 허용해야 사용할 수 있습니다.'),
        action: SnackBarAction(
          label: '설정',
          onPressed: () {
            _key.currentState.hideCurrentSnackBar() /*버튼 누르면 스낵바 숨김*/;
            openAppSettings() /* 설정창으로 이동 */;
          },
        ),
      );
      // 스낵바 호출
      _key.currentState.showSnackBar(snackBar);
    }
  }

  // 유저 퍼미션
  Future<bool> checkedIfPermissionGranted(BuildContext context) async {
    Map<Permission, PermissionStatus> statuses = await [
      Permission.camera,
      Permission.microphone,
      // ios/android 확인 후 퍼미션 적용.
      Platform.isIOS ? Permission.photos : Permission.storage
    ].request();
    bool permitted = true;

    statuses.forEach((permission, permissionStatus) {
      // 하나라도 허락되지 않으면 permitted를 false로 바꿈.
      if (!permissionStatus.isGranted) permitted = false;
    });
    return permitted;
  }
}
Comments