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