Bygga en fungerande Instagram-klon med Flutter och Firebase

Detta är en översikt över hur Flutter och Firebase användes för att skapa ett fotodelningsprogram.

Å nej, ännu en Instagram-klon?

De flesta kloner jag har stött på är antingen bara UI-utmaningar eller saknar funktioner. Detta projekt är emellertid en mer fullständig Instagram-upplevelse med flöde, kommentarer, berättelser, direktmeddelanden, push-meddelanden, post-radering, användarrapporter, kontoens integritet och mer. Det är också tillgängligt för nedladdning på iOS och Android.

Du kan ladda ner applikationen här.

Jag kommer att fokusera på bara kärnämnen och kommer att hoppa över ämnen som Firebase Auth, Cloud Storage och Firebase Cloud Messaging, eftersom det redan finns flera artiklar och handledning om dem.

Jag kommer inte heller att diskutera de flesta av UI-elementen, utom de som jag tycker är mest utmanande.

I varje avsnitt kommer jag att försöka lyfta fram de viktigaste takeaways.

Projektarkitektur

Det här avsnittet är bara för att ge en kort hög nivå av projektet.

Projektarkitekturen är enkel och består av två huvudmappar: ui och tjänster.

Projektstruktur

Ui-mappen är uppdelad i tre delar: skärmar, widgetar och delade.

Skärmar är widgetar på toppnivå som visar alla UI på en skärm på enheten.

Ibland innehåller skärmar widgets med mycket kod, så att dessa widgets abstraheras till sina respektive filer i widgets-mappen.

Vissa widgetar återanvänds i flera skärmar, till exempel en lastningsindikator eller en anpassad rullningsvy, och dessa widgetar placeras i den delade mappen.

Tjänstmappen innehåller filer som hanterar Firebase-tjänster som Firestore, Cloud Storage och Firebase Auth.

Det innehåller också förvaret, som är ett abstraktionslager för app-användargränssnittet för åtkomst. För varje funktion i någon av servicefilerna som behövs i app-användargränssnittet finns det en funktion i förvaret som refererar till den funktionen.

I varje fil som hör till app-användargränssnittet (skärmar och widgetar) som behöver använda molntjänster importeras endast förvaret.

Widgets vet inte om de andra filerna i servicemappen.

Datamodellering

Modelmappen som innehåller dataobjekten: användare, post, kommentar etc.

Dataobjekten som är avsedda att hämtas från Firestore har vanligtvis en hjälparfabrikskonstruktör som tar en DocumentSnapshot som en parameter:

