Boehrsi.de - Blog

Flutter App Development - Teil 6 - RSS Entries und mehr

Erstellt am event Uhr von account_circle Boehrsi in label Development
Flutter App Development - Teil 6 - RSS Entries und mehr Bild

Heute geht es weiter mit den eigentlichen Einträgen eines RSS Feeds. Die dazugehörigen Dateien befinden sich im lib/entry_list/ open_in_new Package und sind verglichen mit der lib/feed_list/ open_in_new Logik etwas einfacher zu handhaben. Dieser Beitrag ist der Abschluss meiner kleinen Tutorialreihe und den gesamten Source-Code findet ihr auf Github. Links zu den gennannten Dingen findet ihr in den Related Links. Mit diesen kurzen Worten der Einleitung möchte ich heute direkt mit dem User Interface starten.

lib/entry_list/entry_list.dart (Code auf GitHub open_in_new)

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:fss/types/rss_entry.dart';
import 'package:fss/types/rss_feed.dart';
import 'package:url_launcher/url_launcher.dart';

import '../constants/dimensions.dart';
import '../constants/strings_user_visible.dart';
import '../feed_list/feed_list_barrel.dart';
import '../feed_list/feed_list_change.dart';
import '../widgets/state_info.dart';
import 'entry_list_barrel.dart';

class EntryList extends StatefulWidget {
  final int feedId;

  const EntryList({Key key, @required this.feedId}) : super(key: key);

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

class _EntryListState extends State<EntryList> {
  EntryListBloc _bloc;
  Completer<void> _refreshCompleter;

  @override
  void initState() {
    super.initState();
    _bloc = EntryListBloc(feedListBloc: BlocProvider.of<FeedListBloc>(context));
    _bloc.add(RequestEntryList(feedId: widget.feedId));
  }

  @override
  Widget build(BuildContext context) {
    final parentContext = context;
    return BlocBuilder<FeedListBloc, FeedListState>(builder: (BuildContext context, state) {
      if (state is FeedListLoaded || state is FeedListUpdated) {
        final feed = _getFeed(state);
        if (feed != null) {
          return Scaffold(
            appBar: AppBar(
              title: Text(feed.name),
              actions: <Widget>[
                IconButton(
                  icon: Icon(Icons.edit),
                  onPressed: () {
                    Navigator.push(
                      context,
                      MaterialPageRoute<Type>(
                        builder: (context) {
                          return MultiBlocProvider(
                            providers: [
                              BlocProvider.value(value: BlocProvider.of<FeedListBloc>(parentContext)),
                              BlocProvider.value(value: _bloc),
                            ],
                            child: FeedListChange(feed: feed),
                          );
                        },
                      ),
                    ).then((result) {
                      if (result != null) {
                        if (result == Type.delete || result == Type.editId) {
                          Navigator.pop(context, result);
                        }
                      }
                    });
                  },
                )
              ],
            ),
            body: BlocConsumer(
              bloc: _bloc,
              listener: (BuildContext context, state) {
                if (_refreshCompleter != null && !_refreshCompleter.isCompleted) {
                  _refreshCompleter.complete();
                }
              },
              builder: (BuildContext context, state) {
                if (state is EntryListLoaded) {
                  return RefreshIndicator(
                    onRefresh: () {
                      _refreshCompleter = Completer();
                      _bloc.add(UpdateEntryList(feedId: widget.feedId));
                      return _refreshCompleter.future;
                    },
                    child: ListView.builder(
                        itemCount: state.entryList.length,
                        itemBuilder: (context, index) {
                          final entry = state.entryList[index];
                          return EntryListItem(entry: entry);
                        }),
                  );
                } else if (state is EntryListFailed) {
                  return ErrorState(error: state.error);
                } else {
                  return LoadingState();
                }
              },
            ),
          );
        } else {
          return Container();
        }
      } else {
        return ErrorState(error: kErrorFeedLoading);
      }
    });
  }

  RssFeed _getFeed(FeedListState state) {
    final feedList = state is FeedListLoaded ? state.feedList : state is FeedListUpdated ? state.feedList : null;
    return feedList.firstWhere((feed) => feed.id == widget.feedId, orElse: () => null);
  }
}

class EntryListItem extends StatelessWidget {
  const EntryListItem({Key key, @required this.entry}) : super(key: key);

  final RssEntry entry;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Padding(
          padding: const EdgeInsets.only(bottom: kDefault8dp),
          child: Text(entry.title, style: Theme.of(context).textTheme.headline6),
        ),
        subtitle: Html(
          data: entry.text,
          onLinkTap: (url) => launch(url),
        ),
        onTap: () => launch(entry.url),
      ),
    );
  }
}

