~cytrogen/fluent-reader-mobile

c919c17115dba6c37433c18c5e287ea0ad984dbd — Haoyuan Liu 5 years ago c0d14ef + 438ed4c
Merge pull request #12 from yang991178/1.0.1

Version 1.0.1
M android/app/src/main/AndroidManifest.xml => android/app/src/main/AndroidManifest.xml +10 -0
@@ 41,6 41,16 @@
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>

            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
                <data
                    android:scheme="fluent-reader"
                    android:host="import" />
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

M assets/article/article.css => assets/article/article.css +4 -0
@@ 126,4 126,8 @@ article ul, article menu, article dir {
}
article li {
    overflow: visible;
}
article pre {
    white-space: pre-wrap;
    word-break: break-all;
}
\ No newline at end of file

M assets/article/article.html => assets/article/article.html +1 -0
@@ 3,6 3,7 @@
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="referrer" content="no-referrer">
    <meta http-equiv="Content-Security-Policy"
        content="default-src 'none'; script-src 'self'; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
    <title>Article</title>

M ios/Podfile.lock => ios/Podfile.lock +6 -0
@@ 14,6 14,8 @@ PODS:
  - sqflite (0.0.2):
    - Flutter
    - FMDB (>= 2.7.5)
  - uni_links (0.0.1):
    - Flutter
  - url_launcher (0.0.1):
    - Flutter
  - webview_flutter (0.0.1):