fabrik Post.fromDoc (DocumentSnapshot doc) {return Post (id: doc.documentID, ..., timestamp: doc ['timestamp'], metadata: doc ['data'] ?? {}, text: doc ['caption') '] ??' ',); }

På så sätt kan vi hämta postföremål från eldstadsdokument som så:

/// Mock-funktion slut QuerySnapshot snap = invänta shared.collection ('posts'). Get ();
slutlig lista posts = snap.docs.map ((doc) => Post.from (doc)). toList ();

Databasdesign

Databasen som används för detta projekt är Firestore. Varför? Därför att…

  1. Firestore är den nyare kusin till realtidsdatabasen och har bättre skalbarhet och datamodellering. Mer info här.
  2. Firebase är en tidig anhängare av Flutter, vilket gör deras plugin tillgängligt på pub.dev från början.
  3. Läser> skriver.

Det huvudsakliga tillvägagångssättet är att strukturera databasen på ett sätt som är lätt att hämta de nödvändiga data. Det största problemet att övervinna var den begränsade frågefunktionen hos Firestore.

Till skillnad från andra databaslösningar kan du inte enkelt fråga något som "hämta senaste inlägg av användare jag följer" för flödesskärmen.

A-ha! ögonblicket var när jag insåg att en skärm bara skulle hämta data från en enda "hink". I detta fall kommer frågan helt enkelt att "hämta dokument från min feed-samling".

I en social media-app som Instagram, läser> skriver. En användare kan läsa hundratals dokument innan han begår en enda skrivning (inlägg, gilla, kommentera, följ / avfölj).

Det är därför det är en bra idé att göra allt hårt arbete inom skrivoperationerna och hålla läsoperationerna enkla.

När en användare laddar upp ett inlägg skrivs inlägget i varje enskild följares flöde. Med andra ord, data dupliceras.

Ja, det här betyder att en användare med miljoner följare kommer att kosta mycket mer än den vanliga användaren. Att skriva till en miljon följares feeds kostar ungefär 1,80 $, men värdet som någon som har samlat som många följare tar med sig till den sociala plattformen är förmodligen mycket större än så.

användare / {userld} / feed / {postID}

På det här sättet kan du enkelt hämta dokument från en enda samling som beställts efter uppladdningsdatumet och paginera vid behov.

Nackdelen med denna databasstruktur är att på grund av dataduplicering krävs extra steg om, låt oss säga, en användare gör ändringar i ett dokument.

Vad händer om användaren ändrar bildtexten för ett inlägg, eller ännu värre, gör ändringar i sin profilbild eller användarnamn? Hur skulle dessa förändringar återspeglas i tidigare inlägg på deras följares flöde? Hur skulle du skriva ett inlägg till en följares foder?

Ange molnfunktioner.

Molnfunktioner

Huvudtanken är att använda en betrodd server för att distribuera kod och undvika att skriva klientkod när du kan.

Hur skulle du skriva till allas flöde när ett nytt inlägg laddas upp?

En användare kan ha tusentals följare och tusentals inlägg. Om användaren gör ändringar i sin profil eller något av sina inlägg, måste vi också sprida sådana ändringar till varje följares flöde.

Denna typ av operation kallas en fan-out operation, där ett dokument dupliceras över flera noder (referenser) i databasen.

Hur man tar fram ett nytt inlägg i följarens flöde:

  1. Skaffa uppläggets uppladdare.
  2. Skapa en dokumentreferens för varje följare med post-id för dokument-id.
  3. Skriv postinformationen till den referensen
  4. Valfritt - skriv det nya inlägget till ditt eget flöde

I alla fan-out operationer är det en bra idé att använda batchskrivningar. Emellertid kan varje parti ha maximalt 500 operationer, så du behöver flera satser för en fan-out-operation som kräver mer än så.

En annan användning av molnfunktion är att uppdatera ett posts liknande räknare.

Ett inläggs liknande räkning kan utsättas för missbruk om det kontrollerades av klientsidan kod. Istället använder vi en molnfunktion som lyssnar på ett dokument som skapas eller raderas i underkollektionen "gillar" och ändrar inläggets liknande räknare i enlighet med FieldValue.increment:

En avgörande användning av molnfunktion är för att skicka pushmeddelanden via firebase cloud messaging (FCM). Denna sendFCM-funktion kallas i varje relevant exporterad funktion (post like, comment like, follow event, comment comment, direct message):

Backend är ryggraden i en app. Du måste planera och strukturera din databas ordentligt tillsammans med appens sökfrågor. Innan du gör din app vacker, få den att fungera.

Låt oss gå vidare till UI-sidan. UI-avsnittet kommer också att innehålla vissa databasdesignsaker som jag inte har behandlat tidigare.

Root PageView och hemsida

Rotwidgeten är en PageView, den första sidan är redigeraren, den andra huvudsidan med alla navigationsflikar och den tredje är direktmeddelandeskärmen.

1. redaktör 2. hem 3. direktmeddelanden

Du kan växla mellan sidor genom att svepa eller trycka på de översta navigeringsknapparna. Ställ in startsidan på sidvisningen till 1, hemsidan.

Om du vill härma navigeringsbeteendet som ses på Instagrams iOS-app, bör du använda ett CupertinoTabScaffold och en CupertinoTabView för varje flik på hemsidan. Varje flikvy hanterar sin egen navigationsstack, vilket är viktigt om du vill bläddra i flera flikar åt gången.

Men jag stötte på ett konstigt fel med fokus på textfältet på redigeringsskärmen när jag använde CupertinoTabView, så jag använde en anpassad navigationslösning av Andrea Bizzotto, som blev av med buggen.

För att visa navigeringsstacken till den nedersta rutten på hemsidan måste du skapa en global navigatörsnyckel för varje flikvy:

Karta > _navigatorKeys = {TabItem.feed: GlobalKey (), TabItem.search: GlobalKey (), TabItem.create: GlobalKey (), TabItem.activity: GlobalKey (), TabItem.profile: GlobalKey (),};

Tilldela flikvy med en navigatörsknapp. Du måste göra detta även om du använder CupertinoTabView.

/// Fliken Hem (flöde)
Navigator (nyckel: _navigatorKeys [TabItem.feed], ...)
/// Sökflik
Navigator (nyckel: _navigatorKeys [TabItem.search], ...)

Du använder sedan BottomNavigationBars onTap (index) återuppringning för att välja vilken stack du vill pop:

/// Se till att fliken du trycker på är den aktuella fliken om (tab == aktuellTab)
/// Pop tills första _navigatorKeys [tab] .currentState .popUntil ((route) => route.isFirst);

Vill du ha en skärm för att bläddra till toppen? Du måste skapa en bläddringskontroller för varje flik:

final feedScrollController = ScrollController (); .... final profileScrollController = ScrollController ();

Tilldela huvudvisningswidgeten till en bläddringskontroll (ignorera initialRoute och onGenerateRoute om du använder CupertinoTabView):

/// Min profil skärmnavigator (nyckel: _navigatorKeys [TabItem.profile], initialRoute: '/', onGenerateRoute: (routeSettings) {return MaterialPageRoute (byggare: (context) => MyProfileScreen (scrollController: profileScrollController,),); ,),

I BottomNavigationBars onTap (index) återuppringning kan du välja vilken controller du vill bläddra till toppen:

if (tab == currentTab) {
switch (tab) {case TabItem.home: controller = feedScrollController; ha sönder; ... fall TabItem.profile: controller = profileScrollController; ha sönder; } /// Bläddra till toppen om (controller.hasClients) controller.animateTo (0, varaktighet: scrollDuration, curve: scrollCurve); }

Utfodra

Huvudwidgeten är widgeten för postlistobjektet:

  1. Rubrik
  2. Photo PageView
  3. Förlovningsfältet (gilla, kommentera och dela knappar)
  4. Bildtext (visas inte)
  5. Som räknaren
  6. Räknefält för kommentarer
  7. Topp kommentarer
  8. Tidsstämpel

Om du undrar: Knappen som ser ut som en bubbelpool låter dig klottera på en post. Du kan se vad andra människor har ritat.

Uppgifterna för rubrik, fotosidesvy, bildtext och tidsstämpel kan hämtas direkt från postdokumentet som finns i flödessamlingen: användare / {userId} / feed / {postId}.

Förlovningsfältet är svårt på grund av liknande knapp. Den liknande knappen ändras i färg beroende på om du gillade inlägget eller inte.

Skapa först en funktion som returnerar en ström av en DocumentSnapshot med egenskapen firestore snapshots ():

/// Kontrollera om den aktuella användaren gillade ett inlägg /// Returnerar en ström, så att didLike = snapshot.data.exists ///auth.uid hänvisar till den aktuella inloggade användarens användar-ID
Ström myPostLikeStream (Post post) {final ref = postRef (post.id) .collection ('gillar'). document (autor.uid); return ref.snapshots (); }

Använd den här strömmen i en StreamBuilder för att visa ett reaktivt användargränssnitt som korrekt återspeglar om liknande knappen har tryckts in eller inte:

StreamBuilder (ström: Repo.myPostLikeStream (inlägg), byggare: (context, snapshot) {if (! snapshot.hasData) returnera SizedBox ();
      /// Om dokumentet finns, har inlägg gillats av den nuvarande /// inloggade användaren
final didLike = snapshot.data.exists; return LikeButton (onTap: () {return didLike? Repo.unlikePost (post): Repo.likePost (post);},
        /// Knappens utseende
icon: Likade du? FontAwesome.heart: FontAwesome.heart_o, färg: DidGilla? Colors.red: Colors.black,); }),

Det fina med detta är att även om du tittar på samma inlägg över flera skärmar eller enheter, reflekteras tillståndet för alla liknande knappar på flera skärmar korrekt.

Som en extra bonus på grund av Firestores offlinefunktioner kommer liknande knappen fortfarande att reagera på användarpress även om du är offline!

Du kan tillämpa samma princip på liknande widgetar som knappen Följ / avföljning som finns på profilsidan.

Ibland finns det delar av användargränssnittet som visas olika beroende på användaren som tittar på det. I dessa fall kan du försöka använda en StreamBuilder för en reaktiv upplevelse.

Om användargränssnittet huvudsakligen är statiskt och är detsamma för alla som tittar på det (som bildtexter eller foton), kan du bara hämta data på vanligt sätt.

Inläggstatistiken (som räkning, kommentarantal) lagras någon annanstans och det måste hämtas separat. Denna postsamling existerar som en separat rotnivåsamling och är inte en underkollektion av användarsamlingen.

Framtida getPostStats (String postId) async {final ref = shared.collection ('posts'). dokument (postId); slutdokument = vänta ref.get (); tillbaka! doc.exists? PostStats.empty (postId): PostStats.fromDoc (doc); }

Varför inte lagra statistiken på samma plats där postdokumentet finns?

Eftersom statistiken är mycket benägna att ändras och därför inte bör dupliceras.

Kan du tänka dig att uppdatera varje följares flöde varje gång ett inlägg gillar eller någon kommenterar?

För de översta kommentarerna måste du fråga efter kommentarerna i inläggets kommentarer underkollektion:

Framtida > getPostTopComments (String postId, {int limit}) async {final ref = shared .collection ('posts') .document (postId) .collection ('comments') .orderBy ('like_count', descending: true) .limit ( gräns ?? 2); slutlig snäpp = vänta ref.getDocuments (); return snap.documents.map ((doc) => Kommentar.fromDoc (doc)). toList (); }

Ja, du kan försöka lagra de översta kommentarerna som en matris på samma postdokument, men det kommer att kräva att du skriver en komplex molnfunktion som lyssnar på kommentarer som skapas eller raderas, samt lyssnar på förändringar i deras liknande antal och slutligen uppdatera / sortera inläggets toppkommentarer efter behov. Ovanpå detta måste du också uppdatera varje följares foder.

Detta innebär att flera läsningar krävs för att hämta ett enda inlägg. Det här är helt bra med tanke på alternativet att lägga all data i ett dokument och att behöva uppdatera potentiellt tusentals dokument för varje enskild liknande, kommentar eller förändring. Kom ihåg att inte alla dina följare kommer att läsa / hämta ditt nya inlägg, men varje fan-out-operation måste spridas till varenda en av dina följare.

I strävan efter att optimera läsoperationerna kan du istället finna att du gör saker mycket svårare och dyrare än det borde vara.

Du bör tänka på postwidgeten som inte en enda widget, men flera widgetar kombinerade, var och en med data från olika källor eller "hinkar". Detta hjälper till med datamodellering eftersom den använder flera dataobjekt istället för ett enda alltför uppblåst postobjekt.

Statlig förvaltning

Jag har försökt använda BloC- och leverantörspaket för att upprätthålla applikationstillstånd. Jag fann dock att det är enklare att använda en StreamBuilder för detta projekt, särskilt med tanke på att firestore redan innehåller strömmar av DocumentSnapshot (enstaka dokument) och QuerySnapshot (flera dokument).

I vissa fall tyckte jag att det var användbart att använda en EventBus, särskilt när du behöver visa toastmeddelanden eller uppdatera användargränssnittet efter en framgångsrik uppladdning eller radering av inlägg.

I de flesta widgetar där du bara behöver ladda data en gång och inte lyssna på dokumentändringar kan du bara använda setState ().

Ta till exempel widgeten som visar en annan användares inlägg på sin profilsida. I sin initState (), ring en funktion som hämtar inlägg:

@override initState () {_getPosts ();
super.initState (); }

UI uppdaterar och visar automatiskt inlägg när du ringer setState ():

_getPosts () async {setState (() {isLoadingPosts = true;});
  slutligt PostCursor-resultat = vänta på Repo.getPostsForUser (uid: uid, limit: 8,);
if (monterad) setState (() {isLoadingPosts = falsk; inlägg = resultat.poster; startAfter = resultat.startAfter;}); }

PostCursor-objektet är en hjälparklass som används för pagination, som jag kommer tillbaka till senare. Den innehåller helt enkelt en lista över inläggslista och en DocumentSnapshot av det senaste dokumentet som hämtades.

Variabeln isLoadingPosts är bara en flagga för att berätta UI när en laddningsindikator ska visas.

Detta mönster för att hämta data i initState () och sedan uppdatera användargränssnittet med hämtad data finns i många andra skärmar.

Det är alltid en bra praxis att kontrollera om (monterad) egenskap innan du ringer setState (), och om du inte vill ringa om (monterad) flera gånger, helt enkelt åsidosätter din StatefulWidgets setState ():

@ override void setState (fn) {ifall (monterad) super.setState (fn); }

Ibland räcker det inte att ringa setState (). Ett relaterat exempel skulle vara i den aktuella inloggade användarens profilskärm, där vi inte bara behöver hämta inlägg, utan också uppdatera användargränssnittet efter det att ett inlägg laddas upp.

Vi kan använda aStreamBuilder för detta, men det är svårt att paginera med en StreamBuilder. Vill du inte paginera dina data? Vad händer om den nuvarande inloggade användaren har tusentals inlägg? Varje gång profilskärmen laddas laddas alla inlägg samtidigt via strömmen. Detta är både kostsamt ur ett fakturerings- och bandbreddsperspektiv.

Lösningen? Använd en kombination av en ström och setState ();

Liksom i föregående exempel hämtar vi först några inlägg efter lastning. Använd dessutom en ström för att lyssna på alla nya inlägg i databasen och lägga till inlägget i UI.

Skapa strömmen och bifoga en lyssnare till den i initState (); om ett nytt inlägg laddas upp ska du uppdatera användargränssnittet:

final postStream = Repo.myPostStream ();
Lista inlägg = [];
@override void initState () {_getPosts (); postStream.listen ((data) {data.documents.forEach ((doc) {if (initialPostsLoaded) {final post = Post.fromDoc (doc); if (post == null) return; setState (() {posts = [ post] + inlägg;});}});}); eventBus.on () .listen ((event) {setState (() {posts = Lista .från (inlägg) ..removeWhere ((p) => p.id == event.postId); }); }); super.initState (); }

Strömmen lyssnar bara på det senaste dokumentet i postsamlingen:

Ström myPostStream () {final ref = userRef (autor.uid) .collection ('posts') .orderBy ('timestamp', fallande: true) .limit (1); return ref.snapshots (); }

För radering av inlägg väljer jag att använda en Event Bus-lyssnare. När en radering av inlägget är klar söker användargränssnittet efter ett inlägg med ID och tar bort det från vyn.

Det finns många sätt att hantera staten. Det finns några populära statliga hanteringslösningar som jag inte har provat, som RxDart. Välj den som gör vad du vill utan att vara alltför komplicerad. Använd inte BloC för en enkel räknarapp - oftast kommer setState () att göra susen. Men tänk också på om lösningen du väljer fortfarande är hanterbar i framtiden.

Paginering

Jag skapade en enkel hjälparklass för att hjälpa till med inlägg relaterade paginationer.

klass PostCursor {slutlig lista inlägg; slutlig DocumentSnapshot startAfter; slutlig DocumentSnapshot endAt; PostCursor (this.posts, this.startAfter, this.endAt); }

Vi kan använda den här klassen i våra servicefiler så:

Framtida getFeed ({DocumentSnapshot startAfter}) async {final uid = Auth.prof.uid; slutfråga = startAfter == null? userRef (uid) .collection ('feed') .orderBy ('create_at', descending: true) .limit (8): userRef (uid) .collection ('feed') .orderBy ('create_at', descending: true) .limit (14) .startAfterDocument (startAfter); slutdokument = väntar på fråga.getDocuments (); sista inlägg = docs.documents.map ((doc) => Post.fromDoc (doc)). toList (); returnera docs.documents.isNo Tom? PostCursor (inlägg, docs.documents.last, docs.documents.first): PostCursor (inlägg, startAfter, null); }

På så sätt behöver skärmar bara hantera ett enda dataobjekt som innehåller all nödvändig data för att visa widgetar och för att paginera. Minimera affärslogiken i dina widgetar.

För skärmar som har ett drag för att uppdatera såväl som en mängd fler funktioner skapade jag en anpassad rullningsvy som reagerar på en överskrullning. Du kan använda andra bibliotek för att uppnå samma resultat.

Det viktigaste är att använda en CustomListView och förklara dess slivers.

Jag använde en CupertinoSliverRefreshControl för en iOS-look och känsla för att uppdatera funktionen.

Placera din ListView eller en widget som utökar ScrollView i SliverToBoxAdapter.

Slutligen, placera en lastningsindikator i en annan SliverToBoxAdapter och visa den endast när skärmen laddar mer data.

Eftersom standard ClampingScrollPhysics () -beteendet på Android inte är det vi vill ha, måste vi ange BouncingScrollPhysics () så att onRefresh och onLoadMore återuppringningar kan ringas från överscrollen.

Nackdelen med att använda detta är att varje gång du placerar en ListView inuti CustomScrollView, måste du ställa in shrinkWrap: true, vilket har minskat prestanda. Ställ också in ListViews fysik på NeverScrollablePhysics () om du inte vill att den ska rulla oberoende av sin överordnade.

Direktmeddelandeskärm

En chattskärm i sig skulle räcka om det enda sättet att komma åt den är genom att trycka på meddelandeknappen på en användarprofil. Men precis som på Instagram vill vi ha en DM-skärm (Direct Messaging) som visar alla aktiva chattar.

DM-dataskinkan innehåller inte de faktiska meddelandena, men underhåller dokument som innehåller användare som du har pratat med tidigare.

Dessutom har varje dokument flera fält som ger viss viktig information.

Det sista kontrollerade fältet innehåller det sista meddelandet som skickades i konversationen.

Sista_seen_timestampen hänvisar till den senaste gången användaren öppnade konversationen.

Eftersom DM-skärmen måste reagera på nya meddelanden använder vi en ström och en StreamBuilder för att mata in data i en ListView. Vi sorterar också data med hjälp av recensen för den senaste_checked_timestampen, så att de senaste konversationerna visas högst upp.

StreamBuilder (stream: Repo.DMStream (), builder: (context, snapshot) {if (! snapshot.hasData) {returnera LoadingIndicator ();} annars {final docs = snapshot.data.documents ..sort ((a, b) {final Timestamp aTime = a.data ['last_checked_timestamp']; final Timestamp bTime = b.data ['last_checked_timestamp']; return bTime.millisecondsSinceEpoch .compareTo (aTime.millisecondsSinceEpoch);});
return docs.is Tom? EmptyIndicator ('Inga konversationer att visa'): ListView.builder (krympaWrap: true, fysik: NeverScrollableScrollPhysics (), itemBuilder: (context, index) {final doc = docs [index]; ...

För att markera en konversation med olästa meddelanden i vår ListView, kontrollerar vi om den senaste_checked_timestampen är större än vår last_seen_timestamp.

final Timestamp lastCheckedTimestamp = doc ['last_checked_timestamp'];
final Timestamp lastSeenTimestamp = doc ['last_seen_timestamp'];
///auth.uid avser aktuellt inloggad användar-ID
final hasUnread = (lastSeenTimestamp == null) // Om det inte finns någon last_seen_timestamp, måste det vara en ny konversation? lastCheckedSenderId! = autor.uid // om jag inte skickade meddelandet, kolla om jag har sett det senaste // kontrollerade meddelandet: (lastSeenTimestamp.seconds 

Last_seen_timestampen uppdateras varje gång chattskärmen öppnas och det kommer ett nytt meddelande från den andra användaren.

Nu har vi all nödvändig information för att visa våra konversationer, sorterade efter recency och olästa meddelanden:

Olästa konversationer är markerade med en blå indikator med fet text

Slutligen använde jag paketet flutter_slidable för att låta användare ta bort konversationer genom att skjuta listvyen.

Fältet is_persisted indikerar om konversationen har tagits bort från användarens DM-skärm. Strömmen som driver DM-skärmen hämtar dokument som har det fältet satt till true.

Ström DMStream () {return userRef (auth.uid) .collection ('chats'). Where ('is_persisted', isEqualTo: true) .snapshots (); }

När en användare tar bort en konversation ställs fältet is_persisted in på falskt och ett nytt fält end_at läggs till, med dess värde inställt på Timestamp.now ().

Framtida deleteChatWithUser (String userId) async {final selfId = autor.uid;
final selfRef = userRef (selfId) .collection ('chats'). dokument (userId);
slutlig nyttolast = {'is_persisted': false, 'end_at': Timestamp.now (),};
return selfRef.setData (nyttolast, sammanfoga: sant); }

Det nya fältet tillåter oss att bara ladda meddelanden upp till ett visst datum när användaren beslutar att öppna konversationen igen efter att den har raderats i slutet.

Observera att de faktiska meddelandena inte raderas, istället tas bara chattsessionen bort från användarens DM-skärm, och användaren kommer aldrig att se meddelanden tidigare än raderingsdatumet. Detta efterliknar beteendet som finns på Instagram.

Chattskärm

När en användare knackar på ett objekt på DM-skärmen riktas de till chattskärmen, där de faktiska meddelandena visas.

Det första steget innan du laddar meddelandena är att få chatt-id. Chatt-id är en kombination av två användar-ID. Detta är så att vi enkelt kan hänvisa till ett samtal utan att behöva göra en ytterligare fråga. I initState () tar vi båda användar-ID, sorterar dem och går med en hypen:

chatId = (uid.hashCode <= peerId.hashCode)? '$ uid- $ peerId': '$ peerId- $ uid';

Vi kontrollerar sedan om konversationen tidigare har raderats genom att kontrollera om ett end_at-värde finns. När vi väl har kommit både chatId och end_at kan vi äntligen hämta meddelandena:

Framtida getInitialMessages () async {final end = invänta Repo.chatEndAtForUser (peer.uid);
final initialMessages = väntar på Repo.getMessages (chatId: chatId, endAt: end); endAt = slut; if (initialMessages.isNotEmpty) {startAt = initialMessages.last.timestamp; } setState (() {messages.addAll (initialMessages); initialMessagedFinishedLoading = true;}); lämna tillbaka; }

Detta är getMessages-funktionen - till skillnad från inlägg, paginerar vi Firestore-frågan med tidstämpel istället för DocumentSnapshot:

Framtida > getMessages (String chatId, Timestamp endAt, {Timestamp startAt}) async {final ref = shared.collection ('chats'). document (chatId); slutgräns = 20; Frågefråga; fråga = endAt == null? fråga = ref .collection ('meddelanden') .orderBy ('tidsstämpel', fallande: sant) .limit (limit): startAt == null? ref .collection ('meddelanden') .orderBy ('timestamp', fallande: true) .endAt ([endAt]). limit (limit): ref .collection ('meddelanden') .orderBy ('timestamp', descending: true ) .startAfter ([startAt]). endAt ([endAt]). limit (limit); final snap = vänta på fråga.getDocuments (); return snap.documents.map ((doc) => ChatItem.fromDoc (doc)). toList (); }

Chattsamlingen ligger på rotnivå och innehåller underkollektionen för meddelanden som innehåller alla meddelanden och deras innehåll:

Dataskinka för chattskärm

När vi har fått meddelandena kan vi äntligen visa dem på chattskärmen:

Varje element du ser i chatten ListView drivs av ett ChatItem-dataobjekt som hämtats från Firestore. Vi använder ett switch-uttalande för att konvertera ChatItem till dess lämpliga UI-element baserat på dess typ, som vi använder för att fylla iListView. Detta inkluderar den vita peer-textbubblan, den blå användartekstbubblan, tidsstämpeln och indikatorn för att skriva.

ChatListView är unik eftersom den laddar meddelanden från botten och paginerar / laddar fler meddelanden när användaren bläddrar uppåt. I huvudsak är ListView inverterad. Lyckligtvis har ListView en handig egenskap omvänd som vi satt till true för att tillgodose detta beteende.

Vi måste hitta rätt plats att lägga till peer-avatar och tidsstämplar. I ListViews artikelBuilder:

itemBuilder: (context, index) {final message = meddelanden [index]; final isPeer = message.senderId! = autor.uid; /// Kom ihåg: listvy är omvänd slutlig isLast = index <1;
final lastMessageIsMine = isLast &&! isPeer; final nextBubbleIsMine = (! isLast && meddelanden [index - 1] .senderId == autor.uid); final showPeerAvatar = (isLast && message.senderId == peer.uid) || nextBubbleIsMine; /// Visa meddelandedatum om föregående meddelande är /// skickat för mer än en timme sedan final ärFirst = index == meddelanden.längd - 1; final currentMessage = meddelanden [index]; slutlig föregående meddelande = är först? null: meddelanden [index + 1];
/// Visa tidsstämpel bool showDate; if (previousMessage == null) {showDate = true; } annat om (currentMessage .timestamp == null || previousMessage.timestamp == null) {showDate = true; } annat {showDate = previousMessage .timestamp.seconds 

När du har bestämt showPeerAvatar och showDate booleska egenskaper kan du injicera dem i vilken widget du använder för att fylla ListView.

Vill du visa om en användare skriver? Först lyssnar vi på en TextEditingController som är kopplad till TextField-widgeten. Ring denna funktion i vår initState:

/// Aktuell inloggad användares uid String uid = FirestoreService.ath.uid;
/// Lyssna på om peer skriver listenToTypingEvent () {textController.addListener (() {if (textController.text.isNotEmpty) {/// Använd en flagga för att se till att inte ringa detta flera gånger
if (! selfIsTyping) {print ('skriver'); Repo.isTypning (chatId, uid, true); } selfIsTyping = true; } annat {selfIsTyping = falsk; print ('skriver inte!'); Repo.isTypning (chatId, uid, false); }}); }

Glöm inte att sluta visa typaktivitet när chattskärmen är stängd. Vi åsidosätter metoden dispose ():

@override void dispose () {print ('dispose chat screen'); Repo.isTypning (chatId, uid, false); super.dispose (); }

Funktionen isTyping skriver till chattens is_typing subcollection - vi behöver inte ställa in några data för det, bara se till att dokumentet skapas eller raderas med användar-ID som dokument-id:

isTyping (String chatId, String uid, bool isTyping) {final ref = shared.collection ('chats'). document (chatId) .collection ('is_typing'); return isTyping? ref.document (uid) .setData ({}): ref.document (uid) .delete (); }

Detta gör att vi kan skapa en ström som lyssnar till vilka av chattdeltagarna som skriver:

Ström isTypingStream (String chatId) {return shared .collection ('chats') .document (chatId) .collection ('is_typing') .snapshots (); }

På vår chattskärm lyssnar vi på den här strömmen som infogar eller tar bort en skrivindikator:

_isTypingStream = Repo.isTypingStream (chatId); _isTypingStream.listen ((data) {ifall (! monterad) return; final uids = data.documents.map ((doc) => doc.documentID) .toList (); print (uids); if (uids.concepts (peer) .uid)) {/// Se till att endast en typindikator är synlig om (! peerIsTyping) {peerIsTyping = true; setState (() {meddelanden.insert (0, ChatItem (typ: Bubbles.isTyping,),);} );}} annars {print ('bör ta bort peer skriver'); peerIsTyping = falsk; _removeMessageOfType (Bubbles.isTyping);}});

När en användare laddar upp ett meddelande görs tre skrivningar - till chattdatabunten, användarens DM-dataskinka och peerens DM-dataskinka:

Framtida uploadMessage ({String content, User peer,}) async {final timestamp = Timestamp.now ();
  final messageRef = shared.collection ('chats'). document (chatId) .collection ('meddelanden') .document ();
final selfRef = userRef (authentic.uid) .collection ('chattar'). document (peer.uid); final peerRef = userRef (peer.uid) .collection ('chats'). document (autor.uid); slutlig selfMap = authentic.user.toMap (); final peerMap = peer.toMap (); slutlig nyttolast = {'avsändare_id': authent.uid, 'tidsstämpel': tidsstämpel, 'innehåll': innehåll, 'typ': 'text',}; slutbatch = delat.batch (); /// Chattmeddelande ref batch.setData (messageRef, nyttolast); /// Min DM-chatt ref batch.setData (selfRef, {'is_persisted': true, 'last_checked': nyttolast, 'last_checked_timestamp': timestamp, 'user': peerMap,}, merge: true); /// Peer DM chat ref batch.setData (peerRef, {'is_persisted': true, 'last_checked': nyttolast, 'last_checked_timestamp': timestamp, 'user': selfMap,}, merge: true); return batch.commit (); }

Beroende på hur du ställer in dina säkerhetsregler, kan du behöva göra det tredje skrivet med en molnfunktion. Det finns faktiskt en extra fördel med dessa skrivare - användardata i DM-dataskopan uppdateras automatiskt varje gång ett meddelande skickas. Därför kanske vi inte behöver skriva en fan-out molnoperation som uppdaterar användardata varje gång någon profil ändras.

Historier

Berättelser är förmodligen den mest utmanande funktionen att implementera. Förutom det riktigt komplexa användargränssnittet behöver vi också ett sätt att hålla reda på berättelserna som en användare har sett. Dessutom finns det flera viktiga skillnader mellan inlägg och stunder, som jag kommer att diskutera senare.

Det finns tre huvudwidgets i spelet:

Inline Stories - det här är widgeten du ser ovanför fönsterskärmen. Dess huvudsakliga syfte är att visa om de användare du följer har laddat upp minst en berättelse under det senaste dygnet.

Berättelser skrivs till en användares feed på liknande sätt med hjälp av en fan-out-metod. När en användare laddar upp en berättelse, aktiveras en molnfunktion och skriver den senaste historiedata till varje följares berättelse:

/// Detta utlöser en fan-out molnfunktion Framtida uploadStory ({@required DocumentReference storyRef, @required String url}) async {return waitit storyRef.setData ({'timestamp': Timestamp.now (), 'url': url , 'uploader': autor.user.toMap (),}); }

Uploadarens användar-ID används som dokument-id. Detta innebär att samlingen bara kan ha ett dokument som gäller en uppladdare.

Berättelser matar datahink

Dokumentet innehåller tidsstämpeln för uppladdarens senaste historia som vi använder för att fråga data för widgeten.

Framtida > getStoriesOfFollowings () async {final now = Timestamp.now (); final TodayInSeconds = now.seconds; slutlig idagInNanoSekunder = nu.nanosekunder; /// för 24 timmar sedan sedan final cutOff = Timestamp (todayInSeconds - 86400, todayInNanoSeconds); slutfråga = myProfileRef .collection ('story_feed'). Where ('timestamp', isG GreaterThanOrEqualTo: cutOff); final snap = vänta på fråga.getDocuments (); return snap.documents.map ((doc) => UserStory.fromDoc (doc)). toList (); }

Vi använder getDocuments () utan att införa en gräns för att hämta alla användare som nyligen har publicerat en berättelse. Detta är den första stora skillnaden - till skillnad från inlägg, paginerar vi inte hämtningen av berättelser.

Det är tillrådligt att sätta en hård gräns för antalet användare vi kan följa, som på Instagram, för att förhindra missbruk.

Nu när vi har de senaste användarhistorierna måste vi ange vilka som innehåller berättelser som användaren inte har sett tidigare genom att använda en ring runt uppladdarens avatar. Om du inte vill använda en lutning för denna indikatorring kan du helt enkelt använda en stack med en CircularProgressIndicator med en något större radie än avataren:

CircularProgressIndicator (valueColor: AlwaysStoppedAnimation (din färg),),

Du kommer att behöva upprätthålla en ström som de användarhistorier du har sett under det senaste dygnet:

Ström seenStoriesStream () => myProfileRef .collection ('seen_stories') .document ('list') .snapshots ();

Denna ström upprätthålls som ett enda dokument, där varje fält motsvarar en uppladdare och värdet är tidsstämpeln för den senaste historien för uppladdaren som användaren har sett:

Observera att varje firestore-dokument har en storlek på 1 MB. Men för att nå den gränsen måste en användare ha sett historier från tiotusentals olika användare på en enda dag, vilket är extremt osannolikt. Det beror på att varje gång vi uppdaterar dokumentet gör vi lite vårrengöring för att ta bort data som är mer än en dag gammal.

Framtida uppdateringSeenStories (Karta data) async {final now = Timestamp.now (); final TodayInSeconds = now.seconds; slutlig idagInNanoSekunder = nu.nanosekunder; /// för 24 timmar sedan sedan final cutOff = Timestamp (todayInSeconds - 86400, todayInNanoSeconds); final ref = myProfileRef.collection ('seen_stories'). dokument ('lista'); slutdokument = vänta ref.get (); slutberättelser = doc.data ?? {}; /// Ta bort gamla datahistorier. Ta bort var ((k, v) => v.sekunder 

Vi använder strömmen för att få StoryState för en viss UserStory där staten inte kan ses, ses eller ses. Injicera tillståndet i en StoryAvatar-widget för att den ska visa en färgad / gradientring om den inte syns, eller visa en tunn grå cirkel om den ses.

/// ListView inuti widgeten Inline Stories har en horisontell rullningsriktning
returnera StreamBuilder (stream: Repo.seenStoriesStream (), byggare: (context, snapshot) {if (! snapshot.hasData) returnerar LoadingIndicator (); final seenStories = snapshot.data.data ?? {}; return ListView.builder (...
rulla riktning: Axis.horizontal, itemCount: widget.userStories? .length ?? 0, itemBuilder: (context, index) {final userStory = widget.userStories [index]; final Timestamp seenStoryTimestamp = seenStories [userStory.uploader.uid]; final storyState = userStory.lastTimestamp == null? StoryState.none: seenStoryTimestamp == null? StoryState.unseen: seenStoryTimestamp.seconds 

Denna updateSeenStories-funktion kallas inom nästa widget som vi kommer att gå vidare till.

Story PageView - Detta är widgeten som öppnas när en användare tappar på en användare i widgeten Inline Stories. Huvuddelen av berättelserna är inom denna widget.

En annan stor skillnad är att berättelser laddas bara när de behövs. Till skillnad från inlägg som alltid är synliga, visas berättelser endast när användaren öppnar en användares berättelse eller sveper till en annan användares berättelse. Detta är beteendet som ses på Instagram, där en framstegsindikator visas innan historier laddas.

Historiens sidvy visas modellt, som på Instagram. Vi kallar metoden showModalBottomSheet och ställer in följande fält för att fylla i hela skärmen:

isScrollControlled: true, useRootNavigator: true,

På så sätt kan vi avvisa StoryPageView genom att svepa ner tack vare det inbyggda beteendet som finns i BottomSheet.

Men jag fann att oavsett vad jag gör så skulle det inte respektera SafeArea. Därför var jag tvungen att manuellt specificera en toppfyllning så att berättarhuvudet inte överlappar systemets UI-överlägg.

Filen innehåller dessa metoder för att navigera genom PageView:

previousPage () och nextPage () gör det möjligt för PageView'sPageController att växla mellan sidor med animering. Vi ställde in kontrollerns startsida i initState () baserat på vilken användaravatar som tryckts på i InlineStories-widgeten.

_pop () ringas när användaren trycker på avbrytningsknappen i det övre högra hörnet, eller automatiskt när den sista historien på den sista sidan i PageView är klar. Den påkallar helt enkelt Navigator.of (context) .pop (), som stänger bottenarket.

Innan vi avfärdar StoryPageView måste vi dock uppdatera vårt seen_stories firestore-dokument om det fanns nya berättelser som användaren såg. Vi åstadkommer detta genom att ringa Repo.updateSeenStories (karta data) i metoden dispose (). Vi får nödvändiga data för att ladda upp från onMomentChanged (int) återuppringning av StoryView.

Varje sida i PageView är en StoryView, widgeten som ansvarar för att visa berättelserna, hålla reda på nya berättelser som användaren har sett, bestämma vilken berättelse som ska spelas först och berätta när han ska byta till en annan användares berättelser.

StoryView själv är en Stack-widget som innehåller en annan PageView som går igenom berättelsebilderna med hjälp av en MomentView.

En stackwidget används för att lägga över användarhuvudet, statusindikatoren och osynliga gestdetektorer ovanpå PageView.

Framstegsindikatorfältet spårar den aktuella historien och visar hur många historier som varje användare har laddat upp. Varje bit kallas ett ögonblick och den innehåller medias url, uppladdningsdatum och visningstid.

Varje StoryView innehåller en animeringskontroller som hanterar automatisk uppspelning av berättelser. Vi ställer in kontrollerns varaktighet på varaktigheten för det aktuella ögonblicket i initState och återupptar det i _play ().

controller = AnimationController (vsync: detta, varaktighet: story.moments [momentIndex]. duration,) .. addStatusListener ((status) {if (status == AnimationStatus.completed) {switchToNextOrFinish ();}}); _spela();

Funktionen _play återupptar animeringskontrollern endast om bilden för ögonblicket har laddats:

/// Återupptar animeringskontrollern /// under förutsättning att det aktuella ögonblicket har laddats tomrum _play () {if (story == null || widget.story == null) return; om (story.moments.isTom) återvänder; /// om momentIndex inte är inom räckvidd (på grund av radering) om (momentIndex> story.moments.length - 1) återvänder; if (story.moments [momentIndex] .isLoaded) controller.forward (); }

Den översta widgeten i bunten är en widget som upptäcker användarbester som vi använder för att pausa, återuppta och byta berättelser:

Positionerad (övre: 120, vänster: 0, höger: 0, barn: GestureDetector (beteende: HitTestBehavior.opaque, onTapDown: onTapDown, onTapUp: onTapUp, onLongPress: onLongPress, onLongPressUp: onLongPressEnd,),)

onTapDown stoppar animeringskontrollern, medan onTapUp beslutar om att gå till nästa eller föregående historia baserat på horisontell position för användargesten. onLongPress stoppar också animeringskontrollern medan du förvandlar StoryView till ett fullskärmsläge genom att dölja överläggningarna (framstegsfält, användarhuvud, etc.). Överlagringarna är inslagna i en AnimatedOpacity-widget som lyssnar till värdet på isInFullscreenMode. onLongPressEnd återgår isInFullscreenMode tillbaka till falsk och återupptar animeringskontrollern:

/// Ställer in förhållandet mellan vänster och höger tappbara delar /// på skärmen: vänster för att växla tillbaka, höger för att växla fram sista dubbelmomentSwitcherFraktion = 0,26;
onTapDown (TapDownDetails detaljer) {controller.stop (); } onTapUp (TapUpDetails-detaljer) {slutlig bredd = MediaQuery.of (context) .storlek.width; if (details.localPosition.dx  isInFullscreenMode = true); } onLongPressEnd () {print ('onlongpress end'); setState (() => isInFullscreenMode = falsk); _spela(); }

I switchToNextOrFinish kontrollerar vi om det aktuella ögonblicket är det sista ögonblicket. Om det är så kallar vi widget.onFlashForward som utlöser StoryPageView för att ladda en helt ny StoryView som innehåller berättelsen om nästa användare. Om det inte är sista ögonblicket ber vi StoryViews sidkontroller att hoppa till nästa bild. Vi återställer också animeringskontrollern och återupptar den när nästa bild laddas. switchToPrevOrFinish följer samma idé.

switchToNextOrFinish () {controller.stop (); if (momentIndex + 1> = story.moments.length) {widget.onFlashForward (); } annat {controller.reset (); setState (() {momentIndex + = 1; _momentPageController.jumpToPage (momentIndex);}); controller.duration = story.moments [momentIndex]. duration; _spela(); widget.onMomentChanged (momentIndex); }} switchToPrevOrFinish () {controller.stop (); if (momentIndex - 1 <0) {widget.isFirstStory? onReset (): widget.onFlashBack (); } annat {controller.reset (); setState (() {momentIndex - = 1; _momentPageController.jumpToPage (momentIndex);}); controller.duration = story.moments [momentIndex]. duration; _spela(); widget.onMomentChanged (momentIndex); }}

Jag använde flutter_instagram_stories-paketet som utgångspunkt och omarbetade det kraftigt för min app. Hela berättelserna är en PageView (StoryView) inom en PageView (StoryPageView) inom en ListView (Inline Stories).

Det finns många andra saker som jag inte har pratat om, som aktivitetsskärmen, redigeringsskärmen, utforska skärmen, kommentarskärmen, kontoens integritet, nämner, regex, använder ett unikt användarnamn för autentisering och mer.

Jag vill också påpeka att Firebase inte är en perfekt lösning om du vill bygga en komplett Instagram-upplevelse. Detta går tillbaka till de begränsade frågefunktionerna i Firestore, och jag har ännu inte kommit fram till superkomplexa frågor som "hämta konton som liknar konton du följer".