~cytrogen/fluent-reader-mobile

0b77b4664f92ca4fce2f347c33fc4298f7bf9c26 — 刘浩远 5 years ago 57e40ef
add feedbin support
M assets/article/article.css => assets/article/article.css +2 -2
@@ 42,7 42,7 @@ html.light {
}

h1, h2, h3, h4, h5, h6, b, strong {
    font-weight: 600;
    font-weight: 800;
}
a, a:hover, a:active {
    color: var(--primary);


@@ 80,7 80,7 @@ a, a:hover, a:active {
#main > p#title {
    font-size: 1.375rem;
    line-height: 1.75rem;
    font-weight: 600;
    font-weight: 800;
    margin: 0;
}
#main > p#date {

M lib/components/sync_control.dart => lib/components/sync_control.dart +4 -2
@@ 13,8 13,10 @@ class _SyncControlState extends State<SyncControl> {
    var completer = Completer();
    Function listener;
    listener = () {
      completer.complete();
      Global.syncModel.removeListener(listener);
      if (!Global.syncModel.syncing) {
        completer.complete();
        Global.syncModel.removeListener(listener);
      }
    };
    Global.syncModel.addListener(listener);
    Global.syncModel.syncWithService();

M lib/l10n/intl_en.arb => lib/l10n/intl_en.arb +2 -1
@@ 76,5 76,6 @@
    "serviceFailureHint": "Please check the service configuration or network status.",
    "logOut": "Log out",
    "logOutWarning": "All local data will be deleted. Are you sure?",
    "confirm": "Confirm"
    "confirm": "Confirm",
    "allLoaded": "All loaded"
  }
\ No newline at end of file

M lib/l10n/intl_zh.arb => lib/l10n/intl_zh.arb +2 -1
@@ 76,5 76,6 @@
    "serviceFailureHint": "请检查服务配置或网络连接",
    "logOut": "登出",
    "logOutWarning": "这将移除所有本地数据,是否继续?",
    "confirm": "确定"
    "confirm": "确定",
    "allLoaded": "已全部加载"
  }
\ No newline at end of file

M lib/main.dart => lib/main.dart +3 -2
@@ 7,6 7,7 @@ import 'package:fluent_reader_lite/pages/home_page.dart';
import 'package:fluent_reader_lite/pages/settings/feed_page.dart';
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/source_edit_page.dart';
import 'package:fluent_reader_lite/pages/settings/sources_page.dart';


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


