~cytrogen/fluent-reader-mobile

06e740abdfab1698e03bc0d4ad921b2c5a7161af — 刘浩远 5 years ago b621440
add inoreader support
M lib/components/favicon.dart => lib/components/favicon.dart +1 -1
@@ 25,7 25,7 @@ class Favicon extends StatelessWidget {
      return Container(
        width: size,
        height: size,
        color: CupertinoColors.systemGrey2,
        color: CupertinoColors.systemGrey.resolveFrom(context),
        child: Center(child: Text(
          source.name.length > 0 ? source.name[0] : "?",
          style: _textStyle,

M lib/l10n/intl_en.arb => lib/l10n/intl_en.arb +2 -1
@@ 78,5 78,6 @@
    "logOut": "Log out",
    "logOutWarning": "All local data will be deleted. Are you sure?",
    "confirm": "Confirm",
    "allLoaded": "All loaded"
    "allLoaded": "All loaded",
    "removeAd": "Remove Ad"
  }
\ No newline at end of file

M lib/l10n/intl_zh.arb => lib/l10n/intl_zh.arb +2 -1
@@ 78,5 78,6 @@
    "logOut": "登出",
    "logOutWarning": "这将移除所有本地数据,是否继续?",
    "confirm": "确定",
    "allLoaded": "已全部加载"
    "allLoaded": "已全部加载",
    "removeAd": "移除广告"
  }
\ No newline at end of file

M lib/main.dart => lib/main.dart +3 -1
@@ 9,6 9,7 @@ import 'package:fluent_reader_lite/pages/settings/general_page.dart';
import 'package:fluent_reader_lite/pages/settings/reading_page.dart';
import 'package:fluent_reader_lite/pages/settings/services/feedbin_page.dart';
import 'package:fluent_reader_lite/pages/settings/services/fever_page.dart';
import 'package:fluent_reader_lite/pages/settings/services/inoreader_page.dart';
import 'package:fluent_reader_lite/pages/settings/source_edit_page.dart';
import 'package:fluent_reader_lite/pages/settings/sources_page.dart';
import 'package:fluent_reader_lite/pages/settings_page.dart';


@@ 60,6 61,7 @@ class MyApp extends StatelessWidget {
    "/settings/about": (context) => AboutPage(),
    "/settings/service/fever": (context) => FeverPage(),
    "/settings/service/feedbin": (context) => FeedbinPage(),
    "/settings/service/inoreader": (context) => InoreaderPage(),
    "/settings/service": (context) {
      var serviceType = SyncService.values[Store.sp.getInt(StoreKeys.SYNC_SERVICE) ?? 0];
      switch (serviceType) {


@@ 73,7 75,7 @@ class MyApp extends StatelessWidget {
          // TODO: Handle this case.
          break;
        case SyncService.Inoreader:
          // TODO: Handle this case.
          return InoreaderPage();
          break;
      }
      return AboutPage();

M lib/models/items_model.dart => lib/models/items_model.dart +1 -1
@@ 26,7 26,7 @@ class ItemsModel with ChangeNotifier {
        item.hasRead = read;
        if (!local) {
          if (read) Global.service.markRead(item);
          else Global.service.markUnead(item);
          else Global.service.markUnread(item);
        }
        Global.sourcesModel.updateUnreadCount(item.source, read ? -1 : 1);
      }

M lib/models/service.dart => lib/models/service.dart +2 -1
@@ 9,12 9,13 @@ enum SyncService {
abstract class ServiceHandler {
  void remove();
  Future<bool> validate();
  Future<void> reauthenticate() async { }
  Future<Tuple2<List<RSSSource>, Map<String, List<String>>>> getSources();
  Future<List<RSSItem>> fetchItems();
  Future<Tuple2<Set<String>, Set<String>>> syncItems();
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before);
  Future<void> markRead(RSSItem item);
  Future<void> markUnead(RSSItem item);
  Future<void> markUnread(RSSItem item);
  Future<void> star(RSSItem item);
  Future<void> unstar(RSSItem item);
}

M lib/models/services/feedbin.dart => lib/models/services/feedbin.dart +6 -1
@@ 214,6 214,7 @@ class FeedbinServiceHandler extends ServiceHandler {
    return _lastSynced;
  }

  @override
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
    List<String> predicates = ["hasRead = 0"];
    if (sids.length > 0) {


@@ 232,18 233,22 @@ class FeedbinServiceHandler extends ServiceHandler {
    await _markItems("unread", "DELETE", List.from(iids));
  }

  @override
  Future<void> markRead(RSSItem item) async{
    await _markItems("unread", "DELETE", [item.id]);
  }

  Future<void> markUnead(RSSItem item) async {
  @override
  Future<void> markUnread(RSSItem item) async {
    await _markItems("unread", "POST", [item.id]);
  }

  @override
  Future<void> star(RSSItem item) async {
    await _markItems("starred", "POST", [item.id]);
  }

  @override
  Future<void> unstar(RSSItem item) async {
    await _markItems("starred", "DELETE", [item.id]);
  }

M lib/models/services/fever.dart => lib/models/services/fever.dart +1 -1
@@ 218,7 218,7 @@ class FeverServiceHandler extends ServiceHandler {
  }

  @override
  Future<void> markUnead(RSSItem item) async {
  Future<void> markUnread(RSSItem item) async {
    await _markItem(item, "unread");
  }


A lib/models/services/greader.dart => lib/models/services/greader.dart +350 -0
@@ 0,0 1,350 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:fluent_reader_lite/models/item.dart';
import 'package:fluent_reader_lite/models/service.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:html/parser.dart';
import 'package:tuple/tuple.dart';
import 'package:http/http.dart' as http;
import 'package:fluent_reader_lite/models/source.dart';

class GReaderServiceHandler extends ServiceHandler {
  static const _ALL_TAG = "user/-/state/com.google/reading-list";
  static const _READ_TAG = "user/-/state/com.google/read";
  static const _STAR_TAG = "user/-/state/com.google/starred";

  String endpoint;
  String username;
  String password;
  int fetchLimit;
  int _lastFetched;
  String _lastId;
  String _auth;
  bool useInt64;
  String inoreaderId;
  String inoreaderKey;
  bool removeInoreaderAd;

  GReaderServiceHandler() {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT);
    username = Store.sp.getString(StoreKeys.USERNAME);
    password = Store.sp.getString(StoreKeys.PASSWORD);
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT);
    _lastFetched = Store.sp.getInt(StoreKeys.LAST_FETCHED);
    _lastId = Store.sp.getString(StoreKeys.LAST_ID);
    _auth = Store.sp.getString(StoreKeys.AUTH);
    useInt64 = Store.sp.getBool(StoreKeys.USE_INT_64);
    inoreaderId = Store.sp.getString(StoreKeys.API_ID);
    inoreaderKey = Store.sp.getString(StoreKeys.API_KEY);
    removeInoreaderAd = Store.sp.getBool(StoreKeys.INOREADER_REMOVE_AD);
  }

  GReaderServiceHandler.fromValues(
    this.endpoint,
    this.username,
    this.password,
    this.fetchLimit,
    {
      this.inoreaderId,
      this.inoreaderKey,
      this.removeInoreaderAd,
    }
  ) {
    _lastFetched = Store.sp.getInt(StoreKeys.LAST_FETCHED);
    _lastId = Store.sp.getString(StoreKeys.LAST_ID);
    _auth = Store.sp.getString(StoreKeys.AUTH);
    useInt64 = Store.sp.getBool(StoreKeys.USE_INT_64)
      ?? !endpoint.endsWith("theoldreader.com");
  }

  void persist() {
    Store.sp.setInt(StoreKeys.SYNC_SERVICE, SyncService.Inoreader.index);
    Store.sp.setString(StoreKeys.ENDPOINT, endpoint);
    Store.sp.setString(StoreKeys.USERNAME, username);
    Store.sp.setString(StoreKeys.PASSWORD, password);
    Store.sp.setInt(StoreKeys.FETCH_LIMIT, fetchLimit);
    Store.sp.setBool(StoreKeys.USE_INT_64, useInt64);
    if (inoreaderId != null) {
      Store.sp.setString(StoreKeys.API_ID, inoreaderId);
      Store.sp.setString(StoreKeys.API_KEY, inoreaderKey);
      Store.sp.setBool(StoreKeys.INOREADER_REMOVE_AD, removeInoreaderAd);
    }
    Global.service = this;
  }

  @override
  void remove() {
    Store.sp.remove(StoreKeys.SYNC_SERVICE);
    Store.sp.remove(StoreKeys.ENDPOINT);
    Store.sp.remove(StoreKeys.USERNAME);
    Store.sp.remove(StoreKeys.PASSWORD);
    Store.sp.remove(StoreKeys.FETCH_LIMIT);
    Store.sp.remove(StoreKeys.LAST_FETCHED);
    Store.sp.remove(StoreKeys.LAST_ID);
    Store.sp.remove(StoreKeys.AUTH);
    Store.sp.remove(StoreKeys.USE_INT_64);
    Store.sp.remove(StoreKeys.API_ID);
    Store.sp.remove(StoreKeys.API_KEY);
    Store.sp.remove(StoreKeys.INOREADER_REMOVE_AD);
    Global.service = null;
  }

  int get lastFetched => _lastFetched;
  set lastFetched(int value) {
    _lastFetched = value;
    Store.sp.setInt(StoreKeys.LAST_FETCHED, value);
  }

  String get lastId => _lastId;
  set lastId(String value) {
    _lastId = value;
    Store.sp.setString(StoreKeys.LAST_ID, value);
  }

  String get auth => _auth;
  set auth(String value) {
    _auth = value;
    Store.sp.setString(StoreKeys.AUTH, value);
  }

  Future<http.Response> _fetchAPI(String params,
      {dynamic body}) async {
    final headers = Map<String, String>();
    if (auth != null) headers["Authorization"] = auth;
    if (inoreaderId != null) {
      headers["AppId"] = inoreaderId;
      headers["AppKey"] = inoreaderKey;
    }
    if (body == null) {
      return await http.get(endpoint + params, headers: headers);
    } else {
      headers["Content-Type"] = "application/x-www-form-urlencoded";
      return await http.post(endpoint + params, headers: headers, body: body);
    }
  }

  Future<Set<String>> _fetchAll(String params) async {
    final results = List<String>.empty(growable: true);
    List fetched;
    String continuation;
    do {
      var p = params;
      if (continuation != null) p += "&c=$continuation";
      final response = await _fetchAPI(p);
      assert(response.statusCode == 200);
      final parsed = jsonDecode(response.body);
      fetched = parsed["itemRefs"];
      if (fetched != null && fetched.length > 0) {
        for (var i in fetched) {
          results.add(i["id"]);
        }
      }
      continuation = parsed["continuation"];
    } while (continuation != null && fetched != null && fetched.length >= 1000);
    return new Set.from(results);
  }

  Future<http.Response> _editTag(String ref, String tag, {add: true}) async {
    final body = "i=$ref&${add?"a":"r"}=$tag";
    return await _fetchAPI("/reader/api/0/edit-tag", body: body);
  }

  String _compactId(String longId) {
    final last = longId.split("/").last;
    if (!useInt64) return last;
    return int.parse(last, radix: 16).toString();
  }

  @override
  Future<bool> validate() async {
    try {
      final result = await _fetchAPI("/reader/api/0/user-info");
      return result.statusCode == 200;
    } catch(exp) {
      return false;
    }
  }

  static final _authRegex = RegExp(r"Auth=(\S+)");
  @override
  Future<void> reauthenticate() async {
    if (!await validate()) {
      final body = {
        "Email": username,
        "Passwd": password,
      };
      final result = await _fetchAPI("/accounts/ClientLogin", body: body);
      assert(result.statusCode == 200);
      final match = _authRegex.firstMatch(result.body);
      if (match != null && match.groupCount > 0) {
        auth = "GoogleLogin auth=${match.group(1)}";
      }
    }
  }

  @override
  Future<Tuple2<List<RSSSource>, Map<String, List<String>>>> getSources() async {
    final response = await _fetchAPI("/reader/api/0/subscription/list?output=json");
    assert(response.statusCode == 200);
    List subscriptions = jsonDecode(response.body)["subscriptions"];
    final groupsMap =  Map<String, List<String>>();
    for (var s in subscriptions) {
      final categories = s["categories"];
      if (categories != null) {
        for (var c in categories) {
          groupsMap.putIfAbsent(c["label"], () => []);
          groupsMap[c["label"]].add(s["id"]);
        }
      }
    }
    final sources = subscriptions.map<RSSSource>((s) {
      return RSSSource(s["id"], s["url"] ?? s["htmlUrl"], s["title"]);
    }).toList();
    return Tuple2(sources, groupsMap);
  }

  @override
  Future<List<RSSItem>> fetchItems() async {
    List items = [];
    List fetchedItems;
    String continuation;
    do {
      try {
        final limit = min(fetchLimit - items.length, 1000);
        var params = "/reader/api/0/stream/contents?output=json&n=$limit";
        if (lastFetched != null) params += "&ot=$lastFetched";
        if (continuation != null) params += "&c=$continuation";
        final response = await _fetchAPI(params);
        assert(response.statusCode == 200);
        final fetched = jsonDecode(response.body);
        fetchedItems = fetched["items"];
        for (var i in fetchedItems) {
          i["id"] = _compactId(i["id"]);
          if (i["id"] == lastId || items.length >= fetchLimit) {
            break;
          } else {
            items.add(i);
          }
        }
        continuation = fetched["continuation"];
      } catch(exp) {
        break;
      }
    } while (continuation != null && items.length < fetchLimit);
    if (items.length > 0) {
      lastId = items[0]["id"];
      lastFetched = int.parse(items[0]["crawlTimeMsec"]) ~/ 1000;
    }
    final parsedItems = items.map<RSSItem>((i) {
      final dom = parse(i["summary"]["content"]);
      if (removeInoreaderAd) {
        if (dom.documentElement.text.trim().startsWith("Ads from Inoreader")) {
          dom.body.firstChild.remove();
        }
      }
      final item = RSSItem(
        id: i["id"],
        source: i["origin"]["streamId"],
        title: i["title"],
        link: i["canonical"][0]["href"],
        date: DateTime.fromMillisecondsSinceEpoch(i["published"] * 1000),
        content: dom.body.innerHtml,
        snippet: dom.documentElement.text.trim(),
        creator: i["author"],
        hasRead: false,
        starred: false,
      );
      if (inoreaderId != null) {
        final titleDom = parse(item.title);
        item.title = titleDom.documentElement.text;
      }
      var img = dom.querySelector("img");
      if (img != null && img.attributes["src"] != null) { 
        var thumb = img.attributes["src"];
        if (thumb.startsWith("http")) {
          item.thumb = thumb;
        }
      }
      for (var c in i["categories"]) {
        if (!item.hasRead && c.endsWith("/state/com.google/read")) item.hasRead = true;
        else if (!item.starred && c.endsWith("/state/com.google/starred")) item.starred = true;
      }
      return item;
    }).toList();
    return parsedItems;
  }

  @override
  Future<Tuple2<Set<String>, Set<String>>> syncItems() async {
    List<Set<String>> results;
    if (inoreaderId != null) {
      results = await Future.wait([
        _fetchAll("/reader/api/0/stream/items/ids?output=json&xt=$_READ_TAG&n=1000"),
        _fetchAll("/reader/api/0/stream/items/ids?output=json&it=$_STAR_TAG&n=1000"),
      ]);
    } else {
      results = await Future.wait([
        _fetchAll("/reader/api/0/stream/items/ids?output=json&s=$_ALL_TAG&xt=$_READ_TAG&n=1000"),
        _fetchAll("/reader/api/0/stream/items/ids?output=json&s=$_STAR_TAG&n=1000"),
      ]);
    }
    return Tuple2.fromList(results);
  }

  @override
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
    if (date != null) {
      List<String> predicates = ["hasRead = 0"];
      if (sids.length > 0) {
        predicates.add("source IN (${List.filled(sids.length, "?").join(" , ")})");
      }
      if (date != null) {
        predicates.add("date ${before ? "<=" : ">="} ${date.millisecondsSinceEpoch}");
      }
      final rows = await Global.db.query(
        "items",
        columns: ["iid"],
        where: predicates.join(" AND "),
        whereArgs: sids.toList(),
      );
      final iids = rows.map((r) => r["iid"]).iterator;
      List<String> refs = [];
      while (iids.moveNext()) {
        refs.add(iids.current);
        if (refs.length >= 1000) {
          _editTag(refs.join("&i="), _READ_TAG);
          refs = [];
        }
      }
      if (refs.length > 0)  _editTag(refs.join("&i="), _READ_TAG);
    } else {
      for (var sid in sids) {
        final body = { "s": sid };
        _fetchAPI("/reader/api/0/mark-all-as-read", body: body);
      }
    }
  }

  @override
  Future<void> markRead(RSSItem item) async {
    await _editTag(item.id, _READ_TAG);
  }

  @override
  Future<void> markUnread(RSSItem item) async {
    await _editTag(item.id, _READ_TAG, add: false);
  }

  @override
  Future<void> star(RSSItem item) async {
    await _editTag(item.id, _STAR_TAG);
  }

  @override
  Future<void> unstar(RSSItem item) async {
    await _editTag(item.id, _STAR_TAG, add: false);
  }
}

M lib/pages/settings/services/feedbin_page.dart => lib/pages/settings/services/feedbin_page.dart +4 -6
@@ 189,12 189,10 @@ class _FeedbinPageState extends State<FeedbinPage> {
        return ListTileGroup([
          MyListTile(
            title: Expanded(child: Center(
              child: _validating
                ? CupertinoActivityIndicator()
                : Text(
                  S.of(context).save,
                  style: saveStyle,
                )
              child: Text(
                S.of(context).save,
                style: saveStyle,
              )
            )),
            onTap: canSave ? _save : null,
            trailingChevron: false,

A lib/pages/settings/services/inoreader_page.dart => lib/pages/settings/services/inoreader_page.dart +297 -0
@@ 0,0 1,297 @@
import 'dart:io';

import 'package:fluent_reader_lite/components/list_tile_group.dart';
import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/models/services/feedbin.dart';
import 'package:fluent_reader_lite/models/services/greader.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';
import 'package:fluent_reader_lite/pages/settings/text_editor_page.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:fluent_reader_lite/utils/utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:overlay_dialog/overlay_dialog.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';

class InoreaderPage extends StatefulWidget {
  @override
  _InoreaderPageState createState() => _InoreaderPageState();
}

class _InoreaderPageState extends State<InoreaderPage> {
  static const _endpointOptions = [
    "https://www.inoreader.com",
    "https://www.innoreader.com",
    "https://jp.inoreader.com"
];

  String _endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? _endpointOptions[0];
  String _username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
  String _password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
  String _apiId = Store.sp.getString(StoreKeys.API_ID) ?? "";
  String _apiKey = Store.sp.getString(StoreKeys.API_KEY) ?? "";
  int _fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 250;
  bool _removeAd = Store.sp.getBool(StoreKeys.INOREADER_REMOVE_AD) ?? true;

  bool _validating = false;

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).username, 
        Utils.notEmpty,
        initialValue: _username,
      ),
    ));
    if (username == null) return;
    setState(() { _username = username; });
  }

  void _editPassword() async {
    final String password = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).password, 
        Utils.notEmpty,
        isPassword: true,
      ),
    ));
    if (password == null) return;
    setState(() { _password = password; });
  }

  void _editAPIId() async {
    final String apiId = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        "API ID", 
        Utils.notEmpty,
        initialValue: _apiId,
      ),
    ));
    if (apiId == null) return;
    setState(() { _apiId = apiId; });
  }

  void _editAPIKey() async {
    final String apiKey = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        "API Key", 
        Utils.notEmpty,
        initialValue: _apiKey,
      ),
    ));
    if (apiKey == null) return;
    setState(() { _apiKey = apiKey; });
  }

  bool _canSave() {
    if (_validating) return false;
    return _endpoint.length > 0 && _username.length > 0 && _password.length > 0
      && _apiId.length > 0 && _apiKey.length > 0;
  }

  void _save() async {
    final handler = GReaderServiceHandler.fromValues(
      _endpoint,
      _username,
      _password,
      _fetchLimit,
      inoreaderId: _apiId,
      inoreaderKey: _apiKey,
      removeInoreaderAd: _removeAd,
    );
    setState(() { _validating = true; });
    DialogHelper().show(
      context,
      DialogWidget.progress(style: DialogStyle.cupertino),
    );
    try {
      await handler.reauthenticate();
      final isValid = await handler.validate();
      if (!mounted) return;
      assert (isValid);
      handler.persist();
      await Global.syncModel.syncWithService();
      Global.syncModel.checkHasService();
      _validating = false;
      DialogHelper().hide(context);
      if (mounted) Navigator.of(context).pop();
    } catch(exp) {
      handler.remove();
      setState(() { _validating = false; });
      DialogHelper().hide(context);
      Utils.showServiceFailureDialog(context);
    }
  }

  void _logOut() async {
    final bool confirmed = await showCupertinoDialog(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).logOutWarning),
        actions: [
          CupertinoDialogAction(
            isDefaultAction: true,
            child: Text(S.of(context).cancel),
            onPressed: () {
              Navigator.of(context).pop();
            },
          ),
          CupertinoDialogAction(
            isDestructiveAction: true,
            child: Text(S.of(context).confirm),
            onPressed: () {
              Navigator.of(context).pop(true);
            },
          ),
        ],
      ),
    );
    if (confirmed != null) {
      setState(() { _validating = true; });
      DialogHelper().show(
        context,
        DialogWidget.progress(style: DialogStyle.cupertino),
      );
      await Global.syncModel.removeService();
      _validating = false;
      DialogHelper().hide(context);
      final navigator = Navigator.of(context);
      while (navigator.canPop()) navigator.pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    final endpointItems = ListTileGroup.fromOptions(
      _endpointOptions.map((e) => Tuple2(e, e)).toList(),
      _endpoint,
      (e) { setState(() { _endpoint = e; } ); },
      title: S.of(context).endpoint,
    );
    final inputs = ListTileGroup([
      MyListTile(
        title: Text(S.of(context).username),
        trailing: Text(_username.length == 0
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editUsername,
      ),
      MyListTile(
        title: Text(S.of(context).password),
        trailing: Text(_password.length == 0
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editPassword,
      ),
      MyListTile(
        title: Text("API ID"),
        trailing: Text(_apiId.length == 0
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editAPIId,
      ),
      MyListTile(
        title: Text("API Key"),
        trailing: Text(_apiKey.length == 0
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editAPIKey,
        withDivider: false,
      ),
    ], title: S.of(context).credentials);
    final syncItems = ListTileGroup([
      MyListTile(
        title: Text(S.of(context).removeAd),
        trailing: CupertinoSwitch(
          value: _removeAd,
          onChanged: (v) { setState(() { _removeAd = v; }); },
        ),
        trailingChevron: false,
      ),
      MyListTile(
        title: Text(S.of(context).fetchLimit),
        trailing: Text(_fetchLimit.toString()),
        trailingChevron: false,
        withDivider: false,
      ),
      MyListTile(
        title: Expanded(child: CupertinoSlider(
          min: 250,
          max: 1500,
          divisions: 5,
          value: _fetchLimit.toDouble(),
          onChanged: (v) { setState(() { _fetchLimit = v.toInt(); }); },
        )),
        trailingChevron: false,
        withDivider: false,
      ),
    ], title: S.of(context).sync);
    final saveButton = Selector<SyncModel, bool>(
      selector: (context, syncModel) => syncModel.syncing,
      builder: (context, syncing, child) {
        var canSave = !syncing && _canSave();
        final saveStyle = TextStyle(
          color: canSave
            ? CupertinoColors.activeBlue.resolveFrom(context)
            : CupertinoColors.secondaryLabel.resolveFrom(context),
        );
        return ListTileGroup([
          MyListTile(
            title: Expanded(child: Center(
              child: Text(
                S.of(context).save,
                style: saveStyle,
              )
            )),
            onTap: canSave ? _save : null,
            trailingChevron: false,
            withDivider: false,
          ),
        ], title: "");
      },
    );
    final logOutButton = Selector<SyncModel, bool>(
      selector: (context, syncModel) => syncModel.syncing,
      builder: (context, syncing, child) {
        return ListTileGroup([
          MyListTile(
            title: Expanded(child: Center(
              child: Text(
                S.of(context).logOut,
                style: TextStyle(
                  color: (_validating || syncing)
                    ? CupertinoColors.secondaryLabel.resolveFrom(context)
                    : CupertinoColors.destructiveRed,
                ),
              )
            )),
            onTap: (_validating || syncing) ? null : _logOut,
            trailingChevron: false,
            withDivider: false,
          ),
        ], title: "");
      },
    );
    final page = CupertinoPageScaffold(
      backgroundColor: MyColors.background,
      navigationBar: CupertinoNavigationBar(
        middle: Text("Inoreader"),
      ),
      child: ListView(children: [
        endpointItems,
        inputs,
        syncItems,
        saveButton,
        if (Global.service != null) logOutButton,
      ]),
    );
    if (Platform.isAndroid) {
      return WillPopScope(child: page, onWillPop: () async => !_validating);
    } else {
      return page;
    }
  }
}

M lib/pages/setup_page.dart => lib/pages/setup_page.dart +1 -0
@@ 38,6 38,7 @@ class SetupPage extends StatelessWidget {
      ),
      MyListTile(
        title: Text("Inoreader"),
        onTap: () { _configure(context, "/settings/service/inoreader"); },
      ),
      MyListTile(
        title: Text("Feedbin"),

M lib/utils/global.dart => lib/utils/global.dart +2 -3
@@ 5,6 5,7 @@ import 'package:fluent_reader_lite/models/items_model.dart';
import 'package:fluent_reader_lite/models/service.dart';
import 'package:fluent_reader_lite/models/services/feedbin.dart';
import 'package:fluent_reader_lite/models/services/fever.dart';
import 'package:fluent_reader_lite/models/services/greader.dart';
import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';
import 'package:fluent_reader_lite/utils/db.dart';


@@ 46,10 47,8 @@ abstract class Global {
        service = FeedbinServiceHandler();
        break;
      case SyncService.GReader:
        // TODO: Handle this case.
        break;
      case SyncService.Inoreader:
        // TODO: Handle this case.
        service = GReaderServiceHandler();
        break;
    }
    syncModel = SyncModel();

M lib/utils/store.dart => lib/utils/store.dart +5 -0
@@ 35,9 35,14 @@ abstract class StoreKeys {
  static const ENDPOINT = "endpoint";
  static const USERNAME = "username";
  static const PASSWORD = "password";
  static const API_ID = "apiId";
  static const API_KEY = "apiKey";
  static const FETCH_LIMIT = "fetchLimit";
  static const FEVER_INT_32 = "feverInt32";
  static const LAST_FETCHED = "lastFetched";
  static const AUTH = "auth";
  static const USE_INT_64 = "useInt64";
  static const INOREADER_REMOVE_AD = "inoRemoveAd";
}

class Store {