iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 25
1
Mobile Development

Flutter---Google推出的跨平台框架,Android、iOS一起搞定系列 第 25

【Flutter基礎概念與實作】 Day25–使用Firestore快速建造簡易留言區

今天來增加討論電影的留言版功能,結合Firebase的Firestore除了能大幅減少建置資料庫的時間外,只要Database有新的資料所有使用者的頁面也會被自動更新。

設定Firestore

網路上已經有很多介紹Firestore細節的文章了,在這邊就快速的帶大家設置我們要的資料結構。

  1. 建立資料庫,為了方便這邊就不多設定讀寫權限,如果系統要上線的話一定要記得設定。
  2. 設定Server地點,越近越好。
  3. 新增集合「comments」,之後的每一則留言就是comments集合下的一個一個文件。
  4. 每則留言會紀錄以下資訊:電影id、留言者信箱、留言、上傳時間

設定好Firestore後回到專案中。
如果Day15前半段提到的設定都有做到,那麼就可以直接使用Firestore的功能啦,還沒設定好的就回頭看一下Day15吧。

Firestore Database

在firebase資料夾下新增「firestore_database.dart」。

要取得Firestore資料的程式碼非常簡單,而且這次我們不用再自己寫一個Bloc實作,因為它本身就是「Stream」了,只要使用StreamBuilder去監聽它的資料狀態顯示對應的畫面即可。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:fluttube/home/comment_widget.dart';

StreamBuilder<QuerySnapshot> getComments(String movieId) {
  return StreamBuilder<QuerySnapshot>(
    stream: Firestore.instance
        .collection('comments')
        .where("movie_id", isEqualTo: movieId)
        .snapshots(),
    builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
      if (!snapshot.hasData)
        return Center(
          child: CircularProgressIndicator(),
        );
      final int commentCount = snapshot.data.documents.length;
      snapshot.data.documents
          .sort((a, b) => b.data['time'].compareTo(a.data['time']));
      if (commentCount > 0) {
        return ListView.builder(
          physics: NeverScrollableScrollPhysics(),
          shrinkWrap: true,
          itemCount: commentCount,
          itemBuilder: (_, int index) {
            final DocumentSnapshot document = snapshot.data.documents[index];
            return commentWidget(
              document['user_email'],
              document['content'],
              document['time'],
            );
          },
        );
      } else {
        return Container(
          padding: EdgeInsets.symmetric(vertical: 10.0),
          alignment: Alignment.center,
          child: Text(
            'no comments...',
            style: TextStyle(fontSize: 20),
          ),
        );
      }
    },
  );
}

void createRecord(String movieId, String email, String content) async {
  await Firestore.instance.collection("comments").document().setData({
    'movie_id': movieId,
    'user_email': email,
    'content': content,
    'time': Timestamp.now()
  });
}


Firestore.instance.collection('comments').where("movie_id", isEqualTo: movieId).snapshots()

這一行是從Firestore取資料的程式碼,指定要取得的集合然後用where來過濾資料。


snapshot.data.documents.sort((a, b) => b.data['time'].compareTo(a.data['time']));

這一行是對取回來的資料依照留言時間的先後做排序,越新的留言排在越上面。

Comment Widget

在home資料夾下新增「comment_widget.dart」。

使用timeago套件協助轉換取回來的TimeStamp值,

import 'package:flutter/material.dart';
import 'package:timeago/timeago.dart' as timeago;

Widget commentWidget(String email, String content, var time) {
  return Container(
      padding: EdgeInsets.symmetric(vertical: 5),
      decoration: BoxDecoration(
          border: Border(
              top: BorderSide(
        color: Colors.black,
        width: 3.0,
      ))),
      child: Column(
        children: <Widget>[
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              Text(
                email,
                style: TextStyle(
                    fontSize: 20,
                    color: Colors.lightBlueAccent,
                    fontWeight: FontWeight.bold),
              ),
              Text(
                timeago.format(time.toDate()),
                style: TextStyle(
                  fontSize: 10,
                  color: Colors.grey,
                ),
              ),
            ],
          ),
          SizedBox(
            height: 10.0,
          ),
          Container(
            padding: EdgeInsets.only(left: 10.0),
            alignment: Alignment.topLeft,
            child: Text(
              content,
              style: TextStyle(fontSize: 16),
            ),
          ),
        ],
      ));
}

Movie Detail Page

修改成以下程式碼:

import 'package:flutter/material.dart';
import '../youtube/youtube.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'trailer_widget.dart';
import '../firebase/firestore_database.dart';
import '../firebase/user_repository.dart';

class MovieDetailPage extends StatefulWidget {
  final posterPath;
  final overview;
  final releaseDate;
  final title;
  final voteAverage;
  final movieId;

  MovieDetailPage(
      {Key key,
      this.posterPath,
      this.overview,
      this.releaseDate,
      this.title,
      this.voteAverage,
      this.movieId})
      : super(key: key);
  @override
  _MovieDetailPageState createState() => _MovieDetailPageState();
}

class _MovieDetailPageState extends State<MovieDetailPage> {
  String get posterPath => widget.posterPath;
  String get overview => widget.overview;
  String get releaseDate => widget.releaseDate;
  String get title => widget.title;
  String get voteAverage => widget.voteAverage.toString();
  String get movieId => widget.movieId.toString();