@@ 63,8 65,7 @@ class MyApp extends StatelessWidget {
        case SyncService.Fever:
          return FeverPage();
        case SyncService.Feedbin:
          // TODO: Handle this case.
          break;
          return FeedbinPage();
        case SyncService.GReader:
          // TODO: Handle this case.
          break;

M lib/models/feed.dart => lib/models/feed.dart +1 -1
@@ 29,7 29,7 @@ class RSSFeed {
    : StoreKeys.FEED_FILTER_SOURCE;

  Tuple2<String, List<String>> _getPredicates() {
    List<String> where = ["TRUE"];
    List<String> where = ["1 = 1"];
    List<String> whereArgs = [];
    if (sids.length > 0) {
      var placeholders = List.filled(sids.length, "?").join(" , ");

M lib/models/items_model.dart => lib/models/items_model.dart +1 -1
@@ 51,7 51,7 @@ class ItemsModel with ChangeNotifier {

  Future<void> markAllRead(Set<String> sids, {DateTime date, before = true}) async {
    Global.service.markAllRead(sids, date, before);
    List<String> predicates = [];
    List<String> predicates = ["hasRead = 0"];
    if (sids.length > 0) {
      predicates.add("source IN (${List.filled(sids.length, "?").join(" , ")})");
    }

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

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:fluent_reader_lite/utils/utils.dart';
import 'package:html/parser.dart';
import 'package:tuple/tuple.dart';
import 'package:http/http.dart' as http;

import '../item.dart';
import '../source.dart';

class FeedbinServiceHandler extends ServiceHandler {
  String endpoint;
  String username;
  String password;
  int fetchLimit;
  int _lastId;
  Tuple2<Set<String>, Set<String>> _lastSynced;

  FeedbinServiceHandler() {
    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);
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
  }

  FeedbinServiceHandler.fromValues(
    this.endpoint,
    this.username,
    this.password,
    this.fetchLimit
  ) {
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
  }

  void persist() {
    Store.sp.setInt(StoreKeys.SYNC_SERVICE, SyncService.Feedbin.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.setInt(StoreKeys.LAST_ID, _lastId);
    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_ID);
    Global.service = null;
  }

  String _getApiKey() {
    final credentials = "$username:$password";
    final bytes = utf8.encode(credentials);
    return base64.encode(bytes);
  }

  Future<http.Response> _fetchAPI(String params) async {
    return await http.get(
      endpoint + params,
      headers: {
        "Authorization": "Basic ${_getApiKey()}",
      }
    );
  }

  Future<void> _markItems(String type, String method, List<String> refs) async {
    final auth = "Basic ${_getApiKey()}";
    final promises = List<Future>.empty(growable: true);
    final client = http.Client();
    try {
      while (refs.length > 0) {
        final batch = List<int>.empty(growable: true);
        while (batch.length < 1000 && refs.length > 0) {
          batch.add(int.parse(refs.removeLast()));
        }
        final bodyObject = {
          "${type}_entries": batch,
        };
        final request = http.Request(
          method,
          Uri.parse(endpoint + type + "_entries.json"),
        );
        request.headers["Authorization"] = auth;
        request.headers["Content-Type"] = "application/json; charset=utf-8";
        request.body = jsonEncode(bodyObject);
        promises.add(client.send(request));
      }
      await Future.wait(promises);
    } finally {
      client.close();
    }
  }

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

  @override
  Future<bool> validate() async {
    try {
      final response = await _fetchAPI("authentication.json");
      return response.statusCode == 200;
    } catch(exp) {
      print(exp);
      return false;
    }
  }

  @override
  Future<Tuple2<List<RSSSource>, Map<String, List<String>>>> getSources() async {
    final response = await _fetchAPI("subscriptions.json");
    assert(response.statusCode == 200);
    final subscriptions = jsonDecode(response.body);
    final groupsMap = Map<String, List<String>>();
    final tagsResponse = await _fetchAPI("taggings.json");
    assert(tagsResponse.statusCode == 200);
    final tags = jsonDecode(tagsResponse.body);
    for (var tag in tags) {
      final name = tag["name"].trim();
      groupsMap.putIfAbsent(name, () => []);
      groupsMap[name].add(tag["feed_id"].toString());
    }
    final sources = subscriptions.map<RSSSource>((s) {
      return RSSSource(s["feed_id"].toString(), s["feed_url"], s["title"]);
    }).toList();
    return Tuple2(sources, groupsMap);
  }

  @override
  Future<List<RSSItem>> fetchItems() async {
    var page = 1;
    var minId = Utils.syncMaxId;
    var items = [];
    List lastFetched;
    do {
      try {
        final response = await _fetchAPI("entries.json?mode=extended&per_page=125&page=$page");
        assert(response.statusCode == 200);
        lastFetched = jsonDecode(response.body);
        items.addAll(lastFetched.where((i) => i["id"] > lastId && i["id"] < minId));
        minId = lastFetched.fold(minId, (m, n) => min(m, n["id"]));
        page += 1;
      } catch(exp) {
        break;
      }
    } while (
      minId > lastId &&
      lastFetched != null && lastFetched.length >= 125 &&
      items.length < fetchLimit
    );
    lastId = items.fold(lastId, (m, n) => max(m, n["id"]));
    final parsedItems = List<RSSItem>.empty(growable: true);
    final unread = _lastSynced.item1;
    final starred = _lastSynced.item2;
    for (var i in items) {
      if (i["content"] == null) continue;
      final dom = parse(i["content"]);
      final iid = i["id"].toString();
      final item = RSSItem(
        id: iid,
        source: i["feed_id"].toString(),
        title: i["title"],
        link: i["url"],
        date: DateTime.parse(i["published"]),
        content: i["content"],
        snippet: dom.documentElement.text.trim(),
        creator: i["author"],
        hasRead: !unread.contains(iid),
        starred: starred.contains(iid),
      );
      if (i["images"] != null && i["images"]["original_url"] != null) {
        item.thumb = i["images"]["original_url"];
      } else {
        var img = dom.querySelector("img");
        if (img != null && img.attributes["src"] != null) { 
          var thumb = img.attributes["src"];
          if (thumb.startsWith("http")) {
            item.thumb = thumb;
          }
        }
      }
      parsedItems.add(item);
    }
    _lastSynced = null;
    return parsedItems;
  }

  @override
  Future<Tuple2<Set<String>, Set<String>>> syncItems() async {
    final responses = await Future.wait([
      _fetchAPI("unread_entries.json"),
      _fetchAPI("starred_entries.json"),
    ]);
    assert(responses[0].statusCode == 200);
    assert(responses[1].statusCode == 200);
    final unread = jsonDecode(responses[0].body);
    final starred = jsonDecode(responses[1].body);
    _lastSynced = Tuple2(
      Set.from(unread.map((i) => i.toString())),
      Set.from(starred.map((i) => i.toString())),
    );
    return _lastSynced;
  }

  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
    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"]);
    await _markItems("unread", "DELETE", List.from(iids));
  }

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

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

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

  Future<void> unstar(RSSItem item) async {
    await _markItems("starred", "DELETE", [item.id]);
  }
}
\ No newline at end of file

M lib/models/services/fever.dart => lib/models/services/fever.dart +4 -3
@@ 4,6 4,7 @@ import 'dart:math';
import 'package:fluent_reader_lite/models/item.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:html/parser.dart';
import 'package:http/http.dart' as http;
import 'package:fluent_reader_lite/models/source.dart';


@@ 117,14 118,14 @@ class FeverServiceHandler extends ServiceHandler {

  @override
  Future<List<RSSItem>> fetchItems() async {
    var minId = useInt32 ? 2147483647 : 2^50;
    var minId = useInt32 ? 2147483647 : Utils.syncMaxId;
    List<dynamic> response;
    List<dynamic> items = [];
    do {
      response = (await _fetchAPI(params: "&items&max_id=$minId"))["items"];
      if (response == null) throw Error();
      items.addAll(response.where((i) => i["id"] > lastId));
      if (response.length == 0 && minId == 2^50) {
      if (response.length == 0 && minId == Utils.syncMaxId) {
        useInt32 = true;
        minId = 2147483647;
        response = null;


@@ 161,7 162,7 @@ class FeverServiceHandler extends ServiceHandler {
        var a = dom.querySelector("body>ul>li:first-child>a");
        if (a != null && a.text.endsWith(", image\/generic") && a.attributes["href"] != null)
          item.thumb = a.attributes["href"];
        }
      }
      return item;
    });
    lastId = items.fold(lastId, (m, n) => max(m, n["id"]));

M lib/pages/home_page.dart => lib/pages/home_page.dart +13 -1
@@ 29,6 29,10 @@ class ScrollTopNotifier with ChangeNotifier {

class _HomePageState extends State<HomePage> {
  final _scrollTopNotifier = ScrollTopNotifier();
  final _controller = CupertinoTabController();
  final List<GlobalKey<NavigatorState>> _tabNavigatorKeys = [
    GlobalKey(), GlobalKey(),
  ];

  Widget _constructPage(Widget page, bool isMobile) {
    return isMobile


@@ 49,7 53,8 @@ class _HomePageState extends State<HomePage> {
      builder: (context, hasService, child) {
        if (!hasService) return SetupPage();
        var isMobile = true;
        var left = CupertinoTabScaffold(
        final leftTabs = CupertinoTabScaffold(
          controller: _controller,
          backgroundColor: CupertinoColors.systemBackground,
          tabBar: CupertinoTabBar(
            backgroundColor: CupertinoColors.systemBackground,


@@ 67,6 72,7 @@ class _HomePageState extends State<HomePage> {
          ),
          tabBuilder: (context, index) {
            return CupertinoTabView(
              navigatorKey: _tabNavigatorKeys[index],
              routes: {
                '/feed': (context) {
                  Widget page = ItemListPage(_scrollTopNotifier);


@@ 82,6 88,12 @@ class _HomePageState extends State<HomePage> {
            );
          },
        );
        final left = WillPopScope(
          child: leftTabs,
          onWillPop: () async {
            return !(await _tabNavigatorKeys[_controller.index].currentState.maybePop());
          },
        );
        return ScreenTypeLayout.builder(
          mobile: (context) => left,
          tablet: (context) {

M lib/pages/item_list_page.dart => lib/pages/item_list_page.dart +2 -2
@@ 352,8 352,8 @@ class _ItemListPageState extends State<ItemListPage> {
          padding: EdgeInsets.symmetric(vertical: 20),
          child: Center(
            child: feed.allLoaded
              ? Text("All loaded", style: TextStyle(
                  color: CupertinoColors.secondaryLabel.resolveFrom(context),
              ? Text(S.of(context).allLoaded, style: TextStyle(
                  color: CupertinoColors.tertiaryLabel.resolveFrom(context),
                ))
              : CupertinoActivityIndicator()
          ),

M lib/pages/settings/about_page.dart => lib/pages/settings/about_page.dart +3 -0
@@ 2,6 2,7 @@ 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/utils/colors.dart';
import 'package:fluent_reader_lite/utils/utils.dart';
import 'package:flutter/cupertino.dart';

class AboutPage extends StatelessWidget {


@@ 45,9 46,11 @@ class AboutPage extends StatelessWidget {
          ListTileGroup([
            MyListTile(
              title: Text(S.of(context).openSource),
              onTap: () { Utils.openExternal("https://github.com/yang991178/fluent-reader-lite"); },
            ),
            MyListTile(
              title: Text(S.of(context).feedback),
              onTap: () { Utils.openExternal("https://github.com/yang991178/fluent-reader-lite/issues"); },
              withDivider: false,
            ),
          ]),

A lib/pages/settings/services/feedbin_page.dart => lib/pages/settings/services/feedbin_page.dart +231 -0
@@ 0,0 1,231 @@
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/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:provider/provider.dart';

class FeedbinPage extends StatefulWidget {
  @override
  _FeedbinPageState createState() => _FeedbinPageState();
}

class _FeedbinPageState extends State<FeedbinPage> {
  String _endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "https://api.feedbin.me/v2/";
  String _username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
  String _password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
  int _fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 250;
  bool _validating = false;

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).endpoint, 
        Utils.testUrl,
        initialValue: _endpoint,
      ),
    ));
    if (endpoint == null) return;
    setState(() { _endpoint = endpoint; });
  }

  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; });
  }

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

  void _save() async {
    final handler = FeedbinServiceHandler.fromValues(
      _endpoint,
      _username,
      _password,
      _fetchLimit,
    );
    setState(() { _validating = true; });
    final isValid = await handler.validate();
    if (!mounted) return;
    if (isValid) {
      handler.persist();
      await Global.syncModel.syncWithService();
      Global.syncModel.checkHasService();
      _validating = false;
      if (mounted) Navigator.of(context).pop();
    } else {
      setState(() { _validating = false; });
      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; });
      await Global.syncModel.removeService();
      setState(() { _validating = false; });
      final navigator = Navigator.of(context);
      while (navigator.canPop()) navigator.pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    final inputs = ListTileGroup([
      MyListTile(
        title: Text(S.of(context).endpoint),
        trailing: Text(_endpoint.length == 0
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editEndpoint,
      ),
      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,
        withDivider: false,
      ),
    ], title: S.of(context).credentials);
    final syncItems = ListTileGroup([
      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: _validating
                ? CupertinoActivityIndicator()
                : 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("Feedbin"),
      ),
      child: ListView(children: [
        inputs,
        syncItems,
        saveButton,
        if (Global.service != null) logOutButton,
      ]),
    );
    return WillPopScope(
      child: page,
      onWillPop: () async => !_validating,
    );
  }
}

M lib/pages/setup_page.dart => lib/pages/setup_page.dart +1 -0
@@ 41,6 41,7 @@ class SetupPage extends StatelessWidget {
      ),
      MyListTile(
        title: Text("Feedbin"),
        onTap: () { _configure(context, "/settings/service/feedbin"); },
      ),
    ], title: S.of(context).service);
    final page = CupertinoPageScaffold(

M lib/utils/global.dart => lib/utils/global.dart +2 -1
@@ 3,6 3,7 @@ import 'package:fluent_reader_lite/models/global_model.dart';
import 'package:fluent_reader_lite/models/groups_model.dart';
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/sources_model.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';


@@ 39,7 40,7 @@ abstract class Global {
        service = FeverServiceHandler();
        break;
      case SyncService.Feedbin:
        // TODO: Handle this case.
        service = FeedbinServiceHandler();
        break;
      case SyncService.GReader:
        // TODO: Handle this case.

M lib/utils/utils.dart => lib/utils/utils.dart +9 -0
@@ 1,9 1,18 @@
import 'dart:math';

import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';

abstract class Utils {
  static final syncMaxId = pow(2, 50);

  static void openExternal(String url) {
    launch(url, forceSafariVC: false, forceWebView: false);
  }

  static int binarySearch<T>(List<T> sortedList, T value, 
    int Function(T, T) compare) {
    var min = 0;