In dieser Datei findet ihr zwei Widgets (EntryList und EntryListItem). Ersteres kümmert sich um das Anzeigen der eigentlichen Liste. Zusätzlich wird die AppBar open_in_new erstellt, welche die Möglichkeit zum Bearbeiten des jeweiligen Feeds beinhaltet. Damit der Bearbeiten-Screen die nötigen Daten erhält wird über einen MultiBlocProvider open_in_new alles Relevante weitergereicht.
Außerdem setze ich auch hier wieder auf einen Completer open_in_new, sodass Pull-To-Refresh möglich ist. Mittels eines BlocConsumers open_in_new kann ich nun sowohl einmalige State Änderungen verarbeiten (listener Parameter), wie auch das User Interface bei Bedarf aktualisieren (builder Parameter). Letzterer wird mit einer ListView open_in_new ausgestattet, welche für jedes Element ein EntryListItem erzeugt.

Das EntryListItem erhält einen RssEntry als Parameter. Selbiger wird dabei aus dem EntryListLoaded State im EntryList extrahiert und direkt an das EntryListItem übergeben. Da kein State verwaltet werden muss handelt es sich bei diesem Widget um ein StatelessWidget open_in_new. Es zeigt lediglich die gegebenen Daten (Titel und Text) an und erlaubt durch einen Tap auf den Eintrag zum eigentlichen Beitrag zu navigieren. Die launch Methode wird durch das url_launcher open_in_new Paket zur Verfügung gestellt und öffnet den Standard-Browser des Geräts mit der gewählten URL.
Neben dem folgenden EntryListBloc gibt es wie gewohnt eine Barrel Datei open_in_new und eine Event / States Datei open_in_new. Da selbige trivial sind verlinke ich sie nur, erläutere sie allerdings nicht weiter.

lib/entry_list/entry_list_bloc.dart (Code auf GitHub open_in_new)

import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

import '../constants/strings_user_visible.dart';
import '../database/database_wrapper.dart';
import '../feed_list/feed_list_barrel.dart';
import '../types/rss_entry.dart';
import '../utils/rss.dart';
import 'entry_list_events_states.dart';

class EntryListBloc extends Bloc<EntryListEvent, EntryListState> with RssReader {
  final databaseWrapper = DatabaseWrapper();
  final FeedListBloc feedListBloc;

  EntryListBloc({@required this.feedListBloc});

  @override
  EntryListState get initialState => LoadingEntryList();

  @override
  Stream<EntryListState> mapEventToState(EntryListEvent event) async* {
    if (event is RequestEntryList) {
      yield* loadEntryList(event.feedId);
    } else if (event is UpdateEntryList) {
      yield* updateEntryList(event.feedId);
    }
  }

  Stream<EntryListState> loadEntryList(int feedId) async* {
    try {
      final entryList = await databaseWrapper.getAllEntriesAsync(feedId);
      yield EntryListLoaded(entryList: entryList.reversed.toList());
    } catch (exception) {
      yield EntryListFailed(error: exception.toString());
    }
  }

  Stream<EntryListState> updateEntryList(int feedId) async* {
    try {
      final feed = await databaseWrapper.getFeedAsync(feedId);
      final response = await http.get(feed.url);
      if (response.statusCode == 200) {
        final entries = await getRssEntryList(response.bodyBytes);
        await databaseWrapper.setMultipleAsync(entries, RssEntry, feedId);
        feedListBloc.add(SetFeed(newFeed: feed.withUpdatedTime()));
        yield* loadEntryList(feedId);
      } else {
        throw Exception(kErrorFeedLoading);
      }
    } catch (exception) {
      yield EntryListFailed(error: exception.toString());
    }
  }
}

