items) {
66 | for (var feed in [all, source]) {
67 | if (feed == null) continue;
68 | var lastDate = feed.iids.length > 0
69 | ? Global.itemsModel.getItem(feed.iids.last).date
70 | : null;
71 | for (var item in items) {
72 | if (!feed.testItem(item)) continue;
73 | if (lastDate != null && item.date.isBefore(lastDate)) continue;
74 | var idx = Utils.binarySearch(feed.iids, item.id, (a, b) {
75 | return Global.itemsModel.getItem(b).date.compareTo(Global.itemsModel.getItem(a).date);
76 | });
77 | feed.iids.insert(idx, item.id);
78 | }
79 | }
80 | notifyListeners();
81 | }
82 |
83 | void initAll() {
84 | for (var feed in [all, source]) {
85 | if (feed == null) continue;
86 | feed.init();
87 | }
88 | }
89 | }
--------------------------------------------------------------------------------
/ios/Runner/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fluent Reader Lite
5 | A simplistic mobile RSS client
6 |
7 |
8 | ## Download
9 |
10 | ### iOS
11 |
12 | - [Download from App Store](https://apps.apple.com/app/id1549611796) ($1.99. This will support development and help cover the $99 annual fee.)
13 | - [Download from TestFlight](https://testflight.apple.com/join/9fwRtH8C) (Free. Inactive testers may be removed due to TestFlight restrictions.)
14 |
15 | ### Android
16 |
17 | - [Download from Google Play](https://play.google.com/store/apps/details?id=me.hyliu.fluent_reader_lite) ($1.99)
18 | - [Download APK from GitHub Releases](https://github.com/yang991178/fluent-reader-lite/releases) (Free)
19 |
20 | ### Desktop App
21 |
22 | The repo of the full-featured desktop app [can be found here](https://github.com/yang991178/fluent-reader).
23 |
24 | ## Features
25 |
26 |
27 |
28 |
29 |
30 | Fluent Reader Lite is a simplistic, cross-platform, and open-source RSS client.
31 |
32 | The following self-hosted and commercial RSS services are supported.
33 |
34 | - Fever API (TT-RSS Fever plugin, FreshRSS, Miniflux, etc.)
35 | - Google Reader API (Bazqux Reader, The Old Reader, etc.)
36 | - Inoreader
37 | - Feedbin (official or self-hosted)
38 |
39 | Other key features include:
40 |
41 | - Dark mode for UI and reading.
42 | - Configure sources to load full content or webpage by default.
43 | - A dedicated subscriptions tab organized by latest updates with article titles.
44 | - Search for local articles or filter by read status.
45 | - Organize subscriptions with groups.
46 | - Support for two-pane view and multitasking on iPad and Android tablets.
47 |
48 | The following features from the desktop app are **NOT** present:
49 |
50 | - Local RSS support and source / group management.
51 | - Importing or exporting OPML files, full application data backup & restoration.
52 | - Regular expression rules that mark articles as they arrive.
53 | - Fetch articles in the background and send push notifications.
54 | - Keyboard shortcuts.
55 |
56 | ## Development
57 |
58 | ### Contribute
59 |
60 | Help make Fluent Reader better by reporting bugs or opening feature requests through [GitHub issues](https://github.com/yang991178/fluent-reader-lite/issues).
61 |
62 | You can also help internationalize the app by providing [translations into additional languages](https://github.com/yang991178/fluent-reader-lite/tree/master/lib/l10n).
63 | You can read more about ARB files [here](https://localizely.com/flutter-arb) or [here](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification).
64 |
65 | If you enjoy using this app, consider supporting its development by donating through [Paypal](https://www.paypal.me/yang991178) or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg).
66 |
67 | ### Build from source
68 |
69 | See [Flutter documentation](https://flutter.dev/docs).
70 |
71 | ### Developed with
72 |
73 | - [Flutter](https://github.com/flutter/flutter)
74 | - [Mercury Parser](https://github.com/postlight/mercury-parser)
75 |
76 | ### License
77 |
78 | BSD
79 |
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]}
--------------------------------------------------------------------------------
/lib/utils/global.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/models/feeds_model.dart';
2 | import 'package:fluent_reader_lite/models/global_model.dart';
3 | import 'package:fluent_reader_lite/models/groups_model.dart';
4 | import 'package:fluent_reader_lite/models/items_model.dart';
5 | import 'package:fluent_reader_lite/models/service.dart';
6 | import 'package:fluent_reader_lite/models/services/feedbin.dart';
7 | import 'package:fluent_reader_lite/models/services/fever.dart';
8 | import 'package:fluent_reader_lite/models/services/greader.dart';
9 | import 'package:fluent_reader_lite/models/sources_model.dart';
10 | import 'package:fluent_reader_lite/models/sync_model.dart';
11 | import 'package:fluent_reader_lite/utils/db.dart';
12 | import 'package:fluent_reader_lite/utils/store.dart';
13 | import 'package:flutter/cupertino.dart';
14 | import 'package:jaguar/serve/server.dart';
15 | import 'package:jaguar_flutter_asset/jaguar_flutter_asset.dart';
16 | import 'package:sqflite/sqflite.dart';
17 |
18 | abstract class Global {
19 | static bool _initialized = false;
20 | static GlobalModel globalModel;
21 | static SourcesModel sourcesModel;
22 | static ItemsModel itemsModel;
23 | static FeedsModel feedsModel;
24 | static GroupsModel groupsModel;
25 | static SyncModel syncModel;
26 | static ServiceHandler service;
27 | static Database db;
28 | static Jaguar server;
29 | static final GlobalKey tabletPanel = GlobalKey();
30 |
31 | static void init() {
32 | assert(!_initialized);
33 | _initialized = true;
34 | globalModel = GlobalModel();
35 | sourcesModel = SourcesModel();
36 | itemsModel = ItemsModel();
37 | feedsModel = FeedsModel();
38 | groupsModel = GroupsModel();
39 | var serviceType = SyncService.values[Store.sp.getInt(StoreKeys.SYNC_SERVICE) ?? 0];
40 | switch (serviceType) {
41 | case SyncService.None:
42 | break;
43 | case SyncService.Fever:
44 | service = FeverServiceHandler();
45 | break;
46 | case SyncService.Feedbin:
47 | service = FeedbinServiceHandler();
48 | break;
49 | case SyncService.GReader:
50 | case SyncService.Inoreader:
51 | service = GReaderServiceHandler();
52 | break;
53 | }
54 | syncModel = SyncModel();
55 | _initContents();
56 | }
57 |
58 | static void _initContents() async {
59 | db = await DatabaseHelper.getDatabase();
60 | await db.delete(
61 | "items",
62 | where: "date < ? AND starred = 0",
63 | whereArgs: [
64 | DateTime.now()
65 | .subtract(Duration(days: globalModel.keepItemsDays))
66 | .millisecondsSinceEpoch,
67 | ],
68 | );
69 | server = Jaguar(address: "127.0.0.1",port: 9000);
70 | server.addRoute(serveFlutterAssets());
71 | await server.serve();
72 | await sourcesModel.init();
73 | await feedsModel.all.init();
74 | if (globalModel.syncOnStart) await syncModel.syncWithService();
75 | }
76 |
77 | static Brightness currentBrightness(BuildContext context) {
78 | return globalModel.getBrightness() ?? MediaQuery.of(context).platformBrightness;
79 | }
80 |
81 | static bool get isTablet => tabletPanel.currentWidget != null;
82 |
83 | static NavigatorState responsiveNavigator(BuildContext context) {
84 | return tabletPanel.currentWidget != null
85 | ? Global.tabletPanel.currentState
86 | : Navigator.of(context, rootNavigator: true);
87 | }
88 | }
--------------------------------------------------------------------------------
/lib/components/my_list_tile.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/utils/colors.dart';
2 | import 'package:flutter/cupertino.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class MyListTile extends StatefulWidget {
6 | final Widget leading;
7 | final Widget title;
8 | final Widget trailing;
9 | final bool trailingChevron;
10 | final bool withDivider;
11 | final Function onTap;
12 | final CupertinoDynamicColor background;
13 |
14 | MyListTile({
15 | this.leading,
16 | @required this.title,
17 | this.trailing,
18 | this.trailingChevron : true,
19 | this.withDivider : true,
20 | this.onTap,
21 | this.background : MyColors.tileBackground,
22 | Key key,
23 | }) : super(key: key);
24 |
25 | @override
26 | _MyListTileState createState() => _MyListTileState();
27 | }
28 |
29 | class _MyListTileState extends State {
30 | bool pressed = false;
31 |
32 | void _onTap() {
33 | if (widget.onTap != null) widget.onTap();
34 | }
35 |
36 | @override
37 | Widget build(BuildContext context) {
38 | final _titleStyle = TextStyle(
39 | fontSize: 16,
40 | color: CupertinoColors.label.resolveFrom(context),
41 | );
42 | final leftPart = Flexible(child: Row(
43 | children: [
44 | if (widget.leading != null) Container(
45 | padding: EdgeInsets.only(right: 16),
46 | width: 40,
47 | height: 24,
48 | child: widget.leading,
49 | ),
50 | DefaultTextStyle(
51 | child: widget.title,
52 | style: _titleStyle,
53 | ),
54 | ],
55 | ));
56 | final _labelStyle = TextStyle(
57 | fontSize: 16,
58 | color: CupertinoColors.secondaryLabel.resolveFrom(context),
59 | );
60 | final rightPart = Row(
61 | children: [
62 | if (widget.trailing != null) DefaultTextStyle(
63 | child: widget.trailing,
64 | style: _labelStyle,
65 | ),
66 | if (widget.trailingChevron) Icon(
67 | CupertinoIcons.chevron_forward,
68 | color: CupertinoColors.tertiaryLabel.resolveFrom(context),
69 | ),
70 | ],
71 | );
72 | return GestureDetector(
73 | onTapDown: (_) { setState(() { pressed = true; }); },
74 | onTapUp: (_) { setState(() { pressed = false; }); },
75 | onTapCancel: () { setState(() { pressed = false; }); },
76 | onTap: _onTap,
77 | child: Column(children: [
78 | Container(
79 | color: (pressed && widget.onTap != null)
80 | ? CupertinoColors.systemGrey4.resolveFrom(context)
81 | : widget.background.resolveFrom(context),
82 | padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6),
83 | constraints: BoxConstraints(minHeight: 48),
84 | child: Row(
85 | crossAxisAlignment: CrossAxisAlignment.center,
86 | mainAxisAlignment: MainAxisAlignment.spaceBetween,
87 | children: [
88 | leftPart,
89 | rightPart,
90 | ],
91 | ),
92 | ),
93 | if (widget.withDivider) Padding(
94 | padding: EdgeInsets.only(left: widget.leading == null ? 16 : 50),
95 | child: Divider(color: CupertinoColors.systemGrey4.resolveFrom(context), height: 0),
96 | ),
97 | ],),
98 | );
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
8 |
9 |
10 |
11 |
16 |
23 |
27 |
31 |
36 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
53 |
54 |
55 |
57 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/lib/l10n/intl_en.arb:
--------------------------------------------------------------------------------
1 | {
2 | "all": "All Articles",
3 | "unread": "Unread",
4 | "starred": "Starred",
5 | "allArticles": "All articles",
6 | "allSubscriptions": "All subscriptions",
7 | "filter": "Filter",
8 | "feed": "Feed",
9 | "subscriptions": "Subscriptions",
10 | "groups": "Groups",
11 | "settings": "Settings",
12 | "service": "Service",
13 | "preferences": "Preferences",
14 | "about": "About",
15 | "theme": "Theme",
16 | "followSystem": "Follow system",
17 | "light": "Light mode",
18 | "dark": "Dark mode",
19 | "language": "Language",
20 | "markRead": "Mark as read",
21 | "markUnread": "Mark as unread",
22 | "markAll": "Mark all as read",
23 | "markAbove": "Mark above as read",
24 | "markBelow": "Mark below as read",
25 | "daysAgo": "{days,plural, =1{Over 1 day ago}other{Over {days} days ago}}",
26 | "star": "Star",
27 | "unstar": "Unstar",
28 | "share": "Share",
29 | "cancel": "Cancel",
30 | "close": "Close",
31 | "save": "Save",
32 | "reading": "Reading",
33 | "account": "Account",
34 | "app": "App",
35 | "general": "General",
36 | "version": "Version",
37 | "openSource": "Open source project",
38 | "feedback": "Feedback",
39 | "showThumb": "Show thumbnail",
40 | "showSnippet": "Show snippet",
41 | "dimRead": "Dim read articles",
42 | "gestures": "Gestures",
43 | "swipeLeft": "Swipe left",
44 | "swipeRight": "Swipe right",
45 | "toggleRead": "Toggle read",
46 | "toggleStar": "Toggle star",
47 | "openMenu": "Open menu",
48 | "openExternal": "Open externally",
49 | "fontSize": "Font size",
50 | "edit": "Edit",
51 | "name": "Name",
52 | "icon": "Icon",
53 | "openTarget": "Default open target",
54 | "rssText": "RSS full text",
55 | "loadWebpage": "Load webpage",
56 | "loadFull": "Load full content",
57 | "invalidValue": "Invalid value",
58 | "unreadOnly": "Unread only",
59 | "starredOnly": "Starred only",
60 | "search": "Search",
61 | "editKeyword": "Edit keyword",
62 | "clearSearch": "Clear search",
63 | "storage": "Storage",
64 | "clearCache": "Clear cache",
65 | "autoDelete": "Auto delete items",
66 | "sync": "Sync",
67 | "syncOnStart": "Sync on start",
68 | "inAppBrowser": "In-app browser",
69 | "lastSyncSuccess": "Last sync successful at",
70 | "lastSyncFailure": "Last sync failed at",
71 | "welcome": "Welcome",
72 | "credentials": "Credentials",
73 | "endpoint": "Endpoint",
74 | "username": "Username",
75 | "password": "Password",
76 | "fetchLimit": "Fetch limit",
77 | "enter": "Required",
78 | "entered": "Entered",
79 | "serviceFailure": "Cannot connect to service",
80 | "serviceFailureHint": "Please check the service configuration or network status.",
81 | "logOut": "Log out",
82 | "logOutWarning": "All local data will be deleted. Are you sure?",
83 | "confirm": "Confirm",
84 | "allLoaded": "All loaded",
85 | "removeAd": "Remove Ad",
86 | "getApiKey": "Get API ID & Key",
87 | "getApiKeyHint": "In \"Preferences\" > \"Developer\"",
88 | "prev": "Previous",
89 | "next": "Next",
90 | "wentWrong": "Something went wrong.",
91 | "retry": "Retry",
92 | "copy": "Copy",
93 | "errorLog": "Error log",
94 | "unreadSourceTip": "You can long press on the title of this page to toggle between all and unread subscriptions.",
95 | "uncategorized": "Uncategorized",
96 | "showUncategorized": "Show uncategorized",
97 | "serviceExists": "A service already exists. Please log out before importing."
98 | }
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
54 |
56 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: fluent_reader_lite
2 | description: A new Flutter project.
3 |
4 | # The following line prevents the package from being accidentally published to
5 | # pub.dev using `pub publish`. This is preferred for private packages.
6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev
7 |
8 | # The following defines the version and build number for your application.
9 | # A version number is three numbers separated by dots, like 1.2.43
10 | # followed by an optional build number separated by a +.
11 | # Both the version and the builder number may be overridden in flutter
12 | # build by specifying --build-name and --build-number, respectively.
13 | # In Android, build-name is used as versionName while build-number used as versionCode.
14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning
15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
16 | # Read more about iOS versioning at
17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
18 | version: 1.0.2+8
19 |
20 | environment:
21 | sdk: ">=2.7.0 <3.0.0"
22 |
23 | dependencies:
24 | flutter:
25 | sdk: flutter
26 | flutter_localizations:
27 | sdk: flutter
28 | provider: ^5.0.0
29 | tuple: ^2.0.0
30 | shared_preferences: ^2.0.6
31 | intl: ^0.17.0-nullsafety.2
32 | http: ^0.13.3
33 | html: ^0.15.0
34 | webview_flutter: ^2.0.10
35 | jaguar: ^3.0.11
36 | jaguar_flutter_asset: ^3.0.0
37 | url_launcher: ^6.0.9
38 | sqflite: ^2.0.0+3
39 | path: ^1.8.0
40 | share: ^2.0.4
41 | package_info: ^2.0.2
42 | crypto: ^3.0.1
43 | responsive_builder: ^0.4.1
44 | cached_network_image: ^3.0.0
45 | flutter_cache_manager: ^3.1.2
46 | lpinyin: ^2.0.3
47 | uni_links: ^0.5.1
48 | modal_bottom_sheet: ^2.0.0
49 | overlay_dialog: ^0.2.0
50 |
51 |
52 | # The following adds the Cupertino Icons font to your application.
53 | # Use with the CupertinoIcons class for iOS style icons.
54 | cupertino_icons: ^1.0.0
55 |
56 | dev_dependencies:
57 | flutter_test:
58 | sdk: flutter
59 |
60 | # For information on the generic Dart part of this file, see the
61 | # following page: https://dart.dev/tools/pub/pubspec
62 |
63 | # The following section is specific to Flutter.
64 | flutter:
65 |
66 | # The following line ensures that the Material Icons font is
67 | # included with your application, so that you can use the icons in
68 | # the material Icons class.
69 | uses-material-design: true
70 |
71 | # To add assets to your application, add an assets section, like this:
72 | assets:
73 | - assets/article/
74 | - assets/icons/
75 |
76 | # An image asset can refer to one or more resolution-specific "variants", see
77 | # https://flutter.dev/assets-and-images/#resolution-aware.
78 |
79 | # For details regarding adding assets from package dependencies, see
80 | # https://flutter.dev/assets-and-images/#from-packages
81 |
82 | # To add custom fonts to your application, add a fonts section here,
83 | # in this "flutter" section. Each entry in this list should have a
84 | # "family" key with the font family name, and a "fonts" key with a
85 | # list giving the asset and other descriptors for the font. For
86 | # example:
87 | # fonts:
88 | # - family: Schyler
89 | # fonts:
90 | # - asset: fonts/Schyler-Regular.ttf
91 | # - asset: fonts/Schyler-Italic.ttf
92 | # style: italic
93 | # - family: Trajan Pro
94 | # fonts:
95 | # - asset: fonts/TrajanPro.ttf
96 | # - asset: fonts/TrajanPro_Bold.ttf
97 | # weight: 700
98 | #
99 | # For details regarding fonts from package dependencies,
100 | # see https://flutter.dev/custom-fonts/#from-packages
101 |
102 | flutter_intl:
103 | enabled: true
104 |
--------------------------------------------------------------------------------
/lib/l10n/intl_hr.arb:
--------------------------------------------------------------------------------
1 | {
2 | "all": "Svi članci",
3 | "unread": "Nepročitano",
4 | "starred": "Označeno",
5 | "allArticles": "Svi članci",
6 | "allSubscriptions": "Sve pretplate",
7 | "filter": "Filter",
8 | "feed": "Novosti",
9 | "subscriptions": "Pretplate",
10 | "groups": "Grupe",
11 | "settings": "Postavke",
12 | "service": "Servis",
13 | "preferences": "Preferencije",
14 | "about": "Više informacija",
15 | "theme": "Tema",
16 | "followSystem": "Slijedi sustav",
17 | "light": "Svijetla tema",
18 | "dark": "Tamna tema",
19 | "language": "Jezik",
20 | "markRead": "Označi kao pročitano",
21 | "markUnread": "Označi kao nepročitano",
22 | "markAll": "Označi sve kao pročitano",
23 | "markAbove": "Označi gornje kao pročitano",
24 | "markBelow": "Označi donje kao pročitano",
25 | "daysAgo": "{days,plural, =1{Prije više od jednog dana}other{Prije više od {days} dana}}",
26 | "star": "Označi",
27 | "unstar": "Odznači",
28 | "share": "Podijeli",
29 | "cancel": "Odustani",
30 | "close": "Zatvori",
31 | "save": "Spremi",
32 | "reading": "Čitanje",
33 | "account": "Račun",
34 | "app": "Aplikacija",
35 | "general": "Općenito",
36 | "version": "Verzija",
37 | "openSource": "Projekt otvorenog kôda",
38 | "feedback": "Povratne informacije",
39 | "showThumb": "Prikaži sličicu",
40 | "showSnippet": "Prikaži izrezak",
41 | "dimRead": "Potamni pročitane članke",
42 | "gestures": "Geste",
43 | "swipeLeft": "Potez ulijevo",
44 | "swipeRight": "Potez udesno",
45 | "toggleRead": "Promijeni pročitano/nepročitano",
46 | "toggleStar": "Promijeni oznaku",
47 | "openMenu": "Otvori izbornik",
48 | "openExternal": "Otvori u vanjskoj aplikaciji",
49 | "fontSize": "Veličina fonta",
50 | "edit": "Uredi",
51 | "name": "Naziv",
52 | "icon": "Ikona",
53 | "openTarget": "Zadani cilj otvaranja",
54 | "rssText": "Puni tekst RSS-a",
55 | "loadWebpage": "Učitaj web-stranicu",
56 | "loadFull": "Učitaj puni sadržaj",
57 | "invalidValue": "Nevažeća vrijednost",
58 | "unreadOnly": "Samo nepročitano",
59 | "starredOnly": "Samo označeno",
60 | "search": "Pretraživanje",
61 | "editKeyword": "Uredi ključnu riječ",
62 | "clearSearch": "Očisti pretragu",
63 | "storage": "Pohrana",
64 | "clearCache": "Očisti predmemoriju",
65 | "autoDelete": "Automatski briši stavke",
66 | "sync": "Sinkronizacija",
67 | "syncOnStart": "Sink. kod pokretanja",
68 | "inAppBrowser": "Preglednik u aplikaciji",
69 | "lastSyncSuccess": "Posljednja uspješna sinkronizacija",
70 | "lastSyncFailure": "Posljednja neuspješna sinkronizacija",
71 | "welcome": "Dobro došli",
72 | "credentials": "Podatci za prijavu",
73 | "endpoint": "Endpoint",
74 | "username": "Korisničko ime",
75 | "password": "Lozinka",
76 | "fetchLimit": "Ograničenje dohvaćanja",
77 | "enter": "Potrebno",
78 | "entered": "Uneseno",
79 | "serviceFailure": "Nije moguće povezati se na servis",
80 | "serviceFailureHint": "Provjerite konfiguraciju servisa ili status mreže.",
81 | "logOut": "Odjava",
82 | "logOutWarning": "Svi lokalni podatci bit će izbrisani. Želite li nastaviti?",
83 | "confirm": "Potvrdi",
84 | "allLoaded": "Sve učitano",
85 | "removeAd": "Ukloni oglas",
86 | "getApiKey": "Preuzmi ID i ključ za API",
87 | "getApiKeyHint": "U \"Preferences\" > \"Developer\"",
88 | "prev": "Prethodno",
89 | "next": "Sljedeće",
90 | "wentWrong": "Dogodila se greška.",
91 | "retry": "Pokušaj ponovno",
92 | "copy": "Kopiraj",
93 | "errorLog": "Izvješće o grešci",
94 | "unreadSourceTip": "Možete dugo pritisnuti naslov ove stranice za prebacivanje između svih i nepročitanih pretplata.",
95 | "uncategorized": "Nekategorizirano",
96 | "showUncategorized": "Prikaži nekategorizirano",
97 | "serviceExists": "Servis već postoji. Odjavite se prije uvoza."
98 | }
99 |
--------------------------------------------------------------------------------
/lib/l10n/intl_ptBR.arb:
--------------------------------------------------------------------------------
1 | {
2 | "all": "Todos",
3 | "unread": "Não lido",
4 | "starred": "Favoritos",
5 | "allArticles": "Todos os artigos",
6 | "allSubscriptions": "Todas as inscrições",
7 | "filter": "Filtro",
8 | "feed": "Feed",
9 | "subscriptions": "Inscrições",
10 | "groups": "Grupos",
11 | "settings": "Configurações",
12 | "service": "Serviço",
13 | "preferences": "Preferências",
14 | "about": "Sobre",
15 | "theme": "Tema",
16 | "followSystem": "Seguir o do sistema",
17 | "light": "Modo claro",
18 | "dark": "Modo escuro",
19 | "language": "Linguagem",
20 | "markRead": "Marcar como lido",
21 | "markUnread": "Marcar como não lido",
22 | "markAll": "Marcar todos como lido",
23 | "markAbove": "Macar artigo(s) de cima como lido(s)",
24 | "markBelow": "Marcar artigo(s) de baixo como lido(s)",
25 | "daysAgo": "{days,plural, =1{Mais de um dia atrás}other{Mais de {days} dias atrás}}",
26 | "star": "Marcar como favorito",
27 | "unstar": "Remover como favorito",
28 | "share": "Compartilhar",
29 | "cancel": "Cancelar",
30 | "close": "Fechar",
31 | "save": "Salvar",
32 | "reading": "Lendo",
33 | "account": "Conta",
34 | "app": "App",
35 | "general": "Geral",
36 | "version": "Versão",
37 | "openSource": "Projeto de código aberto",
38 | "feedback": "Feedback",
39 | "showThumb": "Mostrar capa",
40 | "showSnippet": "Mostrar trecho",
41 | "dimRead": "Ocultar artigos lidos",
42 | "gestures": "Gestos",
43 | "swipeLeft": "Mover para a esquerda",
44 | "swipeRight": "Mover para a direita",
45 | "toggleRead": "Alternar lidos",
46 | "toggleStar": "Alternar favoritos",
47 | "openMenu": "Abrir menu",
48 | "openExternal": "Abrir externamente",
49 | "fontSize": "Tamanho da fonte",
50 | "edit": "Editar",
51 | "name": "Nome",
52 | "icon": "ícone",
53 | "openTarget": "Modo de abertura padrão",
54 | "rssText": "Texto completo RSS",
55 | "loadWebpage": "Carregar página web",
56 | "loadFull": "Carregar todo conteúdo",
57 | "invalidValue": "Url inválida",
58 | "unreadOnly": "Somente não lidos",
59 | "starredOnly": "Somente favoritos",
60 | "search": "Pesquisar",
61 | "editKeyword": "Editar palavra",
62 | "clearSearch": "Limpar pesquisa",
63 | "storage": "Armazenamento",
64 | "clearCache": "Limpar cache",
65 | "autoDelete": "Auto deletar itens",
66 | "sync": "Sincronizar",
67 | "syncOnStart": "Sincronizar ao iniciar",
68 | "inAppBrowser": "Navegador interno",
69 | "lastSyncSuccess": "Última sincronização de sucesso às",
70 | "lastSyncFailure": "última sincronização falha às",
71 | "welcome": "Bem-vindo",
72 | "credentials": "Credenciais",
73 | "endpoint": "Ponto de saída",
74 | "username": "Usuário",
75 | "password": "Senha",
76 | "fetchLimit": "Limite de busca",
77 | "enter": "Requerido",
78 | "entered": "Entrar",
79 | "serviceFailure": "Não foi possível conectar com o serviço",
80 | "serviceFailureHint": "Por favor olhe as configurações do serviço ou sua conexão de rede",
81 | "logOut": "Sair",
82 | "logOutWarning": "Todos os dados serão apagados. Prosseguir mesmo assim?",
83 | "confirm": "Confirmar",
84 | "allLoaded": "Todos carregados",
85 | "removeAd": "Remover anúncio",
86 | "getApiKey": "Obter ID da API & Key",
87 | "getApiKeyHint": "Em \"Preferências\" > \"Desenvolvedor\"",
88 | "prev": "Anterior",
89 | "next": "Próximo",
90 | "wentWrong": "Algo deu errado.",
91 | "retry": "Tentar novamente",
92 | "copy": "Copiar",
93 | "errorLog": "Registro de erro",
94 | "unreadSourceTip": "Você pode pressionar de forma longa o título dessa página para alternar entre todas as inscrições e as não lidas.",
95 | "uncategorized": "Não categorizado",
96 | "showUncategorized": "Mostrar não categorizado",
97 | "serviceExists": "Um serviço já existe. Por favor saia antes de importar."
98 | }
99 |
--------------------------------------------------------------------------------
/lib/l10n/intl_es.arb:
--------------------------------------------------------------------------------
1 | {
2 | "all": "Todos",
3 | "unread": "Sin leídos",
4 | "starred": "Favoritos",
5 | "allArticles": "Todos los artículos",
6 | "allSubscriptions": "Todas las suscripciones",
7 | "filter": "Filtrar",
8 | "feed": "Artículos",
9 | "subscriptions": "Suscriptores",
10 | "groups": "Grupos",
11 | "settings": "Ajustes",
12 | "service": "Servicio",
13 | "preferences": "Ajustes",
14 | "about": "Acerca de",
15 | "theme": "Tema",
16 | "followSystem": "Seguir sistema",
17 | "light": "Modo claro",
18 | "dark": "Modo oscuro",
19 | "language": "Idioma",
20 | "markRead": "Marcar como leído",
21 | "markUnread": "Marcar como no leído",
22 | "markAll": "Marcar a todos como leído",
23 | "markAbove": "Marcar arriba como leído",
24 | "markBelow": "Marcar abajo como leído",
25 | "daysAgo": "{days,plural, =1{Hace más de 1 día}other{Hace más de {days} días}}",
26 | "star": "Favorito",
27 | "unstar": "No favorito",
28 | "share": "Compartir",
29 | "cancel": "Cancelar",
30 | "close": "Cerrar",
31 | "save": "Guardar",
32 | "reading": "Leyendo",
33 | "account": "Cuenta",
34 | "app": "App",
35 | "general": "General",
36 | "version": "Versión",
37 | "openSource": "Proyecto de código abierto",
38 | "feedback": "Comentarios",
39 | "showThumb": "Mostrar miniatura",
40 | "showSnippet": "Mostrar extracto",
41 | "dimRead": "Ocultar artículos leídos",
42 | "gestures": "Gestos",
43 | "swipeLeft": "Deslizar a la izquierda",
44 | "swipeRight": "Deslizar a la derecha",
45 | "toggleRead": "Alternar leídos",
46 | "toggleStar": "Alternar favoritos",
47 | "openMenu": "Abrir menú",
48 | "openExternal": "Abrir externamente",
49 | "fontSize": "Tamaño de fuente",
50 | "edit": "Editar",
51 | "name": "Nombre",
52 | "icon": "Icono",
53 | "openTarget": "Modo de apertura predeterminado",
54 | "rssText": "Texto RSS completo",
55 | "loadWebpage": "Cargar página web",
56 | "loadFull": "Cargar todo el contenido",
57 | "invalidValue": "Valor no válido",
58 | "unreadOnly": "Solo sin leídos",
59 | "starredOnly": "Solo favoritos",
60 | "search": "Buscar",
61 | "editKeyword": "Editar palabra",
62 | "clearSearch": "Limpiar búsqueda",
63 | "storage": "Almacenamiento",
64 | "clearCache": "Limpiar caché",
65 | "autoDelete": "Autoborrar artículos",
66 | "sync": "Sincronización",
67 | "syncOnStart": "Sincronizar al inicio",
68 | "inAppBrowser": "Navegador interno",
69 | "lastSyncSuccess": "Última sincronización exitosa a las",
70 | "lastSyncFailure": "Última sincronización fallida a las",
71 | "welcome": "Bienvenido",
72 | "credentials": "Credenciales",
73 | "endpoint": "Punto de salida",
74 | "username": "Usuario",
75 | "password": "Contraseña",
76 | "fetchLimit": "Límite de solicitudes",
77 | "enter": "Requerido",
78 | "entered": "Ingresado",
79 | "serviceFailure": "No se pudo conectar al servicio",
80 | "serviceFailureHint": "Por favor, compruebe la configuración del servicio o el estado de la red.",
81 | "logOut": "Salir",
82 | "logOutWarning": "Todos los datos locales serán eliminados. ¿Está seguro?",
83 | "confirm": "Confirmar",
84 | "allLoaded": "Todos cargados",
85 | "removeAd": "Borrar Ad",
86 | "getApiKey": "Obtener API ID & Key",
87 | "getApiKeyHint": "En \"Ajustes\" > \"Desarrollo\"",
88 | "prev": "Anterior",
89 | "next": "Siguiente",
90 | "wentWrong": "Algo pasó mal.",
91 | "retry": "Reintentar",
92 | "copy": "Copiar",
93 | "errorLog": "Registro de error",
94 | "unreadSourceTip": "Puede hacer una pulsación larga en el título de esta página para alternar entre todas las suscripciones y las no leídas.",
95 | "uncategorized": "Sin categorizar",
96 | "showUncategorized": "Mostrar sin categorizar",
97 | "serviceExists": "Ya existe este servicio. Por favor, cierre la sesión antes de importar."
98 | }
99 |
--------------------------------------------------------------------------------
/lib/l10n/intl_de.arb:
--------------------------------------------------------------------------------
1 | {
2 | "all": "Alle Artikel",
3 | "unread": "Ungelesen",
4 | "starred": "Favorisiert",
5 | "allArticles": "Alle Artikel",
6 | "allSubscriptions": "Alle Abonnements",
7 | "filter": "Filter",
8 | "feed": "Feed",
9 | "subscriptions": "Abonnements",
10 | "groups": "Kategorien",
11 | "settings": "Einstellungen",
12 | "service": "Service",
13 | "preferences": "Voreinstellungen",
14 | "about": "Info",
15 | "theme": "Theme",
16 | "followSystem": "Folge System",
17 | "light": "Heller Modus",
18 | "dark": "Dunkler Modus",
19 | "language": "Sprache",
20 | "markRead": "Markiere als gelesen",
21 | "markUnread": "Markiere als ungelesen",
22 | "markAll": "Markiere alles als gelesen",
23 | "markAbove": "Markiere darüber als gelesen",
24 | "markBelow": "Markiere darunter als gelesen",
25 | "daysAgo": "{days,plural, =1{Älter als ein Tag}other{Älter als {days} Tage}}",
26 | "star": "Favorisieren",
27 | "unstar": "Favorit entfernen",
28 | "share": "Teilen",
29 | "cancel": "Abbrechen",
30 | "close": "Schließen",
31 | "save": "Speichern",
32 | "reading": "Reading",
33 | "account": "Konto",
34 | "app": "Anwendung",
35 | "general": "Allgemein",
36 | "version": "Version",
37 | "openSource": "Öffne Source Projekt",
38 | "feedback": "Rückmeldung",
39 | "showThumb": "Zeige Miniaturbild",
40 | "showSnippet": "Zeige Schnipsel",
41 | "dimRead": "Verdunkle gelesene Artikel",
42 | "gestures": "Gesten",
43 | "swipeLeft": "Nach links wischen",
44 | "swipeRight": "Nach rechts wischen",
45 | "toggleRead": "Wechsel gelesen/ungelesen",
46 | "toggleStar": "Wechsel Favorit",
47 | "openMenu": "Menü offenen",
48 | "openExternal": "Extern öffnen",
49 | "fontSize": "Schriftgröße",
50 | "edit": "Editieren",
51 | "name": "Name",
52 | "icon": "Icon",
53 | "openTarget": "Voreinstellung beim Öffnen",
54 | "rssText": "Kompletter RSS Text",
55 | "loadWebpage": "Lade Webseite",
56 | "loadFull": "Lade gesamten Inhalt",
57 | "invalidValue": "Ungültiger Wert",
58 | "unreadOnly": "Nur ungelesene",
59 | "starredOnly": "Nur Favoriten",
60 | "search": "Suchen",
61 | "editKeyword": "Schlagwort editieren",
62 | "clearSearch": "Suche löschen",
63 | "storage": "Speicher",
64 | "clearCache": "Lösche Cache",
65 | "autoDelete": "Einträge automatisch löschen",
66 | "sync": "Synchronisation",
67 | "syncOnStart": "Synchronisiere beim Start",
68 | "inAppBrowser": "Eingebetteter Browser",
69 | "lastSyncSuccess": "Zuletzt erfolgreich synchronisiert um",
70 | "lastSyncFailure": "Zuletzt fehlerhaft synchronisiert um",
71 | "welcome": "Willkommen",
72 | "credentials": "Zugangsdaten",
73 | "endpoint": "Adresse",
74 | "username": "Benutzername",
75 | "password": "Passwort",
76 | "fetchLimit": "Abruflimit",
77 | "enter": "Erforderlich",
78 | "entered": "Eingegeben",
79 | "serviceFailure": "Verbindung mit Service nicht möglich",
80 | "serviceFailureHint": "Bitte überprüfen sie die Service Einstellungen oder den Network Status.",
81 | "logOut": "Abmelden",
82 | "logOutWarning": "Alle lokalen Daten werden gelöscht. Sind Sie sicher?",
83 | "confirm": "Bestätigung",
84 | "allLoaded": "Alles geladen",
85 | "removeAd": "Werbung entfernen",
86 | "getApiKey": "API ID & Schlüssel holen",
87 | "getApiKeyHint": "Unter \"Einstellungen\" > \"Entwickler\"",
88 | "prev": "Zurück",
89 | "next": "Nächstes",
90 | "wentWrong": "Etwas ist schief gegangen.",
91 | "retry": "Wiederholen",
92 | "copy": "Kopieren",
93 | "errorLog": "Fehler Protokoll",
94 | "unreadSourceTip": "Langdruck auf den Titel dieser Seite zum Wechsel der Ansicht zwischen aller oder ungelesender Abonnements.",
95 | "uncategorized": "Ohne Kategorie",
96 | "showUncategorized": "Zeige ohne Kategorie",
97 | "serviceExists": "Ein Service ist schon eingestellt. Bitte vor dem Import abmelden."
98 | }
--------------------------------------------------------------------------------
/lib/models/feed.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/utils/global.dart';
2 | import 'package:fluent_reader_lite/utils/store.dart';
3 | import 'package:tuple/tuple.dart';
4 |
5 | import 'item.dart';
6 |
7 | enum FilterType {
8 | All, Unread, Starred
9 | }
10 |
11 | const _LOAD_LIMIT = 50;
12 |
13 | class RSSFeed {
14 | bool initialized = false;
15 | bool loading = false;
16 | bool allLoaded = false;
17 | Set sids;
18 | List iids = [];
19 | FilterType filterType;
20 | String search = "";
21 |
22 | RSSFeed({this.sids}) {
23 | if (sids == null) sids = Set();
24 | filterType = FilterType.values[Store.sp.getInt(_filterKey) ?? 0];
25 | }
26 |
27 | String get _filterKey => sids.length == 0
28 | ? StoreKeys.FEED_FILTER_ALL
29 | : StoreKeys.FEED_FILTER_SOURCE;
30 |
31 | Tuple2> _getPredicates() {
32 | List where = ["1 = 1"];
33 | List whereArgs = [];
34 | if (sids.length > 0) {
35 | var placeholders = List.filled(sids.length, "?").join(" , ");
36 | where.add("source IN ($placeholders)");
37 | whereArgs.addAll(sids);
38 | }
39 | if (filterType == FilterType.Unread) {
40 | where.add("hasRead = 0");
41 | } else if (filterType == FilterType.Starred) {
42 | where.add("starred = 1");
43 | }
44 | if (search != "") {
45 | where.add("(UPPER(title) LIKE ? OR UPPER(snippet) LIKE ?)");
46 | var keyword = "%$search%".toUpperCase();
47 | whereArgs.add(keyword);
48 | whereArgs.add(keyword);
49 | }
50 | return Tuple2(where.join(" AND "), whereArgs);
51 | }
52 |
53 | bool testItem(RSSItem item) {
54 | if (sids.length > 0 && !sids.contains(item.source)) return false;
55 | if (filterType == FilterType.Unread && item.hasRead) return false;
56 | if (filterType == FilterType.Starred && !item.starred) return false;
57 | if (search != "") {
58 | var keyword = search.toUpperCase();
59 | if (item.title.toUpperCase().contains(keyword)) return true;
60 | if (item.snippet.toUpperCase().contains(keyword)) return true;
61 | return false;
62 | }
63 | return true;
64 | }
65 |
66 | Future init() async {
67 | if (loading) return;
68 | loading = true;
69 | var predicates = _getPredicates();
70 | var items = (await Global.db.query(
71 | "items",
72 | orderBy: "date DESC",
73 | limit: _LOAD_LIMIT,
74 | where: predicates.item1,
75 | whereArgs: predicates.item2,
76 | )).map((m) => RSSItem.fromMap(m)).toList();
77 | allLoaded = items.length < _LOAD_LIMIT;
78 | Global.itemsModel.loadItems(items);
79 | iids = items.map((i) => i.id).toList();
80 | loading = false;
81 | initialized = true;
82 | Global.feedsModel.broadcast();
83 | }
84 |
85 | Future loadMore() async {
86 | if (loading || allLoaded) return;
87 | loading = true;
88 | var predicates = _getPredicates();
89 | var offset = iids
90 | .map((iid) => Global.itemsModel.getItem(iid))
91 | .fold(0, (c, i) => c + (testItem(i) ? 1 : 0));
92 | var items = (await Global.db.query(
93 | "items",
94 | orderBy: "date DESC",
95 | limit: _LOAD_LIMIT,
96 | offset: offset,
97 | where: predicates.item1,
98 | whereArgs: predicates.item2,
99 | )).map((m) => RSSItem.fromMap(m)).toList();
100 | if (items.length < _LOAD_LIMIT) {
101 | allLoaded = true;
102 | }
103 | Global.itemsModel.loadItems(items);
104 | iids.addAll(items.map((i) => i.id));
105 | loading = false;
106 | Global.feedsModel.broadcast();
107 | }
108 |
109 | Future setFilter(FilterType filter) async {
110 | if (filterType == filter && filter == FilterType.All) return;
111 | filterType = filter;
112 | Store.sp.setInt(_filterKey, filter.index);
113 | await init();
114 | }
115 |
116 | Future performSearch(String keyword) async {
117 | if (search == keyword) return;
118 | search = keyword;
119 | await init();
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/lib/pages/settings/text_editor_page.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:fluent_reader_lite/components/list_tile_group.dart';
4 | import 'package:fluent_reader_lite/components/my_list_tile.dart';
5 | import 'package:fluent_reader_lite/generated/l10n.dart';
6 | import 'package:fluent_reader_lite/utils/colors.dart';
7 | import 'package:flutter/cupertino.dart';
8 |
9 | class TextEditorPage extends StatefulWidget {
10 | final String title;
11 | final String saveText;
12 | final String initialValue;
13 | final Color navigationBarColor;
14 | final FutureOr Function(String) validate;
15 | final TextInputType inputType;
16 | final bool autocorrect;
17 | final List suggestions;
18 |
19 | TextEditorPage(
20 | this.title,
21 | this.validate,
22 | {
23 | this.navigationBarColor,
24 | this.saveText,
25 | this.initialValue: "",
26 | this.inputType,
27 | this.autocorrect: false,
28 | this.suggestions,
29 | Key key,
30 | })
31 | : super(key: key);
32 |
33 | @override
34 | _TextEditorPage createState() => _TextEditorPage();
35 | }
36 |
37 | class _TextEditorPage extends State {
38 | TextEditingController _controller;
39 | bool _validating = false;
40 |
41 | @override
42 | void initState() {
43 | super.initState();
44 | _controller = TextEditingController(text: widget.initialValue);
45 | }
46 |
47 | void _onSave() async {
48 | setState(() { _validating = true; });
49 | var trimmed = _controller.text.trim();
50 | var valid = await widget.validate(trimmed);
51 | if (!mounted) return;
52 | setState(() { _validating = false; });
53 | if (valid) {
54 | Navigator.of(context).pop(trimmed);
55 | } else {
56 | showCupertinoDialog(
57 | context: context,
58 | builder: (context) => CupertinoAlertDialog(
59 | title: Text(S.of(context).invalidValue),
60 | actions: [
61 | CupertinoDialogAction(
62 | child: Text(S.of(context).close),
63 | isDefaultAction: true,
64 | onPressed: () { Navigator.of(context).pop(); },
65 | ),
66 | ],
67 | ),
68 | );
69 | }
70 | }
71 |
72 | @override
73 | Widget build(BuildContext context) {
74 | return CupertinoPageScaffold(
75 | backgroundColor: MyColors.background,
76 | navigationBar: CupertinoNavigationBar(
77 | middle: Text(widget.title),
78 | backgroundColor: widget.navigationBarColor,
79 | trailing: CupertinoButton(
80 | padding: EdgeInsets.zero,
81 | child: _validating
82 | ? CupertinoActivityIndicator()
83 | : Text(widget.saveText ?? S.of(context).save),
84 | onPressed: _validating ? null : _onSave,
85 | ),
86 | ),
87 | child: ListView(children: [
88 | ListTileGroup([
89 | CupertinoTextField(
90 | controller: _controller,
91 | decoration: null,
92 | padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
93 | clearButtonMode: OverlayVisibilityMode.editing,
94 | readOnly: _validating,
95 | autofocus: true,
96 | obscureText: widget.inputType == TextInputType.visiblePassword,
97 | keyboardType: widget.inputType,
98 | onSubmitted: (v) { _onSave(); },
99 | autocorrect: widget.autocorrect,
100 | enableSuggestions: widget.autocorrect,
101 | ),
102 | ]),
103 | if (widget.suggestions != null) ...widget.suggestions.map((s) {
104 | return MyListTile(
105 | title: Flexible(child: Text(
106 | s,
107 | style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)),
108 | overflow: TextOverflow.ellipsis,
109 | )),
110 | trailingChevron: false,
111 | background: MyColors.background,
112 | onTap: () { _controller.text = s; },
113 | );
114 | })
115 | ]),
116 | );
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/lib/components/cupertino_toolbar.dart:
--------------------------------------------------------------------------------
1 | /*
2 | * cupertino_toolbar
3 | * Copyright (c) 2019 Christian Mengler. All rights reserved.
4 | * See LICENSE for distribution and usage details.
5 | */
6 |
7 | import 'package:fluent_reader_lite/utils/colors.dart';
8 | import 'package:fluent_reader_lite/utils/global.dart';
9 | import 'package:flutter/cupertino.dart';
10 | import 'package:flutter/widgets.dart';
11 |
12 | /// Display a persistent bottom iOS styled toolbar for Cupertino theme
13 | ///
14 | class CupertinoToolbar extends StatelessWidget {
15 | /// Creates a persistent bottom iOS styled toolbar for Cupertino
16 | /// themed app,
17 | ///
18 | /// Typically used as the [child] attribute of a [CupertinoPageScaffold].
19 | ///
20 | /// {@tool sample}
21 | ///
22 | /// A sample code implementing a typical iOS page with bottom toolbar.
23 | ///
24 | /// ```dart
25 | /// CupertinoPageScaffold(
26 | /// navigationBar: CupertinoNavigationBar(
27 | /// middle: Text('Cupertino Toolbar')
28 | /// ),
29 | /// child: CupertinoToolbar(
30 | /// items: [
31 | /// CupertinoToolbarItem(
32 | /// icon: CupertinoIcons.delete,
33 | /// onPressed: () {}
34 | /// ),
35 | /// CupertinoToolbarItem(
36 | /// icon: CupertinoIcons.settings,
37 | /// onPressed: () {}
38 | /// )
39 | /// ],
40 | /// body: Center(
41 | /// child: Text('Hello World')
42 | /// )
43 | /// )
44 | /// )
45 | /// ```
46 | /// {@end-tool}
47 | ///
48 | CupertinoToolbar({
49 | Key key,
50 | @required this.items,
51 | @required this.body
52 | }) : assert(items != null),
53 | assert(
54 | items.every((CupertinoToolbarItem item) => (item.icon != null)) == true,
55 | 'Every item must have an icon and onPressed defined',
56 | ),
57 | assert(body != null),
58 | super(key: key);
59 |
60 | /// The interactive items laid out within the toolbar where each item has an icon.
61 | final List items;
62 |
63 | /// The body displayed above the toolbar.
64 | final Widget body;
65 |
66 | @override
67 | Widget build(BuildContext context) {
68 | return Column(
69 | children: [
70 | Expanded(
71 | child: body
72 | ),
73 | Container(
74 | decoration: BoxDecoration(
75 | border: Border(
76 | top: BorderSide(color: MyColors.barDivider.resolveFrom(context), width: 0.0)
77 | )
78 | ),
79 | child: SafeArea(
80 | top: false,
81 | child: SizedBox(
82 | height: Global.isTablet ? 50.0 : 44.0,
83 | child: Row(
84 | mainAxisAlignment: MainAxisAlignment.spaceEvenly,
85 | children: _createButtons()
86 | )
87 | )
88 | )
89 | )
90 | ]
91 | );
92 | }
93 |
94 | List _createButtons() {
95 | final List children = [];
96 | for (int i = 0; i < items.length; i += 1) {
97 | children.add(CupertinoButton(
98 | padding: EdgeInsets.zero,
99 | child: Icon(
100 | items[i].icon,
101 | // color: CupertinoColors.systemBlue,
102 | semanticLabel: items[i].semanticLabel,
103 | ),
104 | onPressed: items[i].onPressed
105 | ));
106 | }
107 | return children;
108 | }
109 | }
110 |
111 | /// An interactive button within iOS themed [CupertinoToolbar]
112 | class CupertinoToolbarItem {
113 | /// Creates an item that is used with [CupertinoToolbar.items].
114 | ///
115 | /// The argument [icon] should not be null.
116 | const CupertinoToolbarItem({
117 | @required this.icon,
118 | this.onPressed,
119 | this.semanticLabel
120 | }) : assert(icon != null);
121 |
122 | /// The icon of the item.
123 | ///
124 | /// This attribute must not be null.
125 | final IconData icon;
126 |
127 | /// The callback that is called when the item is tapped.
128 | ///
129 | /// This attribute must not be null.
130 | final VoidCallback onPressed;
131 |
132 | /// Semantic label for the icon.
133 | ///
134 | /// Announced in accessibility modes (e.g TalkBack/VoiceOver).
135 | /// This label does not show in the UI.
136 | final String semanticLabel;
137 | }
--------------------------------------------------------------------------------
/lib/utils/store.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:fluent_reader_lite/models/global_model.dart';
4 | import 'package:flutter/cupertino.dart';
5 | import 'package:shared_preferences/shared_preferences.dart';
6 |
7 | abstract class StoreKeys {
8 | static const GROUPS = "groups";
9 | static const ERROR_LOG = "errorLog";
10 | static const UNCATEGORIZED = "uncategorized";
11 | static const UNREAD_SUBS_ONLY = "unreadSubsOnly";
12 |
13 | // General
14 | static const THEME = "theme";
15 | static const LOCALE = "locale";
16 | static const KEEP_ITEMS_DAYS = "keepItemsD";
17 | static const SYNC_ON_START = "syncOnStart";
18 | static const IN_APP_BROWSER = "inAppBrowser";
19 | static const TEXT_SCALE = "textScale";
20 |
21 | // Feed preferences
22 | static const FEED_FILTER_ALL = "feedFilterA";
23 | static const FEED_FILTER_SOURCE = "feedFilterS";
24 | static const SHOW_THUMB = "showThumb";
25 | static const SHOW_SNIPPET = "showSnippet";
26 | static const DIM_READ = "dimRead";
27 | static const FEED_SWIPE_R = "feedSwipeR";
28 | static const FEED_SWIPE_L = "feedSwipeL";
29 | static const UNREAD_SOURCE_TIP = "unreadSourceTip";
30 |
31 | // Reading preferences
32 | static const ARTICLE_FONT_SIZE = "articleFontSize";
33 |
34 | // Syncing
35 | static const SYNC_SERVICE = "syncService";
36 | static const LAST_SYNCED = "lastSynced";
37 | static const LAST_SYNC_SUCCESS = "lastSyncSuccess";
38 | static const LAST_ID = "lastId";
39 | static const ENDPOINT = "endpoint";
40 | static const USERNAME = "username";
41 | static const PASSWORD = "password";
42 | static const API_ID = "apiId";
43 | static const API_KEY = "apiKey";
44 | static const FETCH_LIMIT = "fetchLimit";
45 | static const FEVER_INT_32 = "feverInt32";
46 | static const LAST_FETCHED = "lastFetched";
47 | static const AUTH = "auth";
48 | static const USE_INT_64 = "useInt64";
49 | static const INOREADER_REMOVE_AD = "inoRemoveAd";
50 | }
51 |
52 | class Store {
53 | // Initialized in main.dart
54 | static SharedPreferences sp;
55 |
56 | static Locale getLocale() {
57 | if (!sp.containsKey(StoreKeys.LOCALE)) return null;
58 | var localeString = sp.getString(StoreKeys.LOCALE);
59 | var splitted = localeString.split('_');
60 | if (splitted.length > 1) {
61 | return Locale(splitted[0], splitted[1]);
62 | } else {
63 | return Locale(localeString);
64 | }
65 | }
66 |
67 | static void setLocale(Locale locale) {
68 | if (locale == null) sp.remove(StoreKeys.LOCALE);
69 | else sp.setString(StoreKeys.LOCALE, locale.toString());
70 | }
71 |
72 | static ThemeSetting getTheme() {
73 | return sp.containsKey(StoreKeys.THEME)
74 | ? ThemeSetting.values[sp.getInt(StoreKeys.THEME)]
75 | : ThemeSetting.Default;
76 | }
77 |
78 | static void setTheme(ThemeSetting theme) {
79 | sp.setInt(StoreKeys.THEME, theme.index);
80 | }
81 |
82 | static Map> getGroups() {
83 | var groups = sp.getString(StoreKeys.GROUPS);
84 | if (groups == null) return Map();
85 | Map> result = Map();
86 | var parsed = jsonDecode(groups);
87 | for (var key in parsed.keys) {
88 | result[key] = List.castFrom(parsed[key]);
89 | }
90 | return result;
91 | }
92 |
93 | static void setGroups(Map> groups) {
94 | sp.setString(StoreKeys.GROUPS, jsonEncode(groups));
95 | }
96 |
97 | static List getUncategorized() {
98 | final stored = sp.getString(StoreKeys.UNCATEGORIZED);
99 | if (stored == null) return null;
100 | final parsed = jsonDecode(stored);
101 | return List.castFrom(parsed);
102 | }
103 |
104 | static void setUncategorized(List value) {
105 | if (value == null) {
106 | sp.remove(StoreKeys.UNCATEGORIZED);
107 | } else {
108 | sp.setString(StoreKeys.UNCATEGORIZED, jsonEncode(value));
109 | }
110 | }
111 |
112 | static int getArticleFontSize() {
113 | return sp.getInt(StoreKeys.ARTICLE_FONT_SIZE) ?? 16;
114 | }
115 |
116 | static void setArticleFontSize(int value) {
117 | sp.setInt(StoreKeys.ARTICLE_FONT_SIZE, value);
118 | }
119 |
120 | static String getErrorLog() {
121 | return sp.getString(StoreKeys.ERROR_LOG) ?? "";
122 | }
123 |
124 | static void setErrorLog(String value) {
125 | sp.setString(StoreKeys.ERROR_LOG, value);
126 | }
127 | }
--------------------------------------------------------------------------------
/lib/pages/settings/source_edit_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/components/list_tile_group.dart';
2 | import 'package:fluent_reader_lite/components/my_list_tile.dart';
3 | import 'package:fluent_reader_lite/generated/l10n.dart';
4 | import 'package:fluent_reader_lite/models/source.dart';
5 | import 'package:fluent_reader_lite/models/sources_model.dart';
6 | import 'package:fluent_reader_lite/pages/settings/text_editor_page.dart';
7 | import 'package:fluent_reader_lite/utils/colors.dart';
8 | import 'package:fluent_reader_lite/utils/global.dart';
9 | import 'package:fluent_reader_lite/utils/utils.dart';
10 | import 'package:flutter/cupertino.dart';
11 | import 'package:flutter/material.dart';
12 | import 'package:flutter/services.dart';
13 | import 'package:provider/provider.dart';
14 | import 'package:tuple/tuple.dart';
15 |
16 | class SourceEditPage extends StatelessWidget {
17 | void _editName(BuildContext context, RSSSource source) async {
18 | final String name = await Navigator.of(context).push(CupertinoPageRoute(
19 | builder: (context) => TextEditorPage(
20 | S.of(context).name,
21 | (v) => v.trim().length > 0,
22 | initialValue: source.name,
23 | ),
24 | ));
25 | if (name == null || name == source.name) return;
26 | var cloned = source.clone();
27 | cloned.name = name;
28 | await Global.sourcesModel.put(cloned);
29 | }
30 |
31 | void _editIcon(BuildContext context, RSSSource source) async {
32 | final String iconUrl = await Navigator.of(context).push(CupertinoPageRoute(
33 | builder: (context) => TextEditorPage(
34 | S.of(context).icon,
35 | (v) async {
36 | var trimmed = v.trim();
37 | if (trimmed.length == 0) return false;
38 | return await Utils.validateFavicon(trimmed);
39 | },
40 | initialValue: source.iconUrl,
41 | ),
42 | ));
43 | if (iconUrl == null || iconUrl == source.iconUrl) return;
44 | var cloned = source.clone();
45 | cloned.iconUrl = iconUrl;
46 | await Global.sourcesModel.put(cloned);
47 | }
48 |
49 | @override
50 | Widget build(BuildContext context) {
51 | final String sid = ModalRoute.of(context).settings.arguments;
52 | return Selector(
53 | selector: (context, sourcesModel) => sourcesModel.getSource(sid),
54 | builder: (context, source, child) {
55 | final urlStyle = TextStyle(
56 | color: CupertinoColors.secondaryLabel.resolveFrom(context),
57 | );
58 | final urlTile = ListTileGroup([
59 | MyListTile(
60 | title: Flexible(child: Text(source.url, style: urlStyle, overflow: TextOverflow.ellipsis)),
61 | trailing: Icon(
62 | CupertinoIcons.doc_on_clipboard,
63 | semanticLabel: S.of(context).copy,
64 | ),
65 | onTap: () { Clipboard.setData(ClipboardData(text: source.url)); },
66 | trailingChevron: false,
67 | withDivider: false,
68 | ),
69 | ], title: "URL");
70 | final editSource = ListTileGroup([
71 | MyListTile(
72 | title: Text(S.of(context).name),
73 | onTap: () { _editName(context, source); },
74 | ),
75 | MyListTile(
76 | title: Text(S.of(context).icon),
77 | onTap: () { _editIcon(context, source); },
78 | withDivider: false,
79 | ),
80 | ], title: S.of(context).edit);
81 | final openTarget = ListTileGroup.fromOptions(
82 | [
83 | Tuple2(S.of(context).rssText, SourceOpenTarget.Local),
84 | Tuple2(S.of(context).loadFull, SourceOpenTarget.FullContent),
85 | Tuple2(S.of(context).loadWebpage, SourceOpenTarget.Webpage),
86 | Tuple2(S.of(context).openExternal, SourceOpenTarget.External),
87 | ],
88 | source.openTarget,
89 | (v) {
90 | var cloned = source.clone();
91 | cloned.openTarget = v;
92 | Global.sourcesModel.put(cloned);
93 | },
94 | title: S.of(context).openTarget,
95 | );
96 | return CupertinoPageScaffold(
97 | backgroundColor: MyColors.background,
98 | navigationBar: CupertinoNavigationBar(
99 | middle: Text(source.name, overflow: TextOverflow.ellipsis),
100 | ),
101 | child: ListView(children: [
102 | urlTile,
103 | editSource,
104 | openTarget,
105 | ],),
106 | );
107 | },
108 | );
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/lib/models/items_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/models/item.dart';
2 | import 'package:fluent_reader_lite/utils/global.dart';
3 | import 'package:flutter/cupertino.dart';
4 | import 'package:sqflite/sqflite.dart';
5 |
6 | class ItemsModel with ChangeNotifier {
7 | Map _items = Map();
8 |
9 | bool has(String id) => _items.containsKey(id);
10 |
11 | RSSItem getItem(String id) => _items[id];
12 | Iterable getItems() => _items.values;
13 |
14 | void loadItems(Iterable items) {
15 | for (var item in items) {
16 | _items[item.id] = item;
17 | }
18 | }
19 |
20 | Future updateItem(String iid,
21 | {Batch batch, bool read, bool starred, local: false}) async {
22 | Map updateMap = Map();
23 | if (_items.containsKey(iid)) {
24 | final item = _items[iid].clone();
25 | if (read != null) {
26 | item.hasRead = read;
27 | if (!local) {
28 | if (read) Global.service.markRead(item);
29 | else Global.service.markUnread(item);
30 | }
31 | Global.sourcesModel.updateUnreadCount(item.source, read ? -1 : 1);
32 | }
33 | if (starred != null) {
34 | item.starred = starred;
35 | if (!local) {
36 | if (starred) Global.service.star(item);
37 | else Global.service.unstar(item);
38 | }
39 | }
40 | _items[iid] = item;
41 | }
42 | if (read != null) updateMap["hasRead"] = read ? 1 : 0;
43 | if (starred != null) updateMap["starred"] = starred ? 1 : 0;
44 | if (batch != null) {
45 | batch.update("items", updateMap, where: "iid = ?", whereArgs: [iid]);
46 | } else {
47 | notifyListeners();
48 | await Global.db.update("items", updateMap, where: "iid = ?", whereArgs: [iid]);
49 | }
50 | }
51 |
52 | Future markAllRead(Set sids, {DateTime date, before = true}) async {
53 | Global.service.markAllRead(sids, date, before);
54 | List predicates = ["hasRead = 0"];
55 | if (sids.length > 0) {
56 | predicates.add("source IN (${List.filled(sids.length, "?").join(" , ")})");
57 | }
58 | if (date != null) {
59 | predicates.add("date ${before ? "<=" : ">="} ${date.millisecondsSinceEpoch}");
60 | }
61 | await Global.db.update(
62 | "items",
63 | { "hasRead": 1 },
64 | where: predicates.join(" AND "),
65 | whereArgs: sids.toList(),
66 | );
67 | for (var item in _items.values.toList()) {
68 | if (sids.length > 0 && !sids.contains(item.source)) continue;
69 | if (date != null &&
70 | (before ? item.date.compareTo(date) > 0 : item.date.compareTo(date) < 0))
71 | continue;
72 | item.hasRead = true;
73 | }
74 | notifyListeners();
75 | Global.sourcesModel.updateUnreadCounts();
76 | }
77 |
78 | Future fetchItems() async {
79 | final items = await Global.service.fetchItems();
80 | final batch = Global.db.batch();
81 | for (var item in items) {
82 | if (!Global.sourcesModel.has(item.source)) continue;
83 | _items[item.id] = item;
84 | batch.insert(
85 | "items",
86 | item.toMap(),
87 | conflictAlgorithm: ConflictAlgorithm.replace,
88 | );
89 | }
90 | await batch.commit(noResult: true);
91 | // notifyListeners();
92 | Global.sourcesModel.updateWithFetchedItems(items);
93 | Global.feedsModel.addFetchedItems(items);
94 | }
95 |
96 | Future syncItems() async {
97 | final tuple = await Global.service.syncItems();
98 | final unreadIds = tuple.item1;
99 | final starredIds = tuple.item2;
100 | final rows = await Global.db.query(
101 | "items",
102 | columns: ["iid", "hasRead", "starred"],
103 | where: "hasRead = 0 OR starred = 1",
104 | );
105 | final batch = Global.db.batch();
106 | for (var row in rows) {
107 | final id = row["iid"];
108 | if (row["hasRead"] == 0 && !unreadIds.remove(id)) {
109 | await updateItem(id, read: true, batch: batch, local: true);
110 | }
111 | if (row["starred"] == 1 && !starredIds.remove(id)) {
112 | await updateItem(id, starred: false, batch: batch, local: true);
113 | }
114 | }
115 | for (var unread in unreadIds) {
116 | await updateItem(unread, read: false, batch: batch, local: true);
117 | }
118 | for (var starred in starredIds) {
119 | await updateItem(starred, starred: true, batch: batch, local: true);
120 | }
121 | notifyListeners();
122 | await batch.commit(noResult: true);
123 | await Global.sourcesModel.updateUnreadCounts();
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/lib/components/subscription_item.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/components/dismissible_background.dart';
2 | import 'package:fluent_reader_lite/components/favicon.dart';
3 | import 'package:fluent_reader_lite/components/mark_all_action_sheet.dart';
4 | import 'package:fluent_reader_lite/components/time_text.dart';
5 | import 'package:fluent_reader_lite/models/source.dart';
6 | import 'package:fluent_reader_lite/utils/global.dart';
7 | import 'package:flutter/cupertino.dart';
8 | import 'package:flutter/material.dart';
9 | import 'package:flutter/services.dart';
10 |
11 | import 'badge.dart';
12 |
13 | class SubscriptionItem extends StatefulWidget {
14 | final RSSSource source;
15 |
16 | SubscriptionItem(this.source, {Key key}) : super(key: key);
17 |
18 | @override
19 | _SubscriptionItemState createState() => _SubscriptionItemState();
20 | }
21 |
22 | class _SubscriptionItemState extends State {
23 | bool pressed = false;
24 |
25 | void _openSourcePage() async {
26 | await Global.feedsModel.initSourcesFeed([widget.source.id]);
27 | Navigator.of(context).pushNamed("/feed", arguments: widget.source.name);
28 | }
29 |
30 | static const _dismissThresholds = {
31 | DismissDirection.horizontal: 0.25,
32 | };
33 |
34 | Future _onDismiss(DismissDirection direction) async {
35 | HapticFeedback.mediumImpact();
36 | if (direction == DismissDirection.startToEnd) {
37 | showCupertinoModalPopup(
38 | context: context,
39 | builder: (context) => MarkAllActionSheet({widget.source.id}),
40 | );
41 | } else {
42 | Navigator.of(context, rootNavigator: true).pushNamed(
43 | "/settings/sources/edit",
44 | arguments: widget.source.id,
45 | );
46 | }
47 | return false;
48 | }
49 |
50 | @override
51 | Widget build(BuildContext context) {
52 | final _titleStyle = TextStyle(
53 | fontSize: 16,
54 | color: CupertinoColors.label.resolveFrom(context),
55 | fontWeight: FontWeight.bold,
56 | );
57 | final _descStyle = TextStyle(
58 | fontSize: 16,
59 | color: CupertinoColors.secondaryLabel.resolveFrom(context),
60 | );
61 | final _timeStyle = TextStyle(
62 | fontSize: 14,
63 | color: CupertinoColors.secondaryLabel.resolveFrom(context),
64 | );
65 | final topLine = Row(
66 | mainAxisAlignment: MainAxisAlignment.spaceBetween,
67 | children: [
68 | Expanded(
69 | child: Row(children: [
70 | Padding(
71 | padding: EdgeInsets.only(right: 8),
72 | child: Favicon(widget.source),
73 | ),
74 | Expanded(
75 | child: Text(widget.source.name, style: _titleStyle, overflow: TextOverflow.ellipsis,),
76 | ),
77 | ]),
78 | ),
79 | TimeText(widget.source.latest, style: _timeStyle),
80 | ],
81 | );
82 | final bottomLine = Row(
83 | mainAxisAlignment: MainAxisAlignment.spaceBetween,
84 | children: [
85 | Expanded(
86 | child: Text(widget.source.lastTitle, style: _descStyle, overflow: TextOverflow.ellipsis),
87 | ),
88 | if (widget.source.unreadCount > 0) Badge(widget.source.unreadCount),
89 | ],
90 | );
91 | final body = GestureDetector(
92 | onTapDown: (_) { setState(() { pressed = true; }); },
93 | onTapUp: (_) { setState(() { pressed = false; }); },
94 | onTapCancel: () { setState(() { pressed = false; }); },
95 | onTap: _openSourcePage,
96 | child: Column(children: [
97 | Container(
98 | constraints: BoxConstraints(minHeight: 64),
99 | color: pressed
100 | ? CupertinoColors.systemGrey4.resolveFrom(context)
101 | : CupertinoColors.systemBackground.resolveFrom(context),
102 | child: Padding(
103 | padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
104 | child: Column(
105 | mainAxisAlignment: MainAxisAlignment.center,
106 | children: [
107 | topLine,
108 | Padding(padding: EdgeInsets.only(top: 4)),
109 | bottomLine
110 | ],
111 | ),
112 | ),
113 | ),
114 | Padding(
115 | padding: EdgeInsets.only(left: 16),
116 | child: Divider(color: CupertinoColors.systemGrey4.resolveFrom(context), height: 1),
117 | ),
118 | ],),
119 | );
120 | return Dismissible(
121 | key: Key("D-${widget.source.id}"),
122 | background: DismissibleBackground(CupertinoIcons.checkmark_circle, true),
123 | secondaryBackground: DismissibleBackground(CupertinoIcons.pencil_circle, false),
124 | dismissThresholds: _dismissThresholds,
125 | confirmDismiss: _onDismiss,
126 | child: body,
127 | );
128 | }
129 | }
--------------------------------------------------------------------------------
/lib/pages/group_list_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/components/badge.dart';
2 | import 'package:fluent_reader_lite/components/dismissible_background.dart';
3 | import 'package:fluent_reader_lite/components/mark_all_action_sheet.dart';
4 | import 'package:fluent_reader_lite/components/my_list_tile.dart';
5 | import 'package:fluent_reader_lite/generated/l10n.dart';
6 | import 'package:fluent_reader_lite/models/groups_model.dart';
7 | import 'package:fluent_reader_lite/models/source.dart';
8 | import 'package:fluent_reader_lite/models/sources_model.dart';
9 | import 'package:fluent_reader_lite/utils/global.dart';
10 | import 'package:fluent_reader_lite/utils/utils.dart';
11 | import 'package:flutter/cupertino.dart';
12 | import 'package:flutter/services.dart';
13 | import 'package:provider/provider.dart';
14 |
15 | class GroupListPage extends StatefulWidget {
16 | @override
17 | _GroupListPageState createState() => _GroupListPageState();
18 | }
19 |
20 | class _GroupListPageState extends State {
21 | static const List _uncategorizedIndicator = [null, null];
22 |
23 | int _unreadCount(Iterable sources) {
24 | return sources.fold(0, (c, s) => c + (s != null ? s.unreadCount : 0));
25 | }
26 |
27 | static const _dismissThresholds = {
28 | DismissDirection.startToEnd: 0.25,
29 | };
30 |
31 | @override
32 | Widget build(BuildContext context) {
33 | final navigationBar = CupertinoSliverNavigationBar(
34 | largeTitle: Text(S.of(context).groups),
35 | automaticallyImplyLeading: false,
36 | backgroundColor: Global.isTablet ? CupertinoColors.systemBackground : null,
37 | leading: CupertinoButton(
38 | minSize: 36,
39 | padding: EdgeInsets.zero,
40 | child: Text(S.of(context).cancel),
41 | onPressed: () { Navigator.of(context).pop(); },
42 | ),
43 | );
44 | final allSources = Consumer(
45 | builder: (context, sourcesModel, child) {
46 | var count = _unreadCount(sourcesModel.getSources());
47 | return SliverToBoxAdapter(child: MyListTile(
48 | title: Text(S.of(context).allSubscriptions),
49 | trailing: count > 0 ? Badge(count) : null,
50 | onTap: () { Navigator.of(context).pop(List.empty()); },
51 | background: CupertinoColors.systemBackground,
52 | ));
53 | },
54 | );
55 | final dismissBg = DismissibleBackground(CupertinoIcons.checkmark_circle, true);
56 | final groupList = Consumer2(
57 | builder: (context, groupsModel, sourcesModel, child) {
58 | final groupNames = groupsModel.groups.keys.toList();
59 | groupNames.sort(Utils.localStringCompare);
60 | if (groupsModel.uncategorized != null) {
61 | groupNames.insert(0, null);
62 | }
63 | return SliverList(
64 | delegate: SliverChildBuilderDelegate((context, index) {
65 | String groupName;
66 | List group;
67 | final isUncategorized = groupsModel.showUncategorized && index == 0;
68 | if (isUncategorized) {
69 | groupName = S.of(context).uncategorized;
70 | group = groupsModel.uncategorized;
71 | } else {
72 | groupName = groupNames[index];
73 | group = groupsModel.groups[groupName];
74 | }
75 | final count = _unreadCount(
76 | group.map((sid) => sourcesModel.getSource(sid))
77 | );
78 | final tile = MyListTile(
79 | title: Flexible(child: Text(groupName, overflow: TextOverflow.ellipsis)),
80 | trailing: count > 0 ? Badge(count) : null,
81 | onTap: () {
82 | Navigator.of(context).pop(
83 | isUncategorized ? _uncategorizedIndicator : [groupName]
84 | );
85 | },
86 | background: CupertinoColors.systemBackground,
87 | );
88 | return Dismissible(
89 | key: Key("$groupName$index"),
90 | child: tile,
91 | background: dismissBg,
92 | direction: DismissDirection.startToEnd,
93 | dismissThresholds: _dismissThresholds,
94 | confirmDismiss: (_) async {
95 | HapticFeedback.mediumImpact();
96 | Set sids = Set.from(group);
97 | showCupertinoModalPopup(
98 | context: context,
99 | builder: (context) => MarkAllActionSheet(sids),
100 | );
101 | return false;
102 | },
103 | );
104 | }, childCount: groupNames.length),
105 | );
106 | },
107 | );
108 | final padding = SliverToBoxAdapter(child: Padding(
109 | padding: EdgeInsets.only(bottom: 80),
110 | ),);
111 | return CupertinoPageScaffold(
112 | backgroundColor: CupertinoColors.systemBackground,
113 | child: CupertinoScrollbar(child: CustomScrollView(
114 | slivers: [
115 | navigationBar,
116 | allSources,
117 | groupList,
118 | padding,
119 | ],
120 | ))
121 | );
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/lib/pages/settings/feed_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/components/list_tile_group.dart';
2 | import 'package:fluent_reader_lite/components/my_list_tile.dart';
3 | import 'package:fluent_reader_lite/generated/l10n.dart';
4 | import 'package:fluent_reader_lite/models/feeds_model.dart';
5 | import 'package:fluent_reader_lite/models/groups_model.dart';
6 | import 'package:fluent_reader_lite/utils/colors.dart';
7 | import 'package:flutter/cupertino.dart';
8 | import 'package:provider/provider.dart';
9 | import 'package:tuple/tuple.dart';
10 |
11 | class FeedPage extends StatelessWidget {
12 | void _openGestureOptions(BuildContext context, bool isToRight) {
13 | Navigator.of(context).push(CupertinoPageRoute(
14 | builder: (context) => CupertinoPageScaffold(
15 | backgroundColor: MyColors.background,
16 | navigationBar: CupertinoNavigationBar(
17 | middle: Text(isToRight ? S.of(context).swipeRight : S.of(context).swipeLeft),
18 | ),
19 | child: Consumer(
20 | builder: (context, feedsModel, child) {
21 | final swipeOptons = [
22 | Tuple2(S.of(context).toggleRead, ItemSwipeOption.ToggleRead),
23 | Tuple2(S.of(context).toggleStar, ItemSwipeOption.ToggleStar),
24 | Tuple2(S.of(context).share, ItemSwipeOption.Share),
25 | Tuple2(S.of(context).openExternal, ItemSwipeOption.OpenExternal),
26 | Tuple2(S.of(context).openMenu, ItemSwipeOption.OpenMenu),
27 | ];
28 | return ListView(children: [
29 | ListTileGroup.fromOptions(
30 | swipeOptons,
31 | isToRight ? feedsModel.swipeR : feedsModel.swipeL,
32 | (v) {
33 | if (isToRight) feedsModel.swipeR = v;
34 | else feedsModel.swipeL = v;
35 | },
36 | ),
37 | ]);
38 | },
39 | ),
40 | ),
41 | ));
42 | }
43 |
44 | @override
45 | Widget build(BuildContext context) {
46 | return CupertinoPageScaffold(
47 | backgroundColor: MyColors.background,
48 | navigationBar: CupertinoNavigationBar(
49 | middle: Text(S.of(context).feed),
50 | ),
51 | child: Consumer(
52 | builder: (context, feedsModel, child) {
53 | final swipeOptons = {
54 | ItemSwipeOption.ToggleRead: S.of(context).toggleRead,
55 | ItemSwipeOption.ToggleStar: S.of(context).toggleStar,
56 | ItemSwipeOption.Share: S.of(context).share,
57 | ItemSwipeOption.OpenExternal: S.of(context).openExternal,
58 | ItemSwipeOption.OpenMenu: S.of(context).openMenu,
59 | };
60 | final preferences = ListTileGroup([
61 | MyListTile(
62 | title: Text(S.of(context).showThumb),
63 | trailing: CupertinoSwitch(
64 | value: feedsModel.showThumb,
65 | onChanged: (v) { feedsModel.showThumb = v; },
66 | ),
67 | trailingChevron: false,
68 | ),
69 | MyListTile(
70 | title: Text(S.of(context).showSnippet),
71 | trailing: CupertinoSwitch(
72 | value: feedsModel.showSnippet,
73 | onChanged: (v) { feedsModel.showSnippet = v; },
74 | ),
75 | trailingChevron: false,
76 | ),
77 | MyListTile(
78 | title: Text(S.of(context).dimRead),
79 | trailing: CupertinoSwitch(
80 | value: feedsModel.dimRead,
81 | onChanged: (v) { feedsModel.dimRead = v; },
82 | ),
83 | trailingChevron: false,
84 | withDivider: false,
85 | ),
86 | ], title: S.of(context).preferences);
87 | final groups = ListTileGroup([
88 | Consumer(
89 | builder: (context, groupsModel, child) {
90 | return MyListTile(
91 | title: Text(S.of(context).showUncategorized),
92 | trailing: CupertinoSwitch(
93 | value: groupsModel.showUncategorized,
94 | onChanged: (v) { groupsModel.showUncategorized = v; },
95 | ),
96 | trailingChevron: false,
97 | withDivider: false,
98 | );
99 | },
100 | ),
101 | ], title: S.of(context).groups);
102 | return ListView(
103 | children: [
104 | preferences,
105 | groups,
106 | ListTileGroup([
107 | MyListTile(
108 | title: Text(S.of(context).swipeRight),
109 | trailing: Text(swipeOptons[feedsModel.swipeR]),
110 | onTap: () { _openGestureOptions(context, true); },
111 | ),
112 | MyListTile(
113 | title: Text(S.of(context).swipeLeft),
114 | trailing: Text(swipeOptons[feedsModel.swipeL]),
115 | onTap: () { _openGestureOptions(context, false); },
116 | withDivider: false,
117 | ),
118 | ], title: S.of(context).gestures),
119 | ],
120 | );
121 | },
122 | ),
123 | );
124 | }
125 | }
--------------------------------------------------------------------------------
/lib/pages/settings/general_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/components/list_tile_group.dart';
2 | import 'package:fluent_reader_lite/components/my_list_tile.dart';
3 | import 'package:fluent_reader_lite/generated/l10n.dart';
4 | import 'package:fluent_reader_lite/models/global_model.dart';
5 | import 'package:fluent_reader_lite/utils/colors.dart';
6 | import 'package:flutter/cupertino.dart';
7 | import 'package:flutter/material.dart';
8 | import 'package:flutter_cache_manager/flutter_cache_manager.dart';
9 | import 'package:provider/provider.dart';
10 | import 'package:tuple/tuple.dart';
11 |
12 | class GeneralPage extends StatefulWidget {
13 | @override
14 | _GeneralPageState createState() => _GeneralPageState();
15 | }
16 |
17 | class _GeneralPageState extends State {
18 | bool _clearingCache = false;
19 | double textScale;
20 |
21 | void _clearCache() async {
22 | setState(() { _clearingCache = true; });
23 | await DefaultCacheManager().emptyCache();
24 | if (!mounted) return;
25 | setState(() { _clearingCache = false; });
26 | }
27 |
28 | @override
29 | Widget build(BuildContext context) => CupertinoPageScaffold(
30 | backgroundColor: MyColors.background,
31 | navigationBar: CupertinoNavigationBar(
32 | middle: Text(S.of(context).general),
33 | ),
34 | child: Consumer(
35 | builder: (context, globalModel, child) {
36 | final useSystemTextScale = globalModel.textScale == null;
37 | final textScaleItems = ListTileGroup([
38 | MyListTile(
39 | title: Text(S.of(context).followSystem),
40 | trailing: CupertinoSwitch(
41 | value: useSystemTextScale,
42 | onChanged: (v) {
43 | textScale = null;
44 | globalModel.textScale = v ? null : 1;
45 | },
46 | ),
47 | trailingChevron: false,
48 | withDivider: !useSystemTextScale,
49 | ),
50 | if (!useSystemTextScale) MyListTile(
51 | title: Expanded(child: CupertinoSlider(
52 | min: 0.5,
53 | max: 1.5,
54 | divisions: 8,
55 | value: textScale ?? globalModel.textScale,
56 | onChanged: (v) {
57 | setState(() { textScale = v; });
58 | },
59 | onChangeEnd: (v) {
60 | textScale = null;
61 | globalModel.textScale = v;
62 | },
63 | )),
64 | trailingChevron: false,
65 | withDivider: false,
66 | ),
67 | ], title: S.of(context).fontSize);
68 | final syncItems = ListTileGroup([
69 | MyListTile(
70 | title: Text(S.of(context).syncOnStart),
71 | trailing: CupertinoSwitch(
72 | value: globalModel.syncOnStart,
73 | onChanged: (v) {
74 | globalModel.syncOnStart = v;
75 | setState(() {});
76 | },
77 | ),
78 | trailingChevron: false,
79 | ),
80 | MyListTile(
81 | title: Text(S.of(context).inAppBrowser),
82 | trailing: CupertinoSwitch(
83 | value: globalModel.inAppBrowser,
84 | onChanged: (v) {
85 | globalModel.inAppBrowser = v;
86 | setState(() {});
87 | },
88 | ),
89 | trailingChevron: false,
90 | withDivider: false,
91 | ),
92 | ], title: S.of(context).preferences);
93 | final storageItems = ListTileGroup([
94 | MyListTile(
95 | title: Text(S.of(context).clearCache),
96 | onTap: _clearingCache ? null : _clearCache,
97 | trailing: _clearingCache ? CupertinoActivityIndicator() : null,
98 | trailingChevron: !_clearingCache,
99 | ),
100 | MyListTile(
101 | title: Text(S.of(context).autoDelete),
102 | trailing: Text(S.of(context).daysAgo(globalModel.keepItemsDays)),
103 | trailingChevron: false,
104 | withDivider: false,
105 | ),
106 | MyListTile(
107 | title: Expanded(child: CupertinoSlider(
108 | min: 1,
109 | max: 4,
110 | divisions: 3,
111 | value: (globalModel.keepItemsDays ~/ 7).toDouble(),
112 | onChanged: (v) {
113 | globalModel.keepItemsDays = (v * 7).toInt();
114 | setState(() { });
115 | },
116 | )),
117 | trailingChevron: false,
118 | withDivider: false,
119 | ),
120 | ], title: S.of(context).storage);
121 | final themeItems = ListTileGroup.fromOptions(
122 | [
123 | Tuple2(S.of(context).followSystem, ThemeSetting.Default),
124 | Tuple2(S.of(context).light, ThemeSetting.Light),
125 | Tuple2(S.of(context).dark, ThemeSetting.Dark),
126 | ],
127 | globalModel.theme,
128 | (t) { globalModel.theme = t; },
129 | title: S.of(context).theme,
130 | );
131 | final localeItems = ListTileGroup.fromOptions(
132 | [
133 | Tuple2(S.of(context).followSystem, null),
134 | const Tuple2("Deutsch", Locale("de")),
135 | const Tuple2("English", Locale("en")),
136 | const Tuple2("Español", Locale("es")),
137 | const Tuple2("中文(简体)", Locale("zh")),
138 | ],
139 | globalModel.locale,
140 | (l) { globalModel.locale = l; },
141 | title: S.of(context).language,
142 | );
143 | return ListView(
144 | children: [
145 | syncItems,
146 | textScaleItems,
147 | storageItems,
148 | themeItems,
149 | localeItems,
150 | ],
151 | );
152 | },
153 | ),
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/lib/main.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:fluent_reader_lite/models/service.dart';
4 | import 'package:fluent_reader_lite/pages/article_page.dart';
5 | import 'package:fluent_reader_lite/pages/error_log_page.dart';
6 | import 'package:fluent_reader_lite/pages/settings/about_page.dart';
7 | import 'package:fluent_reader_lite/pages/home_page.dart';
8 | import 'package:fluent_reader_lite/pages/settings/feed_page.dart';
9 | import 'package:fluent_reader_lite/pages/settings/general_page.dart';
10 | import 'package:fluent_reader_lite/pages/settings/reading_page.dart';
11 | import 'package:fluent_reader_lite/pages/settings/services/feedbin_page.dart';
12 | import 'package:fluent_reader_lite/pages/settings/services/fever_page.dart';
13 | import 'package:fluent_reader_lite/pages/settings/services/greader_page.dart';
14 | import 'package:fluent_reader_lite/pages/settings/services/inoreader_page.dart';
15 | import 'package:fluent_reader_lite/pages/settings/source_edit_page.dart';
16 | import 'package:fluent_reader_lite/pages/settings/sources_page.dart';
17 | import 'package:fluent_reader_lite/pages/settings_page.dart';
18 | import 'package:fluent_reader_lite/utils/global.dart';
19 | import 'package:fluent_reader_lite/utils/store.dart';
20 | import 'package:flutter/cupertino.dart';
21 | import 'package:flutter/material.dart';
22 | import 'package:flutter/services.dart';
23 | import 'package:flutter_localizations/flutter_localizations.dart';
24 | import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
25 | import 'package:provider/provider.dart';
26 | import 'package:shared_preferences/shared_preferences.dart';
27 | import 'package:webview_flutter/webview_flutter.dart';
28 | import 'generated/l10n.dart';
29 | import 'models/global_model.dart';
30 |
31 | void main() async {
32 | WidgetsFlutterBinding.ensureInitialized();
33 | Store.sp = await SharedPreferences.getInstance();
34 | Global.init();
35 | if (Platform.isAndroid) {
36 | SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
37 | statusBarColor: Colors.transparent,
38 | ));
39 | WebView.platform = SurfaceAndroidWebView();
40 | }
41 | runApp(MyApp());
42 | SystemChannels.lifecycle.setMessageHandler((msg) {
43 | if (msg == AppLifecycleState.resumed.toString()) {
44 | if (Global.server != null) Global.server.restart();
45 | if (Global.globalModel.syncOnStart
46 | && DateTime.now().difference(Global.syncModel.lastSynced).inMinutes >= 10) {
47 | Global.syncModel.syncWithService();
48 | }
49 | }
50 | return null;
51 | });
52 | }
53 |
54 | class MyApp extends StatelessWidget {
55 | static final Map baseRoutes = {
56 | "/article": (context) => ArticlePage(),
57 | "/error-log": (context) => ErrorLogPage(),
58 | "/settings": (context) => SettingsPage(),
59 | "/settings/sources": (context) => SourcesPage(),
60 | "/settings/sources/edit": (context) => SourceEditPage(),
61 | "/settings/feed": (context) => FeedPage(),
62 | "/settings/reading": (context) => ReadingPage(),
63 | "/settings/general": (context) => GeneralPage(),
64 | "/settings/about": (context) => AboutPage(),
65 | "/settings/service/fever": (context) => FeverPage(),
66 | "/settings/service/feedbin": (context) => FeedbinPage(),
67 | "/settings/service/inoreader": (context) => InoreaderPage(),
68 | "/settings/service/greader": (context) => GReaderPage(),
69 | "/settings/service": (context) {
70 | var serviceType = SyncService.values[Store.sp.getInt(StoreKeys.SYNC_SERVICE) ?? 0];
71 | switch (serviceType) {
72 | case SyncService.None:
73 | break;
74 | case SyncService.Fever:
75 | return FeverPage();
76 | case SyncService.Feedbin:
77 | return FeedbinPage();
78 | case SyncService.GReader:
79 | return GReaderPage();
80 | break;
81 | case SyncService.Inoreader:
82 | return InoreaderPage();
83 | break;
84 | }
85 | return AboutPage();
86 | }
87 | };
88 | // This widget is the root of your application.
89 | @override
90 | Widget build(BuildContext context) {
91 | return MultiProvider(
92 | providers: [
93 | ChangeNotifierProvider.value(value: Global.globalModel),
94 | ChangeNotifierProvider.value(value: Global.sourcesModel),
95 | ChangeNotifierProvider.value(value: Global.itemsModel),
96 | ChangeNotifierProvider.value(value: Global.feedsModel),
97 | ChangeNotifierProvider.value(value: Global.groupsModel),
98 | ChangeNotifierProvider.value(value: Global.syncModel),
99 | ],
100 | child: Consumer(
101 | builder: (context, globalModel, child) => CupertinoApp(
102 | title: "Fluent Reader",
103 | debugShowCheckedModeBanner: false,
104 | localizationsDelegates: [
105 | // ... app-specific localization delegate[s] here
106 | S.delegate,
107 | GlobalMaterialLocalizations.delegate,
108 | GlobalWidgetsLocalizations.delegate,
109 | GlobalCupertinoLocalizations.delegate,
110 | ],
111 | locale: globalModel.locale,
112 | supportedLocales: [
113 | const Locale("de"),
114 | const Locale("en"),
115 | const Locale("es"),
116 | const Locale("zh"),
117 | ],
118 | localeResolutionCallback: (_locale, supportedLocales) {
119 | _locale = Locale(_locale.languageCode);
120 | if (globalModel.locale != null) return globalModel.locale;
121 | else if (supportedLocales.contains(_locale)) return _locale;
122 | else return Locale("en");
123 | },
124 | theme: CupertinoThemeData(
125 | primaryColor: CupertinoColors.systemBlue,
126 | brightness: globalModel.getBrightness(),
127 | ),
128 | routes: {
129 | "/": (context) => CupertinoScaffold(body: HomePage()),
130 | ...baseRoutes,
131 | },
132 | builder: (context, child) {
133 | final mediaQueryData = MediaQuery.of(context);
134 | if (Global.globalModel.textScale == null) return child;
135 | return MediaQuery(
136 | data: mediaQueryData.copyWith(textScaleFactor: Global.globalModel.textScale),
137 | child: child
138 | );
139 | },
140 | ),
141 | ),
142 | );
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/lib/models/sources_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluent_reader_lite/models/source.dart';
2 | import 'package:fluent_reader_lite/utils/global.dart';
3 | import 'package:fluent_reader_lite/utils/store.dart';
4 | import 'package:fluent_reader_lite/utils/utils.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:html/parser.dart';
7 | import 'package:http/http.dart' as http;
8 | import 'package:sqflite/sqflite.dart';
9 |
10 | import 'item.dart';
11 |
12 | class SourcesModel with ChangeNotifier {
13 | Map _sources = Map();
14 | Map _deleted = Map();
15 | bool _showUnreadTip = Store.sp.getBool(StoreKeys.UNREAD_SOURCE_TIP) ?? true;
16 |
17 | bool get showUnreadTip => _showUnreadTip;
18 | set showUnreadTip(bool value) {
19 | if (_showUnreadTip != value) {
20 | _showUnreadTip = value;
21 | Store.sp.setBool(StoreKeys.UNREAD_SOURCE_TIP, value);
22 | }
23 | }
24 |
25 | bool has(String id) => _sources.containsKey(id);
26 |
27 | RSSSource getSource(String id) => _sources[id] ?? _deleted[id];
28 |
29 | Iterable getSources() => _sources.values;
30 |
31 | Future init() async {
32 | final maps = await Global.db.query("sources");
33 | for (var map in maps) {
34 | var source = RSSSource.fromMap(map);
35 | _sources[source.id] = source;
36 | }
37 | notifyListeners();
38 | await updateUnreadCounts();
39 | }
40 |
41 | Future updateUnreadCounts() async {
42 | final rows = await Global.db.rawQuery(
43 | "SELECT source, COUNT(iid) FROM items WHERE hasRead=0 GROUP BY source;");
44 | for (var source in _sources.values) {
45 | var cloned = source.clone();
46 | _sources[source.id] = cloned;
47 | cloned.unreadCount = 0;
48 | }
49 | for (var row in rows) {
50 | _sources[row["source"]].unreadCount = row["COUNT(iid)"];
51 | }
52 | notifyListeners();
53 | }
54 |
55 | void updateUnreadCount(String sid, int diff) {
56 | _sources[sid].unreadCount += diff;
57 | notifyListeners();
58 | }
59 |
60 | Future updateWithFetchedItems(Iterable items) async {
61 | Set changed = Set();
62 | for (var item in items) {
63 | var source = _sources[item.source];
64 | if (!item.hasRead) source.unreadCount += 1;
65 | if (item.date.compareTo(source.latest) > 0 ||
66 | source.lastTitle.length == 0) {
67 | source.latest = item.date;
68 | source.lastTitle = item.title;
69 | changed.add(source.id);
70 | }
71 | }
72 | notifyListeners();
73 | if (changed.length > 0) {
74 | var batch = Global.db.batch();
75 | for (var sid in changed) {
76 | var source = _sources[sid];
77 | batch.update(
78 | "sources",
79 | {
80 | "latest": source.latest.millisecondsSinceEpoch,
81 | "lastTitle": source.lastTitle,
82 | },
83 | where: "sid = ?",
84 | whereArgs: [source.id],
85 | );
86 | }
87 | await batch.commit();
88 | }
89 | }
90 |
91 | Future put(RSSSource source, {force: false}) async {
92 | if (_deleted.containsKey(source.id) && !force) return;
93 | _sources[source.id] = source;
94 | notifyListeners();
95 | await Global.db.insert(
96 | "sources",
97 | source.toMap(),
98 | conflictAlgorithm: ConflictAlgorithm.replace,
99 | );
100 | }
101 |
102 | Future putAll(Iterable sources, {force: false}) async {
103 | Batch batch = Global.db.batch();
104 | for (var source in sources) {
105 | if (_deleted.containsKey(source.id) && !force) continue;
106 | _sources[source.id] = source;
107 | batch.insert(
108 | "sources",
109 | source.toMap(),
110 | conflictAlgorithm: ConflictAlgorithm.replace,
111 | );
112 | }
113 | notifyListeners();
114 | await batch.commit(noResult: true);
115 | }
116 |
117 | Future updateSources() async {
118 | final tuple = await Global.service.getSources();
119 | final sources = tuple.item1;
120 | var curr = Set.from(_sources.keys);
121 | List newSources = [];
122 | for (var source in sources) {
123 | if (curr.contains(source.id)) {
124 | curr.remove(source.id);
125 | } else {
126 | newSources.add(source);
127 | }
128 | }
129 | await putAll(newSources, force: true);
130 | await removeSources(curr);
131 | Global.groupsModel.groups = tuple.item2;
132 | fetchFavicons();
133 | }
134 |
135 | Future removeSources(Iterable ids) async {
136 | final batch = Global.db.batch();
137 | for (var id in ids) {
138 | if (!_sources.containsKey(id)) continue;
139 | var source = _sources[id];
140 | batch.delete(
141 | "items",
142 | where: "source = ?",
143 | whereArgs: [id],
144 | );
145 | batch.delete(
146 | "sources",
147 | where: "sid = ?",
148 | whereArgs: [id],
149 | );
150 | _sources.remove(id);
151 | _deleted[id] = source;
152 | }
153 | await batch.commit(noResult: true);
154 | Global.feedsModel.initAll();
155 | notifyListeners();
156 | }
157 |
158 | Future fetchFavicons() async {
159 | for (var key in _sources.keys) {
160 | if (_sources[key].iconUrl == null) {
161 | _fetchFavicon(_sources[key].url).then((url) {
162 | if (!_sources.containsKey(key)) return;
163 | var source = _sources[key].clone();
164 | source.iconUrl = url == null ? "" : url;
165 | put(source);
166 | });
167 | }
168 | }
169 | }
170 |
171 | Future _fetchFavicon(String url) async {
172 | try {
173 | url = url.split("/").getRange(0, 3).join("/");
174 | var uri = Uri.parse(url);
175 | var result = await http.get(uri);
176 | if (result.statusCode == 200) {
177 | var htmlStr = result.body;
178 | var dom = parse(htmlStr);
179 | var links = dom.getElementsByTagName("link");
180 | for (var link in links) {
181 | var rel = link.attributes["rel"];
182 | if ((rel == "icon" || rel == "shortcut icon") &&
183 | link.attributes.containsKey("href")) {
184 | var href = link.attributes["href"];
185 | var parsedUrl = Uri.parse(url);
186 | if (href.startsWith("//"))
187 | return parsedUrl.scheme + ":" + href;
188 | else if (href.startsWith("/"))
189 | return url + href;
190 | else
191 | return href;
192 | }
193 | }
194 | }
195 | url = url + "/favicon.ico";
196 | if (await Utils.validateFavicon(url)) {
197 | return url;
198 | } else {
199 | return null;
200 | }
201 | } catch (exp) {
202 | return null;
203 | }
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/lib/pages/home_page.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:fluent_reader_lite/generated/l10n.dart';
4 | import 'package:fluent_reader_lite/main.dart';
5 | import 'package:fluent_reader_lite/models/services/service_import.dart';
6 | import 'package:fluent_reader_lite/models/sync_model.dart';
7 | import 'package:fluent_reader_lite/pages/setup_page.dart';
8 | import 'package:fluent_reader_lite/pages/subscription_list_page.dart';
9 | import 'package:fluent_reader_lite/pages/tablet_base_page.dart';
10 | import 'package:fluent_reader_lite/utils/global.dart';
11 | import 'package:flutter/cupertino.dart';
12 | import 'package:flutter/material.dart';
13 | import 'package:provider/provider.dart';
14 | import 'package:responsive_builder/responsive_builder.dart';
15 | import 'package:uni_links/uni_links.dart';
16 |
17 | import 'item_list_page.dart';
18 |
19 | class HomePage extends StatefulWidget {
20 | HomePage() : super(key: Key("home"));
21 |
22 | @override
23 | _HomePageState createState() => _HomePageState();
24 | }
25 |
26 | class ScrollTopNotifier with ChangeNotifier {
27 | int index = 0;
28 |
29 | void onTap(int newIndex) {
30 | var oldIndex = index;
31 | index = newIndex;
32 | if (newIndex == oldIndex) notifyListeners();
33 | }
34 | }
35 |
36 | class _HomePageState extends State {
37 | final _scrollTopNotifier = ScrollTopNotifier();
38 | final _controller = CupertinoTabController();
39 | final List> _tabNavigatorKeys = [
40 | GlobalKey(),
41 | GlobalKey(),
42 | ];
43 | StreamSubscription _uriSub;
44 |
45 | void _uriStreamListener(Uri uri) {
46 | if (uri == null) return;
47 | if (uri.host == "import") {
48 | if (Global.syncModel.hasService) {
49 | showCupertinoDialog(
50 | context: context,
51 | builder: (context) => CupertinoAlertDialog(
52 | title: Text(S.of(context).serviceExists),
53 | actions: [
54 | CupertinoDialogAction(
55 | child: Text(S.of(context).confirm),
56 | onPressed: () {
57 | Navigator.of(context).pop();
58 | },
59 | ),
60 | ],
61 | ),
62 | );
63 | } else if (!Global.syncModel.syncing) {
64 | final import = ServiceImport(uri.queryParameters);
65 | final route = ServiceImport.typeMap[uri.queryParameters["t"]];
66 | if (route != null) {
67 | final navigator = Navigator.of(context);
68 | while (navigator.canPop()) navigator.pop();
69 | navigator.pushNamed(route, arguments: import);
70 | }
71 | }
72 | }
73 | }
74 |
75 | @override
76 | void initState() {
77 | super.initState();
78 | _uriSub = uriLinkStream.listen(_uriStreamListener);
79 | Future.delayed(Duration.zero, () async {
80 | try {
81 | final uri = await getInitialUri();
82 | if (uri != null) {
83 | _uriStreamListener(uri);
84 | }
85 | } catch (exp) {
86 | print(exp);
87 | }
88 | });
89 | }
90 |
91 | @override
92 | dispose() {
93 | _uriSub.cancel();
94 | super.dispose();
95 | }
96 |
97 | Widget _constructPage(Widget page, bool isMobile) {
98 | return isMobile
99 | ? CupertinoPageScaffold(
100 | child: page,
101 | backgroundColor:
102 | CupertinoColors.systemBackground.resolveFrom(context),
103 | )
104 | : Container(
105 | child: page,
106 | color: CupertinoColors.systemBackground.resolveFrom(context),
107 | );
108 | }
109 |
110 | Widget buildLeft(BuildContext context, {isMobile: true}) {
111 | final leftTabs = CupertinoTabScaffold(
112 | controller: _controller,
113 | backgroundColor: CupertinoColors.systemBackground,
114 | tabBar: CupertinoTabBar(
115 | backgroundColor: CupertinoColors.systemBackground,
116 | onTap: _scrollTopNotifier.onTap,
117 | items: [
118 | BottomNavigationBarItem(
119 | icon: Icon(Icons.timeline),
120 | label: S.of(context).feed,
121 | ),
122 | BottomNavigationBarItem(
123 | icon: Icon(Icons.list),
124 | label: S.of(context).subscriptions,
125 | ),
126 | ],
127 | ),
128 | tabBuilder: (context, index) {
129 | return CupertinoTabView(
130 | navigatorKey: _tabNavigatorKeys[index],
131 | routes: {
132 | '/feed': (context) {
133 | Widget page = ItemListPage(_scrollTopNotifier);
134 | return _constructPage(page, isMobile);
135 | },
136 | },
137 | builder: (context) {
138 | Widget page = index == 0
139 | ? ItemListPage(_scrollTopNotifier)
140 | : SubscriptionListPage(_scrollTopNotifier);
141 | return _constructPage(page, isMobile);
142 | },
143 | );
144 | },
145 | );
146 | return WillPopScope(
147 | child: leftTabs,
148 | onWillPop: () async {
149 | return !(await _tabNavigatorKeys[_controller.index]
150 | .currentState
151 | .maybePop());
152 | },
153 | );
154 | }
155 |
156 | @override
157 | Widget build(BuildContext context) {
158 | return Selector(
159 | selector: (context, syncModel) => syncModel.hasService,
160 | builder: (context, hasService, child) {
161 | if (!hasService) return SetupPage();
162 | return ScreenTypeLayout.builder(
163 | breakpoints: ScreenBreakpoints(
164 | tablet: 640,
165 | watch: 0,
166 | desktop: 1600,
167 | ),
168 | mobile: (context) => buildLeft(context),
169 | tablet: (context) {
170 | final left = buildLeft(context, isMobile: false);
171 | final right = Container(
172 | decoration: BoxDecoration(),
173 | clipBehavior: Clip.hardEdge,
174 | child: CupertinoTabView(
175 | navigatorKey: Global.tabletPanel,
176 | routes: {
177 | "/": (context) => TabletBasePage(),
178 | ...MyApp.baseRoutes,
179 | },
180 | ));
181 | return Container(
182 | color: CupertinoColors.systemBackground.resolveFrom(context),
183 | child: Row(
184 | children: [
185 | Container(
186 | constraints: BoxConstraints(maxWidth: 320),
187 | child: left,
188 | ),
189 | VerticalDivider(
190 | width: 1,
191 | thickness: 1,
192 | color: CupertinoColors.systemGrey4.resolveFrom(context),
193 | ),
194 | Expanded(child: right),
195 | ],
196 | ));
197 | },
198 | );
199 | },
200 | );
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/lib/models/services/fever.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:math';
3 |
4 | import 'package:fluent_reader_lite/models/item.dart';
5 | import 'package:fluent_reader_lite/utils/global.dart';
6 | import 'package:fluent_reader_lite/utils/store.dart';
7 | import 'package:fluent_reader_lite/utils/utils.dart';
8 | import 'package:html/parser.dart';
9 | import 'package:http/http.dart' as http;
10 | import 'package:fluent_reader_lite/models/source.dart';
11 | import 'package:tuple/tuple.dart';
12 |
13 | import '../service.dart';
14 |
15 | class FeverServiceHandler extends ServiceHandler {
16 | String endpoint;
17 | String apiKey;
18 | int _lastId;
19 | int fetchLimit;
20 | bool _useInt32;
21 |
22 | FeverServiceHandler() {
23 | endpoint = Store.sp.getString(StoreKeys.ENDPOINT);
24 | apiKey = Store.sp.getString(StoreKeys.API_KEY);
25 | _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
26 | fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT);
27 | _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false;
28 | }
29 |
30 | FeverServiceHandler.fromValues(
31 | this.endpoint,
32 | this.apiKey,
33 | this.fetchLimit,
34 | ) {
35 | _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
36 | _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false;
37 | }
38 |
39 | void persist(String username, String password) {
40 | Store.sp.setInt(StoreKeys.SYNC_SERVICE, SyncService.Fever.index);
41 | Store.sp.setString(StoreKeys.ENDPOINT, endpoint);
42 | Store.sp.setString(StoreKeys.USERNAME, username);
43 | Store.sp.setString(StoreKeys.PASSWORD, password);
44 | Store.sp.setString(StoreKeys.API_KEY, apiKey);
45 | Store.sp.setInt(StoreKeys.FETCH_LIMIT, fetchLimit);
46 | Store.sp.setInt(StoreKeys.LAST_ID, _lastId);
47 | Store.sp.setBool(StoreKeys.FEVER_INT_32, _useInt32);
48 | Global.service = this;
49 | }
50 |
51 | @override
52 | void remove() {
53 | super.remove();
54 | Store.sp.remove(StoreKeys.ENDPOINT);
55 | Store.sp.remove(StoreKeys.USERNAME);
56 | Store.sp.remove(StoreKeys.PASSWORD);
57 | Store.sp.remove(StoreKeys.API_KEY);
58 | Store.sp.remove(StoreKeys.FETCH_LIMIT);
59 | Store.sp.remove(StoreKeys.LAST_ID);
60 | Store.sp.remove(StoreKeys.FEVER_INT_32);
61 | Global.service = null;
62 | }
63 |
64 | Future