  bool isOverviewSelected = false;
  bool isSubmitEnable = false;
  YoutubeBloc _youtubeBloc;
  YoutubeRepository _youtubeRepository;
  final TextEditingController _inputController = TextEditingController();
  @override
  void initState() {
    _youtubeRepository = YoutubeRepository();
    _youtubeBloc = YoutubeBloc(youtubeRepository: _youtubeRepository);
    _youtubeBloc.dispatch(SearchYoutubeEvent("$title 預告片"));
    _inputController.addListener(_onInputChanged);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
          top: false,
          bottom: false,
          child: CustomScrollView(
            slivers: <Widget>[
              SliverAppBar(
                expandedHeight: 200.0,
                floating: false,
                elevation: 0.0,
                flexibleSpace: FlexibleSpaceBar(
                    background: Image.network(
                  "https://image.tmdb.org/t/p/w500${posterPath}",
                  fit: BoxFit.cover,
                )),
              ),
              SliverList(
                delegate: SliverChildListDelegate([
                  Container(margin: EdgeInsets.only(top: 5.0)),
                  Text(
                    title,
                    style: TextStyle(
                      fontSize: 25.0,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
                  Row(
                    children: <Widget>[
                      Icon(
                        Icons.favorite,
                        color: Colors.red,
                      ),
                      Container(
                        margin: EdgeInsets.only(left: 1.0, right: 1.0),
                      ),
                      Text(
                        voteAverage,
                        style: TextStyle(
                          fontSize: 18.0,
                        ),
                      ),
                      Container(
                        margin: EdgeInsets.only(left: 10.0, right: 50.0),
                      ),
                      Text(
                        "上映日期:${releaseDate}",
                        style: TextStyle(
                          fontSize: 16.0,
                        ),
                      ),
                    ],
                  ),
                  Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
                  isOverviewSelected
                      ? GestureDetector(
                          onTap: () => setState(() {
                            isOverviewSelected = !isOverviewSelected;
                          }),
                          child: Text(overview),
                        )
                      : GestureDetector(
                          onTap: () => setState(() {
                            isOverviewSelected = !isOverviewSelected;
                          }),
                          child: Column(
                            children: <Widget>[
                              ConstrainedBox(
                                constraints: BoxConstraints(maxHeight: 200),
                                child: Text(
                                  overview,
                                  softWrap: true,
                                  overflow: TextOverflow.visible,
                                  maxLines: 2,
                                ),
                              ),
                              Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: <Widget>[
                                  Icon(Icons.arrow_drop_down),
                                  Text('閱讀全文'),
                                ],
                              )
                            ],
                          ),
                        ),
                  Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
                  Text(
                    "Trailer",
                    style: TextStyle(
                      fontSize: 28.0,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
                  Container(
                    child: BlocBuilder(
                      bloc: _youtubeBloc,
                      builder: (context, state) {
                        if (state is YoutubeSuccessState) {
                          return trailerWidget(state.ytResult);
                        }
                        return Center(child: CircularProgressIndicator());
                      },
                    ),
                  ),
                  Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
                  Text(
                    "Comments",
                    style: TextStyle(
                      fontSize: 28.0,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  ListTile(
                    title: TextField(
                      controller: _inputController,
                      decoration: InputDecoration(
                          icon: Icon(Icons.comment), labelText: '留言'),
                    ),
                    trailing: IconButton(
                        onPressed: isSubmitEnable
                            ? () async {
                                String email =
                                    await new UserRepository().getUser();
                                createRecord(
                                    movieId, email, _inputController.text);
                                _inputController.clear();
                              }
                            : null,
                        icon: Icon(Icons.subdirectory_arrow_left)),
                  ),
                  Container(
                    child: getComments(movieId),
                  )
                ]),
              )
            ],
          )),
    );
  }

  void _onInputChanged() {
    if (_inputController.text.isNotEmpty) {
      setState(() {
        isSubmitEnable = true;
      });
    } else {
      setState(() {
        isSubmitEnable = false;
      });
    }
  }
  
  @override
  void dispose() {
    _inputController.dispose();
    super.dispose();
  }
}

和昨天的相比,我們多加了TextField讓使用者可以輸入他的留言,當按下送出後就使用firestore_database裡面的createRecord新增一筆留言資料。

把當前的電影id傳給getComments回傳StreamBuilder widget顯示使用者留言。

最後記得在dispose()把inputController給關閉。

功能展示

今日總結

使用Firebase能大幅度減少開發的時間,當然如果是大型專案還是使用穩固的資料庫比較適合。不過單以今天的留言功能來說,大概半小時~一個小時就能完成了,真的是很方便呢。

不過現在的留言長的實在太醜了,我想加上使用者的大頭貼應該會好看點,所以明後兩天就使用Firebase提供的其他服務來幫助我們達成這項任務吧。

完整程式碼在這裡-> FlutTube Github


上一篇
【Flutter基礎概念與實作】 Day24–設計電影細節頁面、播放Youtube影片
下一篇
【Flutter基礎概念與實作】 Day26–上傳圖片到Firebase Storage
系列文
Flutter---Google推出的跨平台框架,Android、iOS一起搞定30

尚未有邦友留言

立即登入留言