@@ 26,6 28,7 @@ DEPENDENCIES:
  - share (from `.symlinks/plugins/share/ios`)
  - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`)
  - sqflite (from `.symlinks/plugins/sqflite/ios`)
  - uni_links (from `.symlinks/plugins/uni_links/ios`)
  - url_launcher (from `.symlinks/plugins/url_launcher/ios`)
  - webview_flutter (from `.symlinks/plugins/webview_flutter/ios`)



@@ 46,6 49,8 @@ EXTERNAL SOURCES:
    :path: ".symlinks/plugins/shared_preferences/ios"
  sqflite:
    :path: ".symlinks/plugins/sqflite/ios"
  uni_links:
    :path: ".symlinks/plugins/uni_links/ios"
  url_launcher:
    :path: ".symlinks/plugins/url_launcher/ios"
  webview_flutter:


@@ 59,6 64,7 @@ SPEC CHECKSUMS:
  share: 0b2c3e82132f5888bccca3351c504d0003b3b410
  shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d
  sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
  uni_links: d97da20c7701486ba192624d99bffaaffcfc298a
  url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
  webview_flutter: d2b4d6c66968ad042ad94cbb791f5b72b4678a96


M ios/Runner/Info.plist => ios/Runner/Info.plist +15 -2
@@ 2,8 2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
	<key>CFBundleDisplayName</key>


@@ 22,8 20,23 @@
	<string>$(FLUTTER_BUILD_NAME)</string>
	<key>CFBundleSignature</key>
	<string>????</string>
	<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Editor</string>
			<key>CFBundleURLName</key>
			<string>me.hyliu.fluent-reader-lite</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>fluent-reader</string>
			</array>
		</dict>
	</array>
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>
	<key>LSRequiresIPhoneOS</key>
	<true/>
	<key>NSAppTransportSecurity</key>

M lib/l10n/intl_en.arb => lib/l10n/intl_en.arb +5 -1
@@ 87,5 87,9 @@
    "wentWrong": "Something went wrong.",
    "retry": "Retry",
    "copy": "Copy",
    "errorLog": "Error log"
    "errorLog": "Error log",
    "unreadSourceTip": "You can long press on the title of this page to toggle between all and unread subscriptions.",
    "uncategorized": "Uncategorized",
    "showUncategorized": "Show uncategorized",
    "serviceExists": "A service already exists. Please log out before importing."
  }
\ No newline at end of file

M lib/l10n/intl_zh.arb => lib/l10n/intl_zh.arb +5 -1
@@ 87,5 87,9 @@
    "wentWrong": "发生错误",
    "retry": "重试",
    "copy": "复制",
    "errorLog": "错误日志"
    "errorLog": "错误日志",
    "unreadSourceTip": "您可以长按此页面的标题来切换全部订阅源或仅未读订阅源。",
    "uncategorized": "未分组",
    "showUncategorized": "显示“未分组”",
    "serviceExists": "已登录至一个服务,请在导入前登出。"
  }
\ No newline at end of file

M lib/models/groups_model.dart => lib/models/groups_model.dart +31 -0
@@ 1,13 1,44 @@
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:flutter/cupertino.dart';

class GroupsModel with ChangeNotifier {
  Map<String, List<String>> _groups = Store.getGroups();
  List<String> uncategorized = Store.getUncategorized();

  Map<String, List<String>> get groups => _groups;
  set groups(Map<String, List<String>> groups) {
    _groups = groups;
    updateUncategorized();
    notifyListeners();
    Store.setGroups(groups);
  }

  void updateUncategorized({force: false}) {
    if (uncategorized != null || force) {
      final sids = Set<String>.from(
        Global.sourcesModel.getSources().map<String>((s) => s.id)
      );
      for (var group in _groups.values) {
        for (var sid in group) {
          sids.remove(sid);
        }
      }
      uncategorized = sids.toList();
      Store.setUncategorized(uncategorized);
    }
  }

  bool get showUncategorized => uncategorized != null;
  set showUncategorized(bool value) {
    if (showUncategorized != value) {
      if (value) {
        updateUncategorized(force: true);
      } else {
        uncategorized = null;
        Store.setUncategorized(null);
      }
      notifyListeners();
    }
  }
}
\ No newline at end of file

M lib/models/service.dart => lib/models/service.dart +7 -1
@@ 1,5 1,7 @@
import 'package:fluent_reader_lite/models/item.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:tuple/tuple.dart';

enum SyncService {


@@ 7,7 9,11 @@ enum SyncService {
}

abstract class ServiceHandler {
  void remove();
  void remove() {
    Store.sp.remove(StoreKeys.SYNC_SERVICE);
    Global.groupsModel.groups = Map();
    Global.groupsModel.showUncategorized = false;
  }
  Future<bool> validate();
  Future<void> reauthenticate() async { }
  Future<Tuple2<List<RSSSource>, Map<String, List<String>>>> getSources();

M lib/models/services/feedbin.dart => lib/models/services/feedbin.dart +1 -1
@@ 49,7 49,7 @@ class FeedbinServiceHandler extends ServiceHandler {

  @override
  void remove() {
    Store.sp.remove(StoreKeys.SYNC_SERVICE);
    super.remove();
    Store.sp.remove(StoreKeys.ENDPOINT);
    Store.sp.remove(StoreKeys.USERNAME);
    Store.sp.remove(StoreKeys.PASSWORD);

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

  @override
  void remove() {
    Store.sp.remove(StoreKeys.SYNC_SERVICE);
    super.remove();
    Store.sp.remove(StoreKeys.ENDPOINT);
    Store.sp.remove(StoreKeys.USERNAME);
    Store.sp.remove(StoreKeys.PASSWORD);

M lib/models/services/greader.dart => lib/models/services/greader.dart +1 -1
@@ 79,7 79,7 @@ class GReaderServiceHandler extends ServiceHandler {

  @override
  void remove() {
    Store.sp.remove(StoreKeys.SYNC_SERVICE);
    super.remove();
    Store.sp.remove(StoreKeys.ENDPOINT);
    Store.sp.remove(StoreKeys.USERNAME);
    Store.sp.remove(StoreKeys.PASSWORD);

A lib/models/services/service_import.dart => lib/models/services/service_import.dart +22 -0
@@ 0,0 1,22 @@
class ServiceImport {
  String endpoint;
  String username;
  String password;
  String apiId;
  String apiKey;

  static const typeMap = {
    "f": "/settings/service/fever",
    "g": "/settings/service/greader",
    "i": "/settings/service/inoreader",
    "fb": "/settings/service/feedbin"
  };

  ServiceImport(Map<String, String> params) {
    endpoint = params["e"];
    username = params["u"];
    password = params["p"];
    apiId = params["i"];
    apiKey = params["k"];
  }
}

M lib/models/sources_model.dart => lib/models/sources_model.dart +12 -0
@@ 1,5 1,8 @@
import 'dart:collection';

import 'package:fluent_reader_lite/models/source.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/material.dart';
import 'package:html/parser.dart';


@@ 11,6 14,15 @@ import 'item.dart';
class SourcesModel with ChangeNotifier {
  Map<String, RSSSource> _sources = Map();
  Map<String, RSSSource> _deleted = Map();
  bool _showUnreadTip = Store.sp.getBool(StoreKeys.UNREAD_SOURCE_TIP) ?? true;

  bool get showUnreadTip => _showUnreadTip;
  set showUnreadTip(bool value) {
    if (_showUnreadTip != value) {
      _showUnreadTip = value;
      Store.sp.setBool(StoreKeys.UNREAD_SOURCE_TIP, value);
    }
  }

  bool has(String id) => _sources.containsKey(id);


M lib/pages/group_list_page.dart => lib/pages/group_list_page.dart +26 -6
@@ 7,6 7,7 @@ import 'package:fluent_reader_lite/models/groups_model.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';


@@ 17,6 18,8 @@ class GroupListPage extends StatefulWidget {
}

class _GroupListPageState extends State<GroupListPage> {
  static const List<String> _uncategorizedIndicator = [null, null];

  int _unreadCount(Iterable<RSSSource> sources) {
    return sources.fold(0, (c, s) => c + (s != null ? s.unreadCount : 0));
  }


@@ 32,6 35,7 @@ class _GroupListPageState extends State<GroupListPage> {
      automaticallyImplyLeading: false,
      backgroundColor: Global.isTablet ? CupertinoColors.systemBackground : null,
      leading: CupertinoButton(
        minSize: 36,
        padding: EdgeInsets.zero,
        child: Text(S.of(context).cancel),
        onPressed: () { Navigator.of(context).pop(); },


@@ 52,28 56,44 @@ class _GroupListPageState extends State<GroupListPage> {
    final groupList = Consumer2<GroupsModel, SourcesModel>(
      builder: (context, groupsModel, sourcesModel, child) {
        final groupNames = groupsModel.groups.keys.toList();
        groupNames.sort();
        groupNames.sort(Utils.localStringCompare);
        if (groupsModel.uncategorized != null) {
          groupNames.insert(0, null);
        }
        return SliverList(
          delegate: SliverChildBuilderDelegate((context, index) {
            final groupName = groupNames[index];
            String groupName;
            List<String> group;
            final isUncategorized = groupsModel.showUncategorized && index == 0;
            if (isUncategorized) {
              groupName = S.of(context).uncategorized;
              group = groupsModel.uncategorized;
            } else {
              groupName = groupNames[index];
              group = groupsModel.groups[groupName];
            }
            final count = _unreadCount(
              groupsModel.groups[groupName].map((sid) => sourcesModel.getSource(sid))
              group.map((sid) => sourcesModel.getSource(sid))
            );
            final tile = MyListTile(
              title: Flexible(child: Text(groupName, overflow: TextOverflow.ellipsis)),
              trailing: count > 0 ? Badge(count) : null,
              onTap: () { Navigator.of(context).pop([groupName]); },
              onTap: () { 
                Navigator.of(context).pop(
                  isUncategorized ? _uncategorizedIndicator : [groupName]
                );
              },
              background: CupertinoColors.systemBackground,
            );
            return Dismissible(
              key: Key(groupName),
              key: Key("$groupName$index"),
              child: tile,
              background: dismissBg,
              direction: DismissDirection.startToEnd,
              dismissThresholds: _dismissThresholds,
              confirmDismiss: (_) async {
                HapticFeedback.mediumImpact();
                Set<String> sids = Set.from(groupsModel.groups[groupName]);
                Set<String> sids = Set.from(group);
                showCupertinoModalPopup(
                  context: context,
                  builder: (context) => MarkAllActionSheet(sids),

M lib/pages/home_page.dart => lib/pages/home_page.dart +57 -0
@@ 1,5 1,8 @@
import 'dart:async';

import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/main.dart';
import 'package:fluent_reader_lite/models/services/service_import.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';
import 'package:fluent_reader_lite/pages/setup_page.dart';
import 'package:fluent_reader_lite/pages/subscription_list_page.dart';


@@ 9,10 12,13 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:responsive_builder/responsive_builder.dart';
import 'package:uni_links/uni_links.dart';

import 'item_list_page.dart';

class HomePage extends StatefulWidget {
  HomePage() : super(key: Key("home"));

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


@@ 33,6 39,57 @@ class _HomePageState extends State<HomePage> {
  final List<GlobalKey<NavigatorState>> _tabNavigatorKeys = [
    GlobalKey(), GlobalKey(),
  ];
  StreamSubscription _uriSub;

  void _uriStreamListener(Uri uri) {
    if (uri == null) return;
    if (uri.host == "import") {
      if (Global.syncModel.hasService) {
        showCupertinoDialog(
          context: context, 
          builder: (context) => CupertinoAlertDialog(
            title: Text(S.of(context).serviceExists),
            actions: [
              CupertinoDialogAction(
                child: Text(S.of(context).confirm),
                onPressed: () { Navigator.of(context).pop(); },
              ),
            ],
          ),
        );
      } else if (!Global.syncModel.syncing) {
        final import = ServiceImport(uri.queryParameters);
        final route = ServiceImport.typeMap[uri.queryParameters["t"]];
        if (route != null) {
          final navigator = Navigator.of(context);
          while (navigator.canPop()) navigator.pop();
          navigator.pushNamed(route, arguments: import);
        }
      }
    }
  }

  @override
  void initState() {
    super.initState();
    _uriSub = getUriLinksStream().listen(_uriStreamListener);
    Future.delayed(Duration.zero, () async {
      try {
        final uri = await getInitialUri();
        if (uri != null) {
          _uriStreamListener(uri);
        }
      } catch(exp) {
        print(exp);
      }
    });
  }

  @override
  dispose() {
    _uriSub.cancel();
    super.dispose();
  }

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

M lib/pages/item_list_page.dart => lib/pages/item_list_page.dart +0 -1
@@ 342,7 342,6 @@ class _ItemListPageState extends State<ItemListPage> {
        return SliverList(
          delegate: SliverChildBuilderDelegate((content, index) {
            return Selector2<ItemsModel, SourcesModel, Tuple2<RSSItem, RSSSource>>(
              key: Key(feed.iids[index]),
              selector: (context, itemsModel, sourcesModel) {
                var item = itemsModel.getItem(feed.iids[index]);
                var source = sourcesModel.getSource(item.source);

M lib/pages/settings/feed_page.dart => lib/pages/settings/feed_page.dart +43 -25
@@ 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/models/feeds_model.dart';
import 'package:fluent_reader_lite/models/groups_model.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';


@@ 56,35 57,52 @@ class FeedPage extends StatelessWidget {
            ItemSwipeOption.OpenExternal: S.of(context).openExternal,
            ItemSwipeOption.OpenMenu: S.of(context).openMenu,
          };
          return ListView(
            children: [
              ListTileGroup([
                MyListTile(
                  title: Text(S.of(context).showThumb),
                  trailing: CupertinoSwitch(
                    value: feedsModel.showThumb,
                    onChanged: (v) { feedsModel.showThumb = v; },
                  ),
                  trailingChevron: false,
                ),
                MyListTile(
                  title: Text(S.of(context).showSnippet),
                  trailing: CupertinoSwitch(
                    value: feedsModel.showSnippet,
                    onChanged: (v) { feedsModel.showSnippet = v; },
                  ),
                  trailingChevron: false,
                ),
                MyListTile(
                  title: Text(S.of(context).dimRead),
          final preferences = ListTileGroup([
            MyListTile(
              title: Text(S.of(context).showThumb),
              trailing: CupertinoSwitch(
                value: feedsModel.showThumb,
                onChanged: (v) { feedsModel.showThumb = v; },
              ),
              trailingChevron: false,
            ),
            MyListTile(
              title: Text(S.of(context).showSnippet),
              trailing: CupertinoSwitch(
                value: feedsModel.showSnippet,
                onChanged: (v) { feedsModel.showSnippet = v; },
              ),
              trailingChevron: false,
            ),
            MyListTile(
              title: Text(S.of(context).dimRead),
              trailing: CupertinoSwitch(
                value: feedsModel.dimRead,
                onChanged: (v) { feedsModel.dimRead = v; },
              ),
              trailingChevron: false,
              withDivider: false,
            ),
          ], title: S.of(context).preferences);
          final groups = ListTileGroup([
            Consumer<GroupsModel>(
              builder: (context, groupsModel, child) {
                return MyListTile(
                  title: Text(S.of(context).showUncategorized),
                  trailing: CupertinoSwitch(
                    value: feedsModel.dimRead,
                    onChanged: (v) { feedsModel.dimRead = v; },
                    value: groupsModel.showUncategorized,
                    onChanged: (v) { groupsModel.showUncategorized = v; },
                  ),
                  trailingChevron: false,
                  withDivider: false,
                ),
              ], title: S.of(context).preferences),
                );
              },
            ),
          ], title: S.of(context).groups);
          return ListView(
            children: [
              preferences,
              groups,
              ListTileGroup([
                MyListTile(
                  title: Text(S.of(context).swipeRight),

M lib/pages/settings/services/feedbin_page.dart => lib/pages/settings/services/feedbin_page.dart +22 -0
@@ 1,9 1,11 @@
import 'dart:convert';
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/service_import.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';


@@ 26,6 28,26 @@ class _FeedbinPageState extends State<FeedbinPage> {
  int _fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 250;
  bool _validating = false;

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }
    });
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(

M lib/pages/settings/services/fever_page.dart => lib/pages/settings/services/fever_page.dart +33 -5
@@ 6,6 6,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/models/services/fever.dart';
import 'package:fluent_reader_lite/models/services/service_import.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';


@@ 24,10 25,29 @@ class FeverPage extends StatefulWidget {
class _FeverPageState extends State<FeverPage> {
  String _endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "";
  String _username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
  String _apiKey = Store.sp.getString(StoreKeys.API_KEY);
  String _password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
  int _fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 250;
  bool _validating = false;

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
      }
      if (Utils.notEmpty(import.apiKey)) {
        setState(() { _apiKey = import.apiKey; });
      }
    });
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(


@@ 50,7 70,10 @@ class _FeverPageState extends State<FeverPage> {
      ),
    ));
    if (username == null) return;
    setState(() { _username = username; });
    setState(() {
      _username = username;
      _apiKey = null;
    });
  }

  void _editPassword() async {


@@ 62,16 85,21 @@ class _FeverPageState extends State<FeverPage> {
      ),
    ));
    if (password == null) return;
    setState(() { _password = password; });
    setState(() {
      _password = password;
      _apiKey = null;
    });
  }

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

  void _save() async {
    final apiKey = md5.convert(utf8.encode("$_username:$_password")).toString();
    final apiKey = _apiKey
      ?? md5.convert(utf8.encode("$_username:$_password")).toString();
    final handler = FeverServiceHandler.fromValues(
      _endpoint,
      apiKey,


@@ 154,7 182,7 @@ class _FeverPageState extends State<FeverPage> {
      ),
      MyListTile(
        title: Text(S.of(context).password),
        trailing: Text(_password.length == 0
        trailing: Text(_password.length == 0 && _apiKey == null
          ? S.of(context).enter
          : S.of(context).entered),
        onTap: _editPassword,

M lib/pages/settings/services/greader_page.dart => lib/pages/settings/services/greader_page.dart +22 -0
@@ 1,9 1,11 @@
import 'dart:convert';
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/greader.dart';
import 'package:fluent_reader_lite/models/services/service_import.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';


@@ 27,6 29,26 @@ class _GReaderPageState extends State<GReaderPage> {

  bool _validating = false;

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }
    });
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(

M lib/pages/settings/services/inoreader_page.dart => lib/pages/settings/services/inoreader_page.dart +29 -1
@@ 1,9 1,11 @@
import 'dart:convert';
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/greader.dart';
import 'package:fluent_reader_lite/models/services/service_import.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';


@@ 26,7 28,7 @@ class _InoreaderPageState extends State<InoreaderPage> {
    "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) ?? "";


@@ 38,6 40,32 @@ class _InoreaderPageState extends State<InoreaderPage> {

  bool _validating = false;

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      if (import == null) return;
      if (_endpointOptions.contains(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }
      if (Utils.notEmpty(import.apiId)) {
        setState(() { _apiId = import.apiId; });
      }
      if (Utils.notEmpty(import.apiKey)) {
        setState(() { _apiKey = import.apiKey; });
      }
    });
  }

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(

M lib/pages/settings/sources_page.dart => lib/pages/settings/sources_page.dart +2 -0
@@ 4,6 4,7 @@ import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';



@@ 19,6 20,7 @@ class SourcesPage extends StatelessWidget {
        Consumer<SourcesModel>(
          builder: (context, sourcesModel, child) {
            var sources = sourcesModel.getSources().toList();
            sources.sort((a, b) => Utils.localStringCompare(a.name, b.name));
            return ListTileGroup(sources.map((s) => MyListTile(
              title: Flexible(child: Text(s.name, overflow: TextOverflow.ellipsis)),
              leading: Favicon(s, size: 20),

M lib/pages/subscription_list_page.dart => lib/pages/subscription_list_page.dart +94 -2
@@ 33,6 33,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
  List<String> sids;
  String title;
  bool transitioning = false;
  bool unreadOnly = false;

  void _onScrollTop() {
    if (widget.scrollTopNotifier.index == 1 && !Navigator.of(context).canPop()) {


@@ 72,11 73,17 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
    }
    if (!mounted) return;
    if (result != null) {
      _onScrollTop();
      if (result.length == 0) {
        setState(() {
          title = null;
          sids = null;
        });
      } else if (result.length > 1) {
        setState(() {
          title = S.of(context).uncategorized;
          sids = Global.groupsModel.uncategorized;
        });
      } else {
        setState(() {
          title = result[0];


@@ 106,15 113,93 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
    }
  }

  void _toggleUnreadOnly() {
    HapticFeedback.mediumImpact();
    setState(() { unreadOnly = !unreadOnly; });
    _onScrollTop();
  }

  void _dismissTip() {
    if (Global.sourcesModel.showUnreadTip) {
      Global.sourcesModel.showUnreadTip = false;
      setState(() {});
    }
  }

  Widget _buildUnreadTip() {
    return SliverToBoxAdapter(child: Container(
      padding: EdgeInsets.all(16),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(16),
        child: Container(
          padding: EdgeInsets.all(12),
          color: CupertinoColors.secondarySystemBackground.resolveFrom(context),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: EdgeInsets.only(right: 12),
                child: Icon(Icons.radio_button_checked),
              ),
              Flexible(child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    S.of(context).unreadSourceTip,
                    style: TextStyle(
                      color: CupertinoColors.label.resolveFrom(context),
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  Padding(padding: EdgeInsets.only(bottom: 6)),
                  CupertinoButton(
                    minSize: 28,
                    padding: EdgeInsets.zero,
                    child: Text(S.of(context).confirm),
                    onPressed: _dismissTip,
                  ),
                ],
              )),
            ],
          ),
        ),
      ),
    ));
  }

  @override
  Widget build(BuildContext context) {
    final titleWidget = GestureDetector(
      onLongPress: _toggleUnreadOnly,
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            constraints: BoxConstraints(
              maxWidth: Global.isTablet
                ? 260
                : MediaQuery.of(context).size.width - 60,
            ),
            child: Text(
              title ?? S.of(context).subscriptions, 
              overflow: TextOverflow.ellipsis,
            ),
          ),
          if (unreadOnly) Padding(
            padding: EdgeInsets.only(left: 4),
            child: Icon(Icons.radio_button_checked, size: 18),
          ),
        ],
      ),
    );
    final navigationBar = CupertinoSliverNavigationBar(
      stretch: false,
      largeTitle: Text(title ?? S.of(context).subscriptions),
      largeTitle: titleWidget,
      heroTag: "subscriptions",
      transitionBetweenRoutes: true,
      backgroundColor: transitioning ? MyColors.tileBackground : CupertinoColors.systemBackground,
      leading: CupertinoButton(
        minSize: 36,
        padding: EdgeInsets.zero,
        child: Text(S.of(context).groups),
        onPressed: _openGroups,


@@ 149,10 234,16 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
        List<RSSSource> sources;
        if (sids == null) {
          sources = Global.sourcesModel.getSources().toList();
          if (unreadOnly) {
            sources = sources.where((s) => s.unreadCount > 0).toList();
          }
        } else {
          sources = [];
          for (var sid in sids) {
            sources.add(Global.sourcesModel.getSource(sid));
            final source = Global.sourcesModel.getSource(sid);
            if (!unreadOnly || source.unreadCount > 0) {
              sources.add(source);
            }
          }
        }
        // Latest sources first


@@ 203,6 294,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
      slivers: [
        navigationBar,
        SyncControl(),
        if (Global.sourcesModel.showUnreadTip) _buildUnreadTip(),
        if (sids != null) Consumer<SourcesModel>(
          builder: (context, sourcesModel, child) {
            var count = sids

M lib/utils/global.dart => lib/utils/global.dart +1 -1
@@ 59,7 59,7 @@ abstract class Global {
    db = await DatabaseHelper.getDatabase();
    await db.delete(
      "items",
      where: "date < ?",
      where: "date < ? AND starred = 0",
      whereArgs: [
        DateTime.now()
          .subtract(Duration(days: globalModel.keepItemsDays))

M lib/utils/store.dart => lib/utils/store.dart +17 -0
@@ 7,6 7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
abstract class StoreKeys {
  static const GROUPS = "groups";
  static const ERROR_LOG = "errorLog";
  static const UNCATEGORIZED = "uncategorized";
 
  // General
  static const THEME = "theme";


@@ 24,6 25,7 @@ abstract class StoreKeys {
  static const DIM_READ = "dimRead";
  static const FEED_SWIPE_R = "feedSwipeR";
  static const FEED_SWIPE_L = "feedSwipeL";
  static const UNREAD_SOURCE_TIP = "unreadSourceTip";

  // Reading preferences
  static const ARTICLE_FONT_SIZE = "articleFontSize";


@@ 91,6 93,21 @@ class Store {
    sp.setString(StoreKeys.GROUPS, jsonEncode(groups));
  }

  static List<String> getUncategorized() {
    final stored = sp.getString(StoreKeys.UNCATEGORIZED);
    if (stored == null) return null;
    final parsed = jsonDecode(stored);
    return List.castFrom(parsed);
  }

  static void setUncategorized(List<String> value) {
    if (value == null) {
      sp.remove(StoreKeys.UNCATEGORIZED);
    } else {
      sp.setString(StoreKeys.UNCATEGORIZED, jsonEncode(value));
    }
  }

  static int getArticleFontSize() {
    return sp.getInt(StoreKeys.ARTICLE_FONT_SIZE) ?? 16;
  }

M lib/utils/utils.dart => lib/utils/utils.dart +15 -2
@@ 2,6 2,7 @@ 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:lpinyin/lpinyin.dart';
import 'package:url_launcher/url_launcher.dart';

abstract class Utils {


@@ 46,9 47,9 @@ abstract class Utils {
    r"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*$)",
    caseSensitive: false,
  );
  static bool testUrl(String url) => _urlRegex.hasMatch(url.trim());
  static bool testUrl(String url) => url != null && _urlRegex.hasMatch(url.trim());

  static bool notEmpty(String text) => text.trim().length > 0;
  static bool notEmpty(String text) => text != null && text.trim().length > 0;

  static void showServiceFailureDialog(BuildContext context) {
    showCupertinoDialog(


@@ 65,4 66,16 @@ abstract class Utils {
      ),
    );
  }

  static int localStringCompare(String a, String b) {
    a = a.toLowerCase();
    b = b.toLowerCase();
    try {
      String ap = PinyinHelper.getShortPinyin(a);
      String bp = PinyinHelper.getShortPinyin(b);
      return ap.compareTo(bp);
    } catch(exp) {
      return a.compareTo(b);
    }
  }
}
\ No newline at end of file

M pubspec.lock => pubspec.lock +14 -0
@@ 238,6 238,13 @@ packages:
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "0.11.4"
  lpinyin:
    dependency: "direct main"
    description:
      name: lpinyin
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "1.1.0"
  matcher:
    dependency: transitive
    description:


@@ 537,6 544,13 @@ packages:
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "1.3.0-nullsafety.5"
  uni_links:
    dependency: "direct main"
    description:
      name: uni_links
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "0.4.0"
  url_launcher:
    dependency: "direct main"
    description:

M pubspec.yaml => pubspec.yaml +3 -1
@@ 15,7 15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+4
version: 1.0.1+5

environment:
  sdk: ">=2.7.0 <3.0.0"


@@ 43,6 43,8 @@ dependencies:
  responsive_builder: ^0.3.0
  cached_network_image: ^2.5.0
  flutter_cache_manager: ^2.1.0
  lpinyin: ^1.1.0
  uni_links: ^0.4.0
  modal_bottom_sheet: ^1.0.0+1
  overlay_dialog: ^0.0.3