├── ios ├── Runner │ ├── de.lproj │ │ └── LaunchScreen.strings │ ├── es.lproj │ │ └── LaunchScreen.strings │ ├── zh-Hans.lproj │ │ └── LaunchScreen.strings │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── 100.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 144.png │ │ │ ├── 152.png │ │ │ ├── 167.png │ │ │ ├── 180.png │ │ │ ├── 20.png │ │ │ ├── 29.png │ │ │ ├── 40.png │ │ │ ├── 50.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 72.png │ │ │ ├── 76.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ ├── 1024.png │ │ │ └── Contents.json │ │ └── LaunchImage.imageset │ │ │ ├── 128x128.png │ │ │ ├── 256x256.png │ │ │ ├── 512x512.png │ │ │ ├── README.md │ │ │ └── Contents.json │ ├── Runner.entitlements │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── .gitignore ├── Podfile └── Podfile.lock ├── android ├── app │ ├── proguard-rules.pro │ ├── src │ │ ├── main │ │ │ ├── ic_launcher-playstore.png │ │ │ ├── res │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── logo.png │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ └── drawable-night │ │ │ │ │ └── launch_background.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── fluent_reader_lite │ │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle └── build.gradle ├── assets ├── demo │ └── demo.png ├── icons │ ├── logo.png │ ├── logo-outline.png │ └── logo-outline-dark.png └── article │ ├── article.html │ ├── article.js │ └── article.css ├── .metadata ├── .vscode └── launch.json ├── lib ├── models │ ├── services │ │ ├── service_import.dart │ │ └── fever.dart │ ├── service.dart │ ├── groups_model.dart │ ├── source.dart │ ├── item.dart │ ├── sync_model.dart │ ├── global_model.dart │ ├── feeds_model.dart │ ├── feed.dart │ ├── items_model.dart │ └── sources_model.dart ├── components │ ├── responsive_action_sheet.dart │ ├── dismissible_background.dart │ ├── badge.dart │ ├── sync_control.dart │ ├── favicon.dart │ ├── mark_all_action_sheet.dart │ ├── list_tile_group.dart │ ├── time_text.dart │ ├── my_list_tile.dart │ ├── cupertino_toolbar.dart │ └── subscription_item.dart ├── pages │ ├── tablet_base_page.dart │ ├── error_log_page.dart │ ├── settings │ │ ├── sources_page.dart │ │ ├── reading_page.dart │ │ ├── about_page.dart │ │ ├── text_editor_page.dart │ │ ├── source_edit_page.dart │ │ ├── feed_page.dart │ │ └── general_page.dart │ ├── setup_page.dart │ ├── settings_page.dart │ ├── group_list_page.dart │ └── home_page.dart ├── utils │ ├── colors.dart │ ├── db.dart │ ├── utils.dart │ ├── global.dart │ └── store.dart ├── l10n │ ├── intl_zh.arb │ ├── intl_en.arb │ ├── intl_hr.arb │ ├── intl_ptBR.arb │ ├── intl_es.arb │ └── intl_de.arb └── main.dart ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── FUNDING.yml ├── .gitignore ├── test └── widget_test.dart ├── LICENSE ├── README.md └── pubspec.yaml /ios/Runner/de.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ios/Runner/es.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ios/Runner/zh-Hans.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keep class io.flutter.plugin.editing.** { *; } -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /assets/demo/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/assets/demo/demo.png -------------------------------------------------------------------------------- /assets/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/assets/icons/logo.png -------------------------------------------------------------------------------- /assets/icons/logo-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/assets/icons/logo-outline.png -------------------------------------------------------------------------------- /assets/icons/logo-outline-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/assets/icons/logo-outline-dark.png -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.enableR8=true 5 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxxhdpi/logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/LaunchImage.imageset/128x128.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/LaunchImage.imageset/256x256.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/ios/Runner/Assets.xcassets/LaunchImage.imageset/512x512.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcomplete/fluent-reader-lite/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.hyliu.fluent_reader_lite 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip 7 | -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 78910062997c3a836feee883712c241a5fd22983 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Flutter", 9 | "request": "launch", 10 | "type": "dart", 11 | "flutterMode": "debug" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "128x128.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "256x256.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "512x512.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/models/services/service_import.dart: -------------------------------------------------------------------------------- 1 | class ServiceImport { 2 | String endpoint; 3 | String username; 4 | String password; 5 | String apiId; 6 | String apiKey; 7 | 8 | static const typeMap = { 9 | "f": "/settings/service/fever", 10 | "g": "/settings/service/greader", 11 | "i": "/settings/service/inoreader", 12 | "fb": "/settings/service/feedbin" 13 | }; 14 | 15 | ServiceImport(Map params) { 16 | endpoint = params["e"]; 17 | username = params["u"]; 18 | password = params["p"]; 19 | apiId = params["i"]; 20 | apiKey = params["k"]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/components/responsive_action_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/utils/global.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | 4 | class ResponsiveActionSheet extends StatelessWidget { 5 | final Widget child; 6 | 7 | ResponsiveActionSheet(this.child); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | if (!Global.isTablet) return child; 12 | return Row( 13 | mainAxisAlignment: MainAxisAlignment.start, 14 | children: [ 15 | Container( 16 | constraints: BoxConstraints(maxWidth: 320), 17 | child: child, 18 | ) 19 | ], 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | build 28 | 29 | # Exceptions to above rules. 30 | !default.mode1v3 31 | !default.mode2v3 32 | !default.pbxuser 33 | !default.perspectivev3 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /assets/article/article.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | Article 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.50' 3 | repositories { 4 | google() 5 | jcenter() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:3.5.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | jcenter() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["yang991178"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # "fluent-reader" 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://www.paypal.me/yang991178", "https://hyliu.me/fluent-reader/imgs/alipay.jpg"] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform (please complete the following information):** 27 | - OS: [e.g. Windows 10 2004] 28 | - Version [e.g. 0.6.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /lib/pages/tablet_base_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/utils/global.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter/services.dart'; 4 | 5 | class TabletBasePage extends StatelessWidget { 6 | @override 7 | Widget build(BuildContext context) { 8 | var b = Global.currentBrightness(context) == Brightness.light; 9 | return AnnotatedRegion( 10 | value: b ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light, 11 | child: Container( 12 | color: CupertinoColors.systemBackground.resolveFrom(context), 13 | child: Center( 14 | child: Image.asset( 15 | "assets/icons/logo-outline${b?'':'-dark'}.png", 16 | width: 120, height: 120, 17 | ), 18 | ), 19 | ), 20 | ); 21 | } 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | lib/generated/ 34 | 35 | # Web related 36 | lib/generated_plugin_registrant.dart 37 | 38 | # Symbolication related 39 | app.*.symbols 40 | 41 | # Obfuscation related 42 | app.*.map.json 43 | -------------------------------------------------------------------------------- /lib/components/dismissible_background.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class DismissibleBackground extends StatelessWidget { 4 | final IconData icon; 5 | final bool isToRight; 6 | 7 | DismissibleBackground(this.icon, this.isToRight, {Key key}) 8 | : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) => Container( 12 | color: CupertinoColors.systemGrey5.resolveFrom(context), 13 | padding: EdgeInsets.symmetric(horizontal: 24), 14 | child: Column( 15 | mainAxisAlignment: MainAxisAlignment.center, 16 | crossAxisAlignment: isToRight 17 | ? CrossAxisAlignment.start 18 | : CrossAxisAlignment.end, 19 | children: [Icon( 20 | icon, 21 | color: CupertinoColors.secondaryLabel.resolveFrom(context), 22 | )], 23 | ), 24 | ); 25 | } -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 10.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/components/badge.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class Badge extends StatelessWidget { 4 | Badge(int count, {this.color : CupertinoColors.systemRed, Key key}) : 5 | label = count >= 1000 ? "999+" : count.toString(), 6 | super(key: key); 7 | 8 | final String label; 9 | final CupertinoDynamicColor color; 10 | final labelStyle = TextStyle( 11 | color: CupertinoColors.white, 12 | fontSize: 12 13 | ); 14 | 15 | Widget build(BuildContext context) => Padding( 16 | padding: EdgeInsets.all(3), 17 | child: ClipRRect( 18 | borderRadius: BorderRadius.circular(8), 19 | child: Container( 20 | height: 16, 21 | color: color.resolveFrom(context), 22 | child: Padding( 23 | padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1), 24 | child: Text(label, style: labelStyle,), 25 | ), 26 | ), 27 | ) 28 | ); 29 | } -------------------------------------------------------------------------------- /lib/components/sync_control.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:fluent_reader_lite/utils/global.dart'; 4 | import 'package:flutter/cupertino.dart'; 5 | 6 | class SyncControl extends StatefulWidget { 7 | @override 8 | _SyncControlState createState() => _SyncControlState(); 9 | } 10 | 11 | class _SyncControlState extends State { 12 | Future _onRefresh() { 13 | var completer = Completer(); 14 | Function listener; 15 | listener = () { 16 | if (!Global.syncModel.syncing) { 17 | completer.complete(); 18 | Global.syncModel.removeListener(listener); 19 | } 20 | }; 21 | Global.syncModel.addListener(listener); 22 | Global.syncModel.syncWithService(); 23 | return completer.future; 24 | } 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return CupertinoSliverRefreshControl( 29 | onRefresh: _onRefresh, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/utils/colors.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class MyColors { 5 | static const background = CupertinoDynamicColor.withBrightness( 6 | color: CupertinoColors.extraLightBackgroundGray, 7 | darkColor: CupertinoColors.black, 8 | ); 9 | 10 | static const tileBackground = CupertinoDynamicColor.withBrightness( 11 | color: CupertinoColors.white, 12 | darkColor: CupertinoColors.darkBackgroundGray, 13 | ); 14 | 15 | static const barDivider = CupertinoDynamicColor.withBrightness( 16 | color: CupertinoColors.systemGrey2, 17 | darkColor: CupertinoColors.black, 18 | ); 19 | 20 | static const dynamicBlack = CupertinoDynamicColor.withBrightness( 21 | color: CupertinoColors.black, 22 | darkColor: CupertinoColors.white, 23 | ); 24 | 25 | static const dynamicDarkGrey = CupertinoDynamicColor.withBrightness( 26 | color: Colors.black87, 27 | darkColor: Colors.white70, 28 | ); 29 | 30 | static const indicatorOrange = Color.fromRGBO(255, 170, 68, 1); 31 | } -------------------------------------------------------------------------------- /lib/models/service.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/models/item.dart'; 2 | import 'package:fluent_reader_lite/models/source.dart'; 3 | import 'package:fluent_reader_lite/utils/global.dart'; 4 | import 'package:fluent_reader_lite/utils/store.dart'; 5 | import 'package:tuple/tuple.dart'; 6 | 7 | enum SyncService { 8 | None, Fever, Feedbin, GReader, Inoreader 9 | } 10 | 11 | abstract class ServiceHandler { 12 | void remove() { 13 | Store.sp.remove(StoreKeys.SYNC_SERVICE); 14 | Global.groupsModel.groups = Map(); 15 | Global.groupsModel.showUncategorized = false; 16 | } 17 | Future validate(); 18 | Future reauthenticate() async { } 19 | Future, Map>>> getSources(); 20 | Future> fetchItems(); 21 | Future, Set>> syncItems(); 22 | Future markAllRead(Set sids, DateTime date, bool before); 23 | Future markRead(RSSItem item); 24 | Future markUnread(RSSItem item); 25 | Future star(RSSItem item); 26 | Future unstar(RSSItem item); 27 | } 28 | -------------------------------------------------------------------------------- /lib/components/favicon.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:fluent_reader_lite/models/source.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class Favicon extends StatelessWidget { 6 | final RSSSource source; 7 | final double size; 8 | 9 | const Favicon(this.source, {this.size: 16, Key key}) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final _textStyle = TextStyle( 14 | fontSize: size - 5, 15 | color: CupertinoColors.systemGrey6, 16 | ); 17 | 18 | if (source.iconUrl != null && source.iconUrl.length > 0) { 19 | return CachedNetworkImage( 20 | imageUrl: source.iconUrl, 21 | width: size, 22 | height: size, 23 | ); 24 | } else { 25 | return Container( 26 | width: size, 27 | height: size, 28 | color: CupertinoColors.systemGrey.resolveFrom(context), 29 | child: Center(child: Text( 30 | source.name.length > 0 ? source.name[0] : "?", 31 | style: _textStyle, 32 | )), 33 | ); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:fluent_reader_lite/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /lib/pages/error_log_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/components/list_tile_group.dart'; 2 | import 'package:fluent_reader_lite/generated/l10n.dart'; 3 | import 'package:fluent_reader_lite/utils/colors.dart'; 4 | import 'package:fluent_reader_lite/utils/store.dart'; 5 | import 'package:flutter/cupertino.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter/services.dart'; 8 | 9 | class ErrorLogPage extends StatelessWidget { 10 | @override 11 | Widget build(BuildContext context) { 12 | final errorLog = Store.getErrorLog(); 13 | return CupertinoPageScaffold( 14 | backgroundColor: MyColors.background, 15 | navigationBar: CupertinoNavigationBar( 16 | middle: Text(S.of(context).errorLog), 17 | trailing: CupertinoButton( 18 | padding: EdgeInsets.zero, 19 | child: Text(S.of(context).copy), 20 | onPressed: () { 21 | Clipboard.setData(ClipboardData(text: errorLog)); 22 | }, 23 | ), 24 | ), 25 | child: ListView(children: [ 26 | ListTileGroup([ 27 | SelectableText( 28 | errorLog, 29 | style: TextStyle(color: CupertinoColors.label.resolveFrom(context)), 30 | ), 31 | ]), 32 | ]), 33 | ); 34 | } 35 | } -------------------------------------------------------------------------------- /lib/models/groups_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/utils/global.dart'; 2 | import 'package:fluent_reader_lite/utils/store.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class GroupsModel with ChangeNotifier { 6 | Map> _groups = Store.getGroups(); 7 | List uncategorized = Store.getUncategorized(); 8 | 9 | Map> get groups => _groups; 10 | set groups(Map> groups) { 11 | _groups = groups; 12 | updateUncategorized(); 13 | notifyListeners(); 14 | Store.setGroups(groups); 15 | } 16 | 17 | void updateUncategorized({force: false}) { 18 | if (uncategorized != null || force) { 19 | final sids = Set.from( 20 | Global.sourcesModel.getSources().map((s) => s.id) 21 | ); 22 | for (var group in _groups.values) { 23 | for (var sid in group) { 24 | sids.remove(sid); 25 | } 26 | } 27 | uncategorized = sids.toList(); 28 | Store.setUncategorized(uncategorized); 29 | } 30 | } 31 | 32 | bool get showUncategorized => uncategorized != null; 33 | set showUncategorized(bool value) { 34 | if (showUncategorized != value) { 35 | if (value) { 36 | updateUncategorized(force: true); 37 | } else { 38 | uncategorized = null; 39 | Store.setUncategorized(null); 40 | } 41 | notifyListeners(); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /lib/utils/db.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:path/path.dart'; 4 | import 'package:sqflite/sqflite.dart'; 5 | 6 | abstract class DatabaseHelper { 7 | static final _dbName = "frlite.db"; 8 | static final _dbVersion = 1; 9 | 10 | static Database _database; 11 | 12 | static Future getDatabase() async { 13 | if (_database != null) return _database; 14 | String path = join(await getDatabasesPath(), _dbName); 15 | _database = await openDatabase(path, version:_dbVersion, onCreate: _onCreate); 16 | return _database; 17 | } 18 | 19 | static Future _onCreate(Database db, int version) async { 20 | await db.execute(''' 21 | CREATE TABLE sources ( 22 | sid TEXT PRIMARY KEY, 23 | url TEXT NOT NULL, 24 | iconUrl TEXT, 25 | name TEXT NOT NULL, 26 | openTarget INTEGER NOT NULL, 27 | latest INTEGER NOT NULL, 28 | lastTitle INTEGER NOT NULL 29 | ); 30 | '''); 31 | await db.execute(''' 32 | CREATE TABLE items ( 33 | iid TEXT PRIMARY KEY, 34 | source TEXT NOT NULL, 35 | title TEXT NOT NULL, 36 | link TEXT NOT NULL, 37 | date INTEGER NOT NULL, 38 | content TEXT NOT NULL, 39 | snippet TEXT NOT NULL, 40 | hasRead INTEGER NOT NULL, 41 | starred INTEGER NOT NULL, 42 | creator TEXT, 43 | thumb TEXT 44 | ); 45 | '''); 46 | await db.execute("CREATE INDEX itemsDate ON items (date DESC);"); 47 | } 48 | } -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/models/source.dart: -------------------------------------------------------------------------------- 1 | enum SourceOpenTarget { 2 | Local, FullContent, Webpage, External 3 | } 4 | 5 | class RSSSource { 6 | String id; 7 | String url; 8 | String iconUrl; 9 | String name; 10 | SourceOpenTarget openTarget; 11 | int unreadCount; 12 | DateTime latest; 13 | String lastTitle; 14 | 15 | RSSSource(this.id, this.url, this.name) { 16 | openTarget = SourceOpenTarget.Local; 17 | latest = DateTime.now(); 18 | unreadCount = 0; 19 | lastTitle = ""; 20 | } 21 | 22 | RSSSource._privateConstructor( 23 | this.id, this.url, this.iconUrl, this.name, this.openTarget, 24 | this.unreadCount, this.latest, this.lastTitle, 25 | ); 26 | 27 | RSSSource clone() { 28 | return RSSSource._privateConstructor( 29 | this.id, this.url, this.iconUrl, this.name, this.openTarget, 30 | this.unreadCount, this.latest, this.lastTitle, 31 | ); 32 | } 33 | 34 | Map toMap() { 35 | return { 36 | "sid": id, 37 | "url": url, 38 | "iconUrl": iconUrl, 39 | "name": name, 40 | "openTarget": openTarget.index, 41 | "latest": latest.millisecondsSinceEpoch, 42 | "lastTitle": lastTitle, 43 | }; 44 | } 45 | 46 | RSSSource.fromMap(Map map) { 47 | id = map["sid"]; 48 | url = map["url"]; 49 | iconUrl = map["iconUrl"]; 50 | name = map["name"]; 51 | openTarget = SourceOpenTarget.values[map["openTarget"]]; 52 | latest = DateTime.fromMillisecondsSinceEpoch(map["latest"]); 53 | lastTitle = map["lastTitle"]; 54 | unreadCount = 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Haoyuan Liu 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /lib/models/item.dart: -------------------------------------------------------------------------------- 1 | class RSSItem { 2 | String id; 3 | String source; 4 | String title; 5 | String link; 6 | DateTime date; 7 | String content; 8 | String snippet; 9 | bool hasRead; 10 | bool starred; 11 | String creator; // Optional 12 | String thumb; // Optional 13 | 14 | RSSItem({ 15 | this.id, this.source, this.title, this.link, this.date, 16 | this.content, this.snippet, this.hasRead, this.starred, 17 | this.creator, this.thumb 18 | }); 19 | 20 | RSSItem clone() { 21 | return RSSItem( 22 | id: id, source: source, title: title, link: link, date: date, 23 | content: content, snippet: snippet, hasRead: hasRead, starred: starred, 24 | creator: creator, thumb: thumb, 25 | ); 26 | } 27 | 28 | Map toMap() { 29 | return { 30 | "iid": id, 31 | "source": source, 32 | "title": title, 33 | "link": link, 34 | "date": date.millisecondsSinceEpoch, 35 | "content": content, 36 | "snippet": snippet, 37 | "hasRead": hasRead ? 1 : 0, 38 | "starred": starred ? 1 : 0, 39 | "creator": creator, 40 | "thumb": thumb, 41 | }; 42 | } 43 | 44 | RSSItem.fromMap(Map map) { 45 | id = map["iid"]; 46 | source = map["source"]; 47 | title = map["title"]; 48 | link = map["link"]; 49 | date = DateTime.fromMillisecondsSinceEpoch(map["date"]); 50 | content = map["content"]; 51 | snippet = map["snippet"]; 52 | hasRead = map["hasRead"] != 0; 53 | starred = map["starred"] != 0; 54 | creator = map["creator"]; 55 | thumb = map["thumb"]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/pages/settings/sources_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/components/favicon.dart'; 2 | import 'package:fluent_reader_lite/components/list_tile_group.dart'; 3 | import 'package:fluent_reader_lite/components/my_list_tile.dart'; 4 | import 'package:fluent_reader_lite/generated/l10n.dart'; 5 | import 'package:fluent_reader_lite/models/sources_model.dart'; 6 | import 'package:fluent_reader_lite/utils/colors.dart'; 7 | import 'package:fluent_reader_lite/utils/utils.dart'; 8 | import 'package:flutter/cupertino.dart'; 9 | import 'package:provider/provider.dart'; 10 | 11 | class SourcesPage extends StatelessWidget { 12 | @override 13 | Widget build(BuildContext context) { 14 | return CupertinoPageScaffold( 15 | backgroundColor: MyColors.background, 16 | navigationBar: CupertinoNavigationBar( 17 | middle: Text(S.of(context).subscriptions), 18 | ), 19 | child: ListView(children: [ 20 | Consumer( 21 | builder: (context, sourcesModel, child) { 22 | var sources = sourcesModel.getSources().toList(); 23 | sources.sort((a, b) => Utils.localStringCompare(a.name, b.name)); 24 | return ListTileGroup(sources.map((s) => MyListTile( 25 | title: Flexible(child: Text(s.name, overflow: TextOverflow.ellipsis)), 26 | leading: Favicon(s, size: 20), 27 | withDivider: s.id != sources.last.id, 28 | onTap: () { 29 | Navigator.of(context).pushNamed("/settings/sources/edit", arguments: s.id); 30 | }, 31 | ))); 32 | }, 33 | ), 34 | ]), 35 | ); 36 | } 37 | } -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.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 | -------------------------------------------------------------------------------- /lib/pages/settings/reading_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/utils/colors.dart'; 5 | import 'package:fluent_reader_lite/utils/store.dart'; 6 | import 'package:flutter/cupertino.dart'; 7 | 8 | class ReadingPage extends StatefulWidget { 9 | @override 10 | _ReadingPageState createState() => _ReadingPageState(); 11 | } 12 | 13 | class _ReadingPageState extends State { 14 | int _fontSize = Store.getArticleFontSize(); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return CupertinoPageScaffold( 19 | backgroundColor: MyColors.background, 20 | navigationBar: CupertinoNavigationBar( 21 | middle: Text(S.of(context).reading), 22 | ), 23 | child: ListView(children: [ 24 | ListTileGroup([ 25 | MyListTile( 26 | title: Text(S.of(context).fontSize), 27 | trailing: Text(_fontSize.toString()), 28 | trailingChevron: false, 29 | withDivider: false, 30 | ), 31 | MyListTile( 32 | title: Expanded(child: CupertinoSlider( 33 | min: 10, 34 | max: 22, 35 | divisions: 13, 36 | value: _fontSize.toDouble(), 37 | onChanged: (v) { setState(() { _fontSize = v.toInt(); }); }, 38 | onChangeEnd: (v) { Store.setArticleFontSize(v.toInt()); }, 39 | )), 40 | trailingChevron: false, 41 | withDivider: false, 42 | ), 43 | ], title: S.of(context).preferences), 44 | ]), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/components/mark_all_action_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/components/responsive_action_sheet.dart'; 2 | import 'package:fluent_reader_lite/generated/l10n.dart'; 3 | import 'package:fluent_reader_lite/utils/global.dart'; 4 | import 'package:flutter/cupertino.dart'; 5 | 6 | class MarkAllActionSheet extends StatelessWidget { 7 | final Set sids; 8 | 9 | MarkAllActionSheet(this.sids, {Key key}) : super(key: key); 10 | 11 | DateTime _offset(int days) { 12 | return DateTime.now().subtract(Duration(days: days)); 13 | } 14 | 15 | void _markAll(BuildContext context, {DateTime date}) { 16 | Navigator.of(context, rootNavigator: true).pop(); 17 | Global.itemsModel.markAllRead(sids, date: date); 18 | } 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final sheet = CupertinoActionSheet( 23 | title: Text(S.of(context).markAll), 24 | actions: [ 25 | CupertinoActionSheetAction( 26 | isDestructiveAction: true, 27 | child: Text(S.of(context).allArticles), 28 | onPressed: () { _markAll(context); }, 29 | ), 30 | CupertinoActionSheetAction( 31 | child: Text(S.of(context).daysAgo(1)), 32 | onPressed: () { _markAll(context, date: _offset(1)); }, 33 | ), 34 | CupertinoActionSheetAction( 35 | child: Text(S.of(context).daysAgo(3)), 36 | onPressed: () { _markAll(context, date: _offset(3)); }, 37 | ), 38 | CupertinoActionSheetAction( 39 | child: Text(S.of(context).daysAgo(7)), 40 | onPressed: () { _markAll(context, date: _offset(7)); }, 41 | ), 42 | ], 43 | cancelButton: CupertinoActionSheetAction( 44 | child: Text(S.of(context).cancel), 45 | onPressed: () { 46 | Navigator.of(context, rootNavigator: true).pop(); 47 | }, 48 | ), 49 | ); 50 | return ResponsiveActionSheet(sheet); 51 | } 52 | } -------------------------------------------------------------------------------- /lib/components/list_tile_group.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/components/my_list_tile.dart'; 2 | import 'package:fluent_reader_lite/utils/colors.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:tuple/tuple.dart'; 6 | 7 | class ListTileGroup extends StatelessWidget { 8 | ListTileGroup(this.children, {this.title, Key key}) : super(key: key); 9 | 10 | ListTileGroup.fromOptions( 11 | List> options, 12 | dynamic selected, 13 | Function onSelected, 14 | {this.title, Key key}) : 15 | children = options.map((t) => MyListTile( 16 | title: Text(t.item1), 17 | trailing: t.item2 == selected 18 | ? Icon(Icons.done) 19 | : Icon(null), 20 | trailingChevron: false, 21 | onTap: () { onSelected(t.item2); }, 22 | withDivider: t.item2 != options.last.item2, 23 | )), 24 | super(key: key); 25 | 26 | final Iterable children; 27 | final String title; 28 | 29 | static const _titleStyle = TextStyle( 30 | fontSize: 12, 31 | color: CupertinoColors.systemGrey, 32 | ); 33 | 34 | @override 35 | Widget build(BuildContext context) => Column( 36 | crossAxisAlignment: CrossAxisAlignment.start, 37 | children: [ 38 | if (title != null) Padding( 39 | padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6), 40 | child: Text(title, style: _titleStyle), 41 | ), 42 | Container( 43 | color: MyColors.tileBackground.resolveFrom(context), 44 | child: Column(children: [ 45 | Divider( 46 | color: CupertinoColors.systemGrey5.resolveFrom(context), 47 | height: 1, 48 | thickness: 1, 49 | ), 50 | ...children, 51 | Divider( 52 | color: CupertinoColors.systemGrey5.resolveFrom(context), 53 | height: 1, 54 | thickness: 1, 55 | ), 56 | ], 57 | ), 58 | ), 59 | ],); 60 | } -------------------------------------------------------------------------------- /assets/article/article.js: -------------------------------------------------------------------------------- 1 | function get(name) { 2 | if (name = (new RegExp('[?&]' + encodeURIComponent(name) + '=([^&]*)')).exec(location.search)) 3 | return decodeURIComponent(name[1]) 4 | return null 5 | } 6 | async function getArticle(url) { 7 | let article = get("a") 8 | if (get("m") === "1") { 9 | return (await Mercury.parse(url, {html: article})).content || "" 10 | } else { 11 | return article 12 | } 13 | } 14 | document.documentElement.style.fontSize = get("s") + "px" 15 | let theme = get("t") 16 | if (theme !== null) document.documentElement.classList.add(theme === "1" ? "light" : "dark") 17 | let url = get("u") 18 | getArticle(url).then(article => { 19 | let domParser = new DOMParser() 20 | let dom = domParser.parseFromString(get("h"), "text/html") 21 | dom.getElementsByTagName("article")[0].innerHTML = article 22 | let baseUrl = url.split("/").slice(0, 3).join("/") 23 | for (let s of dom.getElementsByTagName("script")) { 24 | s.parentNode.removeChild(s) 25 | } 26 | for (let e of dom.querySelectorAll("*[src]")) { 27 | if (e.src && !e.src.startsWith("http")) { 28 | if (e.src.startsWith("/")) { 29 | e.src = baseUrl + e.src 30 | } else if (e.src.startsWith(":")) { 31 | e.src = "http" + e.src 32 | } else { 33 | e.src = baseUrl + "/" + e.src 34 | } 35 | } 36 | } 37 | for (let e of dom.querySelectorAll("*[href]")) { 38 | if (e.href && !e.href.startsWith("http")) { 39 | if (e.href.startsWith("/")) { 40 | e.href = baseUrl + e.href 41 | } else if (e.href.startsWith(":")) { 42 | e.href = "http" + e.href 43 | } else { 44 | e.href = baseUrl + "/" + e.href 45 | } 46 | } 47 | } 48 | let main = document.getElementById("main") 49 | main.innerHTML = dom.body.innerHTML 50 | main.classList.add("show") 51 | }) 52 | 53 | -------------------------------------------------------------------------------- /lib/components/time_text.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class TimeText extends StatefulWidget { 6 | final DateTime date; 7 | final TextStyle style; 8 | 9 | TimeText(this.date, {this.style, Key key}) : super(key: key); 10 | 11 | @override 12 | _TimeTextState createState() => _TimeTextState(); 13 | } 14 | 15 | class _TimeTextState extends State { 16 | Timer _timer; 17 | Duration _duration; 18 | 19 | int diffMinutes() { 20 | final now = DateTime.now(); 21 | return now.difference(widget.date).inMinutes; 22 | } 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | updateTimer(); 28 | } 29 | 30 | void updateTimer() { 31 | final diff = diffMinutes(); 32 | Duration duration; 33 | if (diff < 60) { 34 | duration = Duration(minutes: 1); 35 | } else if (diff < 60 * 24) { 36 | duration = Duration(minutes: 60 - diff % 60); 37 | } else { 38 | duration = Duration(minutes: (60 * 24) - diff % (60 * 24)); 39 | } 40 | if (_duration == null || duration.compareTo(_duration) != 0) { 41 | _duration = duration; 42 | if (_timer != null) _timer.cancel(); 43 | _timer = Timer.periodic(duration, (_) { 44 | setState(() {}); 45 | updateTimer(); 46 | }); 47 | } 48 | } 49 | 50 | @override 51 | void dispose() { 52 | if (_timer != null) _timer.cancel(); 53 | super.dispose(); 54 | } 55 | 56 | @override 57 | void didUpdateWidget(covariant TimeText oldWidget) { 58 | if (oldWidget.date.compareTo(widget.date) != 0) updateTimer(); 59 | super.didUpdateWidget(oldWidget); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | final diff = diffMinutes(); 65 | String label; 66 | if (diff < 60) { 67 | label = "${diff}m"; 68 | } else if (diff < 60 * 24) { 69 | label = "${diff ~/ 60}h"; 70 | } else { 71 | label = "${diff ~/ (60 * 24)}d"; 72 | } 73 | return Text(label, style: widget.style); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/models/sync_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/utils/global.dart'; 2 | import 'package:fluent_reader_lite/utils/store.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class SyncModel with ChangeNotifier { 6 | bool hasService = Global.service != null; 7 | bool syncing = false; 8 | bool _lastSyncSuccess = Store.sp.getBool(StoreKeys.LAST_SYNC_SUCCESS) ?? true; 9 | DateTime _lastSynced = DateTime.fromMillisecondsSinceEpoch( 10 | Store.sp.getInt(StoreKeys.LAST_SYNCED) ?? 0 11 | ); 12 | 13 | void checkHasService() { 14 | var value = Global.service != null; 15 | if (value != hasService) { 16 | hasService = value; 17 | notifyListeners(); 18 | } 19 | } 20 | 21 | Future removeService() async { 22 | if (syncing || Global.service == null) return; 23 | syncing = true; 24 | notifyListeners(); 25 | var sids = Global.sourcesModel.getSources() 26 | .map((s) => s.id) 27 | .toList(); 28 | await Global.sourcesModel.removeSources(sids); 29 | Global.service.remove(); 30 | hasService = false; 31 | syncing = false; 32 | notifyListeners(); 33 | } 34 | 35 | bool get lastSyncSuccess => _lastSyncSuccess; 36 | set lastSyncSuccess(bool value) { 37 | _lastSyncSuccess = value; 38 | Store.sp.setBool(StoreKeys.LAST_SYNC_SUCCESS, value); 39 | } 40 | 41 | DateTime get lastSynced => _lastSynced; 42 | set lastSynced(DateTime value) { 43 | _lastSynced = value; 44 | Store.sp.setInt(StoreKeys.LAST_SYNCED, value.millisecondsSinceEpoch); 45 | } 46 | 47 | Future syncWithService() async { 48 | if (syncing || Global.service == null) return; 49 | syncing = true; 50 | notifyListeners(); 51 | try { 52 | await Global.service.reauthenticate(); 53 | await Global.sourcesModel.updateSources(); 54 | await Global.itemsModel.syncItems(); 55 | await Global.itemsModel.fetchItems(); 56 | lastSyncSuccess = true; 57 | } catch(exp) { 58 | lastSyncSuccess = false; 59 | Store.setErrorLog(exp.toString()); 60 | print(exp); 61 | } 62 | lastSynced = DateTime.now(); 63 | syncing = false; 64 | notifyListeners(); 65 | } 66 | } -------------------------------------------------------------------------------- /lib/models/global_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fluent_reader_lite/utils/store.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | enum ThemeSetting { 7 | Default, Light, Dark 8 | } 9 | 10 | class GlobalModel with ChangeNotifier { 11 | ThemeSetting _theme = Store.getTheme(); 12 | Locale _locale = Store.getLocale(); 13 | int _keepItemsDays = Store.sp.getInt(StoreKeys.KEEP_ITEMS_DAYS) ?? 21; 14 | bool _syncOnStart = Store.sp.getBool(StoreKeys.SYNC_ON_START) ?? true; 15 | bool _inAppBrowser = Store.sp.getBool(StoreKeys.IN_APP_BROWSER) ?? Platform.isIOS; 16 | double _textScale = Store.sp.getDouble(StoreKeys.TEXT_SCALE); 17 | 18 | ThemeSetting get theme => _theme; 19 | set theme(ThemeSetting value) { 20 | if (value != _theme) { 21 | _theme = value; 22 | notifyListeners(); 23 | Store.setTheme(value); 24 | } 25 | } 26 | Brightness getBrightness() { 27 | if (_theme == ThemeSetting.Default) return null; 28 | else return _theme == ThemeSetting.Light ? Brightness.light : Brightness.dark; 29 | } 30 | 31 | Locale get locale => _locale; 32 | set locale(Locale value) { 33 | if (value != _locale) { 34 | _locale = value; 35 | notifyListeners(); 36 | Store.setLocale(value); 37 | } 38 | } 39 | 40 | int get keepItemsDays => _keepItemsDays; 41 | set keepItemsDays(int value) { 42 | _keepItemsDays = value; 43 | Store.sp.setInt(StoreKeys.KEEP_ITEMS_DAYS, value); 44 | } 45 | 46 | bool get syncOnStart => _syncOnStart; 47 | set syncOnStart(bool value) { 48 | _syncOnStart = value; 49 | Store.sp.setBool(StoreKeys.SYNC_ON_START, value); 50 | } 51 | 52 | bool get inAppBrowser => _inAppBrowser; 53 | set inAppBrowser(bool value) { 54 | _inAppBrowser = value; 55 | Store.sp.setBool(StoreKeys.IN_APP_BROWSER, value); 56 | } 57 | 58 | double get textScale => _textScale; 59 | set textScale(double value) { 60 | if (_textScale != value) { 61 | _textScale = value; 62 | notifyListeners(); 63 | if (value == null) { 64 | Store.sp.remove(StoreKeys.TEXT_SCALE); 65 | } else { 66 | Store.sp.setDouble(StoreKeys.TEXT_SCALE, value); 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Fluent Reader 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | fluent_reader_lite 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleTypeRole 27 | Editor 28 | CFBundleURLName 29 | me.hyliu.fluent-reader-lite 30 | CFBundleURLSchemes 31 | 32 | fluent-reader 33 | 34 | 35 | 36 | CFBundleVersion 37 | $(FLUTTER_BUILD_NUMBER) 38 | ITSAppUsesNonExemptEncryption 39 | 40 | LSRequiresIPhoneOS 41 | 42 | NSAppTransportSecurity 43 | 44 | NSAllowsArbitraryLoads 45 | 46 | 47 | UILaunchStoryboardName 48 | LaunchScreen 49 | UIMainStoryboardFile 50 | Main 51 | UISupportedInterfaceOrientations 52 | 53 | UIInterfaceOrientationPortrait 54 | 55 | UISupportedInterfaceOrientations~ipad 56 | 57 | UIInterfaceOrientationPortrait 58 | UIInterfaceOrientationPortraitUpsideDown 59 | UIInterfaceOrientationLandscapeLeft 60 | UIInterfaceOrientationLandscapeRight 61 | 62 | UIViewControllerBasedStatusBarAppearance 63 | 64 | io.flutter.embedded_views_preview 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /lib/pages/settings/about_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/utils/colors.dart'; 5 | import 'package:fluent_reader_lite/utils/utils.dart'; 6 | import 'package:flutter/cupertino.dart'; 7 | 8 | class AboutPage extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | final String version = ModalRoute.of(context).settings.arguments ?? "1.0.0"; 12 | final nameStyle = TextStyle( 13 | color: CupertinoColors.label.resolveFrom(context), 14 | fontSize: 18, 15 | fontWeight: FontWeight.bold, 16 | height: 1.5, 17 | ); 18 | final versionStyle = TextStyle( 19 | color: CupertinoColors.label.resolveFrom(context), 20 | fontSize: 14, 21 | height: 1.5, 22 | ); 23 | final copyrightStyle = TextStyle( 24 | color: CupertinoColors.secondaryLabel.resolveFrom(context), 25 | fontSize: 12, 26 | height: 2, 27 | ); 28 | return CupertinoPageScaffold( 29 | backgroundColor: MyColors.background, 30 | navigationBar: CupertinoNavigationBar( 31 | middle: Text(S.of(context).about), 32 | ), 33 | child: ListView( 34 | children: [ 35 | Container( 36 | padding: EdgeInsets.symmetric(vertical: 100), 37 | child: Column( 38 | children: [ 39 | Image.asset("assets/icons/logo.png", width: 80, height: 80), 40 | Text("Fluent Reader Lite", style: nameStyle), 41 | Text("${S.of(context).version} $version", style: versionStyle), 42 | Text("Copyright © 2021 Haoyuan Liu. All rights reserved.", style: copyrightStyle), 43 | ], 44 | ), 45 | ), 46 | ListTileGroup([ 47 | MyListTile( 48 | title: Text(S.of(context).openSource), 49 | onTap: () { Utils.openExternal("https://github.com/yang991178/fluent-reader-lite"); }, 50 | ), 51 | MyListTile( 52 | title: Text(S.of(context).feedback), 53 | onTap: () { Utils.openExternal("https://github.com/yang991178/fluent-reader-lite/issues"); }, 54 | withDivider: false, 55 | ), 56 | ]), 57 | ], 58 | ), 59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Flutter (1.0.0) 3 | - FMDB (2.7.5): 4 | - FMDB/standard (= 2.7.5) 5 | - FMDB/standard (2.7.5) 6 | - package_info (0.0.1): 7 | - Flutter 8 | - path_provider (0.0.1): 9 | - Flutter 10 | - share (0.0.1): 11 | - Flutter 12 | - shared_preferences (0.0.1): 13 | - Flutter 14 | - sqflite (0.0.2): 15 | - Flutter 16 | - FMDB (>= 2.7.5) 17 | - uni_links (0.0.1): 18 | - Flutter 19 | - url_launcher (0.0.1): 20 | - Flutter 21 | - webview_flutter (0.0.1): 22 | - Flutter 23 | 24 | DEPENDENCIES: 25 | - Flutter (from `Flutter`) 26 | - package_info (from `.symlinks/plugins/package_info/ios`) 27 | - path_provider (from `.symlinks/plugins/path_provider/ios`) 28 | - share (from `.symlinks/plugins/share/ios`) 29 | - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) 30 | - sqflite (from `.symlinks/plugins/sqflite/ios`) 31 | - uni_links (from `.symlinks/plugins/uni_links/ios`) 32 | - url_launcher (from `.symlinks/plugins/url_launcher/ios`) 33 | - webview_flutter (from `.symlinks/plugins/webview_flutter/ios`) 34 | 35 | SPEC REPOS: 36 | trunk: 37 | - FMDB 38 | 39 | EXTERNAL SOURCES: 40 | Flutter: 41 | :path: Flutter 42 | package_info: 43 | :path: ".symlinks/plugins/package_info/ios" 44 | path_provider: 45 | :path: ".symlinks/plugins/path_provider/ios" 46 | share: 47 | :path: ".symlinks/plugins/share/ios" 48 | shared_preferences: 49 | :path: ".symlinks/plugins/shared_preferences/ios" 50 | sqflite: 51 | :path: ".symlinks/plugins/sqflite/ios" 52 | uni_links: 53 | :path: ".symlinks/plugins/uni_links/ios" 54 | url_launcher: 55 | :path: ".symlinks/plugins/url_launcher/ios" 56 | webview_flutter: 57 | :path: ".symlinks/plugins/webview_flutter/ios" 58 | 59 | SPEC CHECKSUMS: 60 | Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c 61 | FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a 62 | package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 63 | path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c 64 | share: 0b2c3e82132f5888bccca3351c504d0003b3b410 65 | shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d 66 | sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 67 | uni_links: d97da20c7701486ba192624d99bffaaffcfc298a 68 | url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef 69 | webview_flutter: 3603125dfd3bcbc9d8d418c3f80aeecf331c068b 70 | 71 | PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c 72 | 73 | COCOAPODS: 1.10.1 74 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | def keystoreProperties = new Properties() 29 | def keystorePropertiesFile = rootProject.file('key.properties') 30 | if (keystorePropertiesFile.exists()) { 31 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 32 | } 33 | 34 | android { 35 | compileSdkVersion 29 36 | 37 | sourceSets { 38 | main.java.srcDirs += 'src/main/kotlin' 39 | } 40 | 41 | lintOptions { 42 | disable 'InvalidPackage' 43 | } 44 | 45 | defaultConfig { 46 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 47 | applicationId "me.hyliu.fluent_reader_lite" 48 | minSdkVersion 24 49 | targetSdkVersion 29 50 | versionCode flutterVersionCode.toInteger() 51 | versionName flutterVersionName 52 | } 53 | 54 | signingConfigs { 55 | release { 56 | keyAlias keystoreProperties['keyAlias'] 57 | keyPassword keystoreProperties['keyPassword'] 58 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 59 | storePassword keystoreProperties['storePassword'] 60 | } 61 | } 62 | 63 | buildTypes { 64 | release { 65 | signingConfig signingConfigs.release 66 | shrinkResources false 67 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 68 | } 69 | } 70 | } 71 | 72 | flutter { 73 | source '../..' 74 | } 75 | 76 | dependencies { 77 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 78 | } 79 | -------------------------------------------------------------------------------- /lib/l10n/intl_zh.arb: -------------------------------------------------------------------------------- 1 | { 2 | "all": "全部文章", 3 | "unread": "未读文章", 4 | "starred": "星标文章", 5 | "allArticles": "全部文章", 6 | "allSubscriptions": "全部订阅源", 7 | "filter": "筛选", 8 | "feed": "信息流", 9 | "subscriptions": "订阅源", 10 | "groups": "分组", 11 | "settings": "设置", 12 | "service": "服务", 13 | "preferences": "偏好", 14 | "about": "关于", 15 | "theme": "主题", 16 | "followSystem": "跟随系统", 17 | "light": "浅色模式", 18 | "dark": "深色模式", 19 | "language": "语言", 20 | "markRead": "标为已读", 21 | "markUnread": "标为未读", 22 | "markAll": "全部标为已读", 23 | "markAbove": "将以上标为已读", 24 | "markBelow": "将以下标为已读", 25 | "daysAgo": "超过 {days} 天前", 26 | "star": "标为星标", 27 | "unstar": "取消星标", 28 | "share": "分享", 29 | "cancel": "取消", 30 | "close": "关闭", 31 | "save": "保存", 32 | "reading": "阅读", 33 | "account": "账户", 34 | "app": "应用", 35 | "general": "通用", 36 | "version": "版本", 37 | "openSource": "开源项目", 38 | "feedback": "反馈", 39 | "showThumb": "显示缩略图", 40 | "showSnippet": "显示摘要", 41 | "dimRead": "淡化已读文章", 42 | "gestures": "手势", 43 | "swipeLeft": "向左滑动", 44 | "swipeRight": "向右滑动", 45 | "toggleRead": "切换已读", 46 | "toggleStar": "切换星标", 47 | "openMenu": "打开菜单", 48 | "openExternal": "在外部打开", 49 | "fontSize": "字体大小", 50 | "edit": "编辑", 51 | "name": "名称", 52 | "icon": "图标", 53 | "openTarget": "默认打开方式", 54 | "rssText": "RSS 全文", 55 | "loadWebpage": "加载网页", 56 | "loadFull": "抓取全文", 57 | "invalidValue": "请输入合法的值", 58 | "unreadOnly": "仅未读文章", 59 | "starredOnly": "仅星标文章", 60 | "search": "搜索", 61 | "editKeyword": "编辑关键词", 62 | "clearSearch": "取消搜索", 63 | "storage": "存储", 64 | "clearCache": "清理缓存", 65 | "autoDelete": "自动删除文章", 66 | "sync": "同步", 67 | "syncOnStart": "打开应用时同步", 68 | "inAppBrowser": "应用内浏览器", 69 | "lastSyncSuccess": "最后一次同步成功于", 70 | "lastSyncFailure": "最后一次同步失败于", 71 | "welcome": "欢迎", 72 | "credentials": "凭证", 73 | "endpoint": "端点", 74 | "username": "用户名", 75 | "password": "密码", 76 | "fetchLimit": "同步数量", 77 | "enter": "请输入", 78 | "entered": "已填写", 79 | "serviceFailure": "连接到服务时出错", 80 | "serviceFailureHint": "请检查服务配置或网络连接", 81 | "logOut": "登出", 82 | "logOutWarning": "这将移除所有本地数据,是否继续?", 83 | "confirm": "确定", 84 | "allLoaded": "已全部加载", 85 | "removeAd": "移除广告", 86 | "getApiKey": "获取 API ID & KEY", 87 | "getApiKeyHint": "在 “偏好设置” > “开发者” 下", 88 | "prev": "前一项", 89 | "next": "后一项", 90 | "wentWrong": "发生错误", 91 | "retry": "重试", 92 | "copy": "复制", 93 | "errorLog": "错误日志", 94 | "unreadSourceTip": "您可以长按此页面的标题来切换全部订阅源或仅未读订阅源。", 95 | "uncategorized": "未分组", 96 | "showUncategorized": "显示“未分组”", 97 | "serviceExists": "已登录至一个服务,请在导入前登出。" 98 | } -------------------------------------------------------------------------------- /lib/utils/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/generated/l10n.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:lpinyin/lpinyin.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | 8 | abstract class Utils { 9 | static const syncMaxId = 9007199254740991; 10 | 11 | static void openExternal(String url) { 12 | launch(url, forceSafariVC: false, forceWebView: false); 13 | } 14 | 15 | static int binarySearch( 16 | List sortedList, T value, int Function(T, T) compare) { 17 | var min = 0; 18 | var max = sortedList.length; 19 | while (min < max) { 20 | var mid = min + ((max - min) >> 1); 21 | var element = sortedList[mid]; 22 | var comp = compare(element, value); 23 | if (comp == 0) return mid; 24 | if (comp < 0) { 25 | min = mid + 1; 26 | } else { 27 | max = mid; 28 | } 29 | } 30 | return min; 31 | } 32 | 33 | static Future validateFavicon(String url) async { 34 | var flag = false; 35 | try { 36 | var uri = Uri.parse(url); 37 | var result = await http.get(uri); 38 | if (result.statusCode == 200) { 39 | var contentType = 40 | result.headers["Content-Type"] ?? result.headers["content-type"]; 41 | if (contentType != null && contentType.startsWith("image")) flag = true; 42 | } 43 | } finally { 44 | return flag; 45 | } 46 | } 47 | 48 | static final _urlRegex = RegExp( 49 | r"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*$)", 50 | caseSensitive: false, 51 | ); 52 | static bool testUrl(String url) => 53 | url != null && _urlRegex.hasMatch(url.trim()); 54 | 55 | static bool notEmpty(String text) => text != null && text.trim().length > 0; 56 | 57 | static void showServiceFailureDialog(BuildContext context) { 58 | showCupertinoDialog( 59 | context: context, 60 | builder: (context) => CupertinoAlertDialog( 61 | title: Text(S.of(context).serviceFailure), 62 | content: Text(S.of(context).serviceFailureHint), 63 | actions: [ 64 | CupertinoDialogAction( 65 | child: Text(S.of(context).close), 66 | onPressed: () { 67 | Navigator.of(context).pop(); 68 | }, 69 | ), 70 | ], 71 | ), 72 | ); 73 | } 74 | 75 | static int localStringCompare(String a, String b) { 76 | a = a.toLowerCase(); 77 | b = b.toLowerCase(); 78 | try { 79 | String ap = PinyinHelper.getShortPinyin(a); 80 | String bp = PinyinHelper.getShortPinyin(b); 81 | return ap.compareTo(bp); 82 | } catch (exp) { 83 | return a.compareTo(b); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/pages/setup_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/utils/colors.dart'; 5 | import 'package:fluent_reader_lite/utils/global.dart'; 6 | import 'package:flutter/cupertino.dart'; 7 | import 'package:flutter/services.dart'; 8 | import 'package:package_info/package_info.dart'; 9 | 10 | class SetupPage extends StatelessWidget { 11 | void _configure(BuildContext context, String route) { 12 | Navigator.of(context).pushNamed(route); 13 | } 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final welcomeStyle = TextStyle( 18 | color: CupertinoColors.label.resolveFrom(context), 19 | fontSize: 24, 20 | fontWeight: FontWeight.bold, 21 | height: 1.5, 22 | ); 23 | final top = Container( 24 | padding: EdgeInsets.symmetric(vertical: 100), 25 | child: Column( 26 | children: [ 27 | Image.asset("assets/icons/logo.png", width: 80, height: 80), 28 | Text(S.of(context).welcome, style: welcomeStyle), 29 | ], 30 | ), 31 | ); 32 | final services = ListTileGroup([ 33 | MyListTile( 34 | title: Text("Fever API"), 35 | onTap: () { _configure(context, "/settings/service/fever"); }, 36 | ), 37 | MyListTile( 38 | title: Text("Google Reader API"), 39 | onTap: () { _configure(context, "/settings/service/greader"); }, 40 | ), 41 | MyListTile( 42 | title: Text("Inoreader"), 43 | onTap: () { _configure(context, "/settings/service/inoreader"); }, 44 | ), 45 | MyListTile( 46 | title: Text("Feedbin"), 47 | onTap: () { _configure(context, "/settings/service/feedbin"); }, 48 | withDivider: false, 49 | ), 50 | ], title: S.of(context).service); 51 | final settings = ListTileGroup([ 52 | MyListTile( 53 | title: Text(S.of(context).general), 54 | onTap: () { _configure(context, "/settings/general"); }, 55 | ), 56 | MyListTile( 57 | title: Text(S.of(context).about), 58 | onTap: () async { 59 | var infos = await PackageInfo.fromPlatform(); 60 | Navigator.of(context).pushNamed("/settings/about", arguments: infos.version); 61 | }, 62 | withDivider: false, 63 | ), 64 | ], title: S.of(context).settings); 65 | final page = CupertinoPageScaffold( 66 | backgroundColor: MyColors.background, 67 | child: ListView(children: [ 68 | top, 69 | services, 70 | settings, 71 | ]), 72 | ); 73 | final b = Global.currentBrightness(context) == Brightness.light; 74 | return AnnotatedRegion( 75 | value: b ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light, 76 | child: page, 77 | ); 78 | } 79 | } -------------------------------------------------------------------------------- /assets/article/article.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: sans-serif; 3 | } 4 | html { 5 | overflow: hidden scroll; 6 | margin: 16px 24px 32px; 7 | } 8 | @media (min-width: 440px) { 9 | html { 10 | margin: 16px 36px 32px; 11 | } 12 | } 13 | body { 14 | margin: 0; 15 | } 16 | :root { 17 | --gray: #484644; 18 | --primary: #007aff; 19 | } 20 | @media (prefers-color-scheme: dark) { 21 | :root { 22 | color: #f8f8f8; 23 | --gray: #a19f9d; 24 | --primary:#0a84ff; 25 | } 26 | 27 | html { 28 | background-color: #000; 29 | }; 30 | } 31 | html.dark { 32 | color: #f8f8f8; 33 | --gray: #a19f9d; 34 | --primary:#0a84ff; 35 | background-color: #000; 36 | } 37 | html.light { 38 | color: #000; 39 | --gray: #484644; 40 | --primary: #007aff; 41 | background-color: #fff; 42 | } 43 | 44 | h1, h2, h3, h4, h5, h6, b, strong { 45 | font-weight: 800; 46 | } 47 | a, a:hover, a:active { 48 | color: var(--primary); 49 | text-decoration: none; 50 | } 51 | 52 | @keyframes fadeIn { 53 | 0% { 54 | opacity: 0; 55 | transform: translateY(10px); 56 | } 57 | 100% { 58 | opacity: 1; 59 | transform: translateY(0); 60 | } 61 | } 62 | #main { 63 | max-width: 700px; 64 | margin: 0 auto; 65 | display: none; 66 | } 67 | #main.show { 68 | display: block; 69 | animation-name: fadeIn; 70 | animation-duration: 0.367s; 71 | animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); 72 | animation-fill-mode: both; 73 | } 74 | 75 | #main > p#source { 76 | color: var(--gray); 77 | font-size: .875rem; 78 | margin-block-end: .5rem; 79 | } 80 | #main > p#title { 81 | font-size: 1.375rem; 82 | line-height: 1.75rem; 83 | font-weight: 800; 84 | margin: 0; 85 | } 86 | #main > p#date { 87 | color: var(--gray); 88 | font-size: .875rem; 89 | margin-block-start: .5rem; 90 | } 91 | 92 | article { 93 | line-height: 1.6; 94 | } 95 | article * { 96 | max-width: 100%; 97 | overflow: hidden; 98 | } 99 | article img { 100 | height: auto; 101 | } 102 | article figure { 103 | margin: 16px 0; 104 | text-align: center; 105 | } 106 | article figure figcaption { 107 | font-size: .875rem; 108 | color: var(--gray); 109 | -webkit-user-modify: read-only; 110 | } 111 | article iframe { 112 | width: 100%; 113 | } 114 | article code { 115 | font-family: Monaco, Consolas, monospace; 116 | font-size: .875rem; 117 | line-height: 1; 118 | } 119 | article blockquote { 120 | border-left: 2px solid var(--gray); 121 | margin: 1em 0; 122 | padding: 0 16px; 123 | } 124 | article ul, article menu, article dir { 125 | padding-inline-start: 24px; 126 | } 127 | article li { 128 | overflow: visible; 129 | } 130 | article pre { 131 | white-space: pre-wrap; 132 | word-break: break-all; 133 | } -------------------------------------------------------------------------------- /lib/pages/settings_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/utils/colors.dart'; 5 | import 'package:flutter/cupertino.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:package_info/package_info.dart'; 8 | 9 | class SettingsPage extends StatefulWidget { 10 | @override 11 | _SettingsPageState createState() => _SettingsPageState(); 12 | } 13 | 14 | class _SettingsPageState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | return CupertinoPageScaffold( 18 | backgroundColor: MyColors.background, 19 | navigationBar: CupertinoNavigationBar( 20 | middle: Text(S.of(context).settings), 21 | ), 22 | child: ListView(children: [ 23 | ListTileGroup([ 24 | MyListTile( 25 | title: Text(S.of(context).subscriptions), 26 | leading: Icon(Icons.rss_feed, color: CupertinoColors.systemOrange, size: 24), 27 | onTap: () { Navigator.of(context).pushNamed("/settings/sources"); }, 28 | ), 29 | MyListTile( 30 | title: Text(S.of(context).feed), 31 | leading: Icon(Icons.timeline, color: CupertinoColors.systemBlue, size: 24), 32 | onTap: () { Navigator.of(context).pushNamed("/settings/feed"); }, 33 | ), 34 | MyListTile( 35 | title: Text(S.of(context).reading), 36 | leading: Icon(Icons.article_outlined, color: CupertinoColors.systemBlue, size: 24), 37 | onTap: () { Navigator.of(context).pushNamed("/settings/reading"); }, 38 | withDivider: false, 39 | ), 40 | ], title: S.of(context).preferences), 41 | ListTileGroup([ 42 | MyListTile( 43 | title: Text(S.of(context).service), 44 | leading: Icon(Icons.account_circle, color: CupertinoColors.systemOrange, size: 24), 45 | onTap: () { Navigator.of(context).pushNamed("/settings/service"); }, 46 | withDivider: false, 47 | ), 48 | ], title: S.of(context).account), 49 | ListTileGroup([ 50 | MyListTile( 51 | title: Text(S.of(context).general), 52 | leading: Icon(Icons.toggle_on, color: CupertinoColors.systemGreen, size: 24), 53 | onTap: () { Navigator.of(context).pushNamed("/settings/general"); }, 54 | ), 55 | MyListTile( 56 | title: Text(S.of(context).about), 57 | leading: Icon(Icons.info, color: CupertinoColors.systemBlue, size: 24), 58 | onTap: () async { 59 | var infos = await PackageInfo.fromPlatform(); 60 | Navigator.of(context).pushNamed("/settings/about", arguments: infos.version); 61 | }, 62 | withDivider: false, 63 | ), 64 | ], title: S.of(context).app), 65 | ]), 66 | ); 67 | } 68 | } -------------------------------------------------------------------------------- /lib/models/feeds_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluent_reader_lite/models/feed.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/cupertino.dart'; 6 | 7 | import 'item.dart'; 8 | 9 | enum ItemSwipeOption { 10 | ToggleRead, ToggleStar, Share, OpenMenu, OpenExternal, 11 | } 12 | 13 | class FeedsModel with ChangeNotifier { 14 | RSSFeed all = RSSFeed(); 15 | RSSFeed source; 16 | 17 | bool _showThumb = Store.sp.getBool(StoreKeys.SHOW_THUMB) ?? true; 18 | bool get showThumb => _showThumb; 19 | set showThumb(bool value) { 20 | _showThumb = value; 21 | Store.sp.setBool(StoreKeys.SHOW_THUMB, value); 22 | notifyListeners(); 23 | } 24 | 25 | bool _showSnippet = Store.sp.getBool(StoreKeys.SHOW_SNIPPET) ?? true; 26 | bool get showSnippet => _showSnippet; 27 | set showSnippet(bool value) { 28 | _showSnippet = value; 29 | Store.sp.setBool(StoreKeys.SHOW_SNIPPET, value); 30 | notifyListeners(); 31 | } 32 | 33 | bool _dimRead = Store.sp.getBool(StoreKeys.DIM_READ) ?? false; 34 | bool get dimRead => _dimRead; 35 | set dimRead(bool value) { 36 | _dimRead = value; 37 | Store.sp.setBool(StoreKeys.DIM_READ, value); 38 | notifyListeners(); 39 | } 40 | 41 | ItemSwipeOption _swipeR = ItemSwipeOption.values[Store.sp.getInt(StoreKeys.FEED_SWIPE_R) ?? 0]; 42 | ItemSwipeOption get swipeR => _swipeR; 43 | set swipeR(ItemSwipeOption value) { 44 | _swipeR = value; 45 | Store.sp.setInt(StoreKeys.FEED_SWIPE_R, value.index); 46 | notifyListeners(); 47 | } 48 | 49 | ItemSwipeOption _swipeL = ItemSwipeOption.values[Store.sp.getInt(StoreKeys.FEED_SWIPE_L) ?? 1]; 50 | ItemSwipeOption get swipeL => _swipeL; 51 | set swipeL(ItemSwipeOption value) { 52 | _swipeL = value; 53 | Store.sp.setInt(StoreKeys.FEED_SWIPE_L, value.index); 54 | notifyListeners(); 55 | } 56 | 57 | void broadcast() { notifyListeners(); } 58 | 59 | Future initSourcesFeed(Iterable sids) async { 60 | Set sidSet = Set.from(sids); 61 | source = RSSFeed(sids: sidSet); 62 | await source.init(); 63 | } 64 | 65 | void addFetchedItems(Iterable 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> _fetchAPI({params: "", postparams: ""}) async { 65 | var uri = Uri.parse(endpoint + "?api" + params); 66 | final response = await http.post( 67 | uri, 68 | headers: {"content-type": "application/x-www-form-urlencoded"}, 69 | body: "api_key=$apiKey$postparams", 70 | ); 71 | final body = Utf8Decoder().convert(response.bodyBytes); 72 | return jsonDecode(body); 73 | } 74 | 75 | int get lastId => _lastId; 76 | set lastId(int value) { 77 | _lastId = value; 78 | Store.sp.setInt(StoreKeys.LAST_ID, value); 79 | } 80 | 81 | bool get useInt32 => _useInt32; 82 | set useInt32(bool value) { 83 | _useInt32 = value; 84 | Store.sp.setBool(StoreKeys.FEVER_INT_32, value); 85 | } 86 | 87 | @override 88 | Future validate() async { 89 | try { 90 | return (await _fetchAPI())["auth"] == 1; 91 | } catch (exp) { 92 | return false; 93 | } 94 | } 95 | 96 | @override 97 | Future, Map>>> 98 | getSources() async { 99 | var response = await _fetchAPI(params: "&feeds"); 100 | var sources = response["feeds"].map((f) { 101 | return RSSSource(f["id"].toString(), f["url"], f["title"]); 102 | }).toList(); 103 | var feedGroups = response["feeds_groups"]; 104 | var groupsMap = Map>(); 105 | var groups = (await _fetchAPI(params: "&groups"))["groups"]; 106 | if (groups == null || feedGroups == null) throw Error(); 107 | var groupsIdMap = Map(); 108 | for (var group in groups) { 109 | var title = group["title"].trim(); 110 | groupsIdMap[group["id"]] = title; 111 | } 112 | for (var group in feedGroups) { 113 | var name = groupsIdMap[group["group_id"]]; 114 | for (var fid in group["feed_ids"].split(",")) { 115 | groupsMap.putIfAbsent(name, () => []); 116 | groupsMap[name].add(fid); 117 | } 118 | } 119 | return Tuple2(sources, groupsMap); 120 | } 121 | 122 | @override 123 | Future> fetchItems() async { 124 | var minId = useInt32 ? 2147483647 : Utils.syncMaxId; 125 | List response; 126 | List items = []; 127 | do { 128 | response = (await _fetchAPI(params: "&items&max_id=$minId"))["items"]; 129 | if (response == null) throw Error(); 130 | for (var i in response) { 131 | if (i["id"] is String) i["id"] = int.parse(i["id"]); 132 | if (i["id"] > lastId) items.add(i); 133 | } 134 | if (response.length == 0 && minId == Utils.syncMaxId) { 135 | useInt32 = true; 136 | minId = 2147483647; 137 | response = null; 138 | } else { 139 | minId = response.fold(minId, (m, n) => min(m, n["id"])); 140 | } 141 | } while (minId > lastId && 142 | (response == null || response.length >= 50) && 143 | items.length < fetchLimit); 144 | var parsedItems = items.map((i) { 145 | var dom = parse(i["html"]); 146 | var item = RSSItem( 147 | id: i["id"].toString(), 148 | source: i["feed_id"].toString(), 149 | title: i["title"], 150 | link: i["url"], 151 | date: DateTime.fromMillisecondsSinceEpoch(i["created_on_time"] * 1000), 152 | content: i["html"], 153 | snippet: dom.documentElement.text.trim(), 154 | creator: i["author"], 155 | hasRead: i["is_read"] == 1, 156 | starred: i["is_saved"] == 1, 157 | ); 158 | // Try to get the thumbnail of the item 159 | var img = dom.querySelector("img"); 160 | if (img != null && img.attributes["src"] != null) { 161 | var thumb = img.attributes["src"]; 162 | if (thumb.startsWith("http")) { 163 | item.thumb = thumb; 164 | } 165 | } else if (useInt32) { 166 | // TTRSS Fever Plugin attachments 167 | var a = dom.querySelector("body>ul>li:first-child>a"); 168 | if (a != null && 169 | a.text.endsWith(", image\/generic") && 170 | a.attributes["href"] != null) item.thumb = a.attributes["href"]; 171 | } 172 | return item; 173 | }); 174 | lastId = items.fold(lastId, (m, n) => max(m, n["id"])); 175 | return parsedItems.toList(); 176 | } 177 | 178 | @override 179 | Future, Set>> syncItems() async { 180 | final responses = await Future.wait([ 181 | _fetchAPI(params: "&unread_item_ids"), 182 | _fetchAPI(params: "&saved_item_ids"), 183 | ]); 184 | final unreadIds = responses[0]["unread_item_ids"]; 185 | final starredIds = responses[1]["saved_item_ids"]; 186 | return Tuple2( 187 | Set.from(unreadIds.split(",")), Set.from(starredIds.split(","))); 188 | } 189 | 190 | Future _markItem(RSSItem item, String asType) async { 191 | try { 192 | await _fetchAPI(postparams: "&mark=item&as=$asType&id=${item.id}"); 193 | } catch (exp) { 194 | print(exp); 195 | } 196 | } 197 | 198 | @override 199 | Future markAllRead(Set sids, DateTime date, bool before) async { 200 | if (date != null && !before) { 201 | var items = Global.itemsModel.getItems().where((i) => 202 | (sids.length == 0 || sids.contains(i.source)) && 203 | i.date.compareTo(date) >= 0); 204 | await Future.wait(items.map((i) => markRead(i))); 205 | } else { 206 | var timestamp = date != null 207 | ? date.millisecondsSinceEpoch 208 | : DateTime.now().millisecondsSinceEpoch; 209 | timestamp = timestamp ~/ 1000 + 1; 210 | try { 211 | await Future.wait(Global.sourcesModel 212 | .getSources() 213 | .where((s) => sids.length == 0 || sids.contains(s.id)) 214 | .map((s) => _fetchAPI( 215 | postparams: 216 | "&mark=feed&as=read&id=${s.id}&before=$timestamp"))); 217 | } catch (exp) { 218 | print(exp); 219 | } 220 | } 221 | } 222 | 223 | @override 224 | Future markRead(RSSItem item) async { 225 | await _markItem(item, "read"); 226 | } 227 | 228 | @override 229 | Future markUnread(RSSItem item) async { 230 | await _markItem(item, "unread"); 231 | } 232 | 233 | @override 234 | Future star(RSSItem item) async { 235 | await _markItem(item, "saved"); 236 | } 237 | 238 | @override 239 | Future unstar(RSSItem item) async { 240 | await _markItem(item, "unsaved"); 241 | } 242 | } 243 | --------------------------------------------------------------------------------