Im BloC spielt sich wie gewohnt die eigentliche Logik ab. Selbige ist allerdings eher simpel aufgebaut. Zwei Events und drei mögliche States wollen miteinander verbunden werden.
Wenn eine App-Komponente Interesse an einer Liste von RssEntry Elementen hat wird das RequestEntryList mit einer Id für den gewünschten Feed ausgestattet und dann ausgelöst. Der BloC versucht dann die zugehörigen Elemente in der loadEntryList() Methode zu laden. Dabei werden alle lokal bekannten Einträge über den DatabaseWrapper open_in_new geladen und sortiert zurückgegeben. Sofern dies erfolgreich ist wird der EntryListLoaded State gesetzt, andernfalls der EntryListFailed State. Sofern die Liste noch nicht geladen oder in einem Fehlerzustand ist, befindet sie sich im initialen LoadingEntryList Zustand.
Sollte sich ein aktuell angezeigter Feed geändert haben, wird das UpdateEntryList Event, ebenfalls mit einer Feed Id ausgestattet, gesendet. Hier wird der Feed über die updateEntryList() aktualisiert und löst abermals einen EntryListLoaded oder EntryListFailed State aus. Innerhalb der updateEntryList() Methode wird für den aktuellen Feed mittels des HTTP Packages die neuste Liste von RSS Feeds geladen. Die erhaltene XML Datei wird durch die Helper Methode getRssEntryList() (siehe unten) verarbeitet und folgenden in die Datenbank geschrieben. Sobald die Daten aktuell sind wird die loadEntryList() Methode aufgerufen, welche dem User Interface die neuen Informationen mitteilt.

lib/utils/rss.dart (Code auf GitHub open_in_new)

import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:webfeed/webfeed.dart' as webfeed;

import '../constants/strings_user_visible.dart';
import '../types/rss_entry.dart';

final _formatRss = DateFormat('EEE, d MMM yyyy HH:mm:ss Z');
final _typeRss = '<rss version="2.0"';

mixin RssReader {
  Future<List<RssEntry>> getRssEntryList(Uint8List xmlBytes) async {
    final xmlString = utf8.decode(xmlBytes);
    return await compute(_parseFeed, xmlString);
  }
}

List<RssEntry> _parseFeed(String responseBody) {
  final entries = <RssEntry>[];
  var parsedFeed;
  if (responseBody.contains(_typeRss)) {
    try {
      parsedFeed = webfeed.RssFeed.parse(responseBody);
    } catch (_) {}
  } else {
    try {
      parsedFeed ??= webfeed.AtomFeed.parse(responseBody);
    } catch (_) {}
  }
  if (parsedFeed == null) {
    throw Exception(kErrorFeedParse);
  }
  parsedFeed.items.forEach((item) {
    String url;
    DateTime date;
    String content;
    if (item is webfeed.RssItem) {
      url = item.link;
      date = _formatRss.parse(item.pubDate);
      content = item.description;
    } else if (item is webfeed.AtomItem) {
      url = item.links.first.href;
      date = DateTime.parse(item.published);
      content = item.content ?? item.summary;
    }

    entries.add(
      RssEntry(
        id: url.hashCode,
        url: url,
        title: item.title,
        date: date.millisecondsSinceEpoch,
        text: content,
      ),
    );
  });
  return entries;
}

Die getRssEntryList() Methode ist dabei sehr kurz aber relevant. An sich ruft sie lediglich die _parseFeed() Methode auf, welche den Feed dann verarbeitet und die nötigen Daten aus dem XML Feed in Objekte übersetzt. Doch die Art und Weise wie die Methode aufgerufen wird ist interessant. Denn compute() open_in_new erlaubt es uns die Berechnung auf ein anderes Dart Isolate open_in_new auszulagern und sobald die Berechnung abgeschlossen ist, erhalten wir das Ergebnis. Auf diese Art können wir potentiell Zeitaufwendige Dinge nicht nur vom User Interface lösen, Dinge lassen sich so auch parallelisieren. In diesem Kontext kann ich bei Interesse die Dokumentation zum Thema nur empfehlen.
Das Keyword mixin sorgt hier übrigens dafür, dass wir diese Klasse überall wo benötigt einfach mit dem Keyword with in die jeweiligen Klassen einbinden können (siehe Dokumentation open_in_new).

Mit diesen Worten ist meine kleine Flutter Development Serie vorerst beendet, denn es wurde fast alles Relevante erklärt. Vorerst nutze ich an dieser Stelle, da es durchaus sein kann das es noch einen Nachtrag gibt, entweder weil ich noch erklärenswerte Dinge finde oder weil ich Fragen von euch da draußen ausführlicher beantworten will. Solltet ihr Fragen haben, meldet euch gerne in den Kommentaren.

Related Links
Kommentare  
Kommentar erstellen
Mit dem Abschicken des Kommentars erklären sie sich mit der in der Datenschutzerklärung dargelegten Datenerhebung für Kommentare einverstanden. Spam, unangebrachte Werbung und andere unerwünschte Inhalte werden entfernt. Das Abonnieren via E-Mail ist nur für E-Mail Adressen erlaubt die Sie rechtmäßig administrieren. Widerrechtliche Abonnements werden entfernt.