├── .gitignore ├── .metadata ├── CHANGELOG.md ├── Justfile ├── LICENSE ├── README.md ├── README_zh.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── io │ │ │ │ └── flutter │ │ │ │ └── app │ │ │ │ └── FlutterMultiDexApplication.java │ │ ├── kotlin │ │ │ └── xin │ │ │ │ └── liuyu │ │ │ │ └── meread │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── play_store_512.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── app_en_1.png ├── app_en_2.png ├── app_en_3.png ├── app_en_4.png ├── app_zh_1.png ├── app_zh_2.png ├── app_zh_3.png ├── app_zh_4.png ├── avatar.png ├── meread.png └── meread_round.png ├── lib ├── common │ └── init_app.dart ├── helpers │ ├── constant_helper.dart │ ├── dio_helper.dart │ ├── font_helper.dart │ ├── isar_helper.dart │ ├── log_helper.dart │ ├── opml_helper.dart │ ├── prefs_helper.dart │ ├── resolve_helper.dart │ ├── routes_helper.dart │ ├── theme_helper.dart │ └── update_helper.dart ├── main.dart ├── models │ ├── category.dart │ ├── category.g.dart │ ├── feed.dart │ ├── feed.g.dart │ ├── post.dart │ └── post.g.dart ├── translation │ └── translation.dart └── ui │ ├── viewmodels │ ├── add_feed │ │ └── add_feed_controller.dart │ ├── edit_feed │ │ └── edit_feed_controller.dart │ ├── home_controller.dart │ ├── post │ │ └── post_controller.dart │ └── settings │ │ ├── display │ │ └── display_setting_controller.dart │ │ ├── read │ │ └── read_controller.dart │ │ └── resolve │ │ └── resolve_setting_controller.dart │ ├── views │ ├── add_feed │ │ └── add_feed_view.dart │ ├── edit_feed │ │ └── edit_feed_view.dart │ ├── home_view.dart │ ├── post │ │ └── post_view.dart │ └── setting │ │ ├── about │ │ └── about_view.dart │ │ ├── data_manage │ │ └── data_manage_view.dart │ │ ├── display │ │ └── display_setting_view.dart │ │ ├── read │ │ └── read_setting_view.dart │ │ ├── resolve │ │ └── resolve_setting_view.dart │ │ └── setting_view.dart │ └── widgets │ ├── feed_panel.dart │ └── post_card.dart ├── pubspec.lock ├── pubspec.yaml └── test └── widget_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | 46 | # Linux 47 | /AppDir/ 48 | /appimage-build/ 49 | *.AppImage* 50 | /dist/ -------------------------------------------------------------------------------- /.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: "2524052335ec76bb03e04ede244b071f1b86d190" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 2524052335ec76bb03e04ede244b071f1b86d190 17 | base_revision: 2524052335ec76bb03e04ede244b071f1b86d190 18 | - platform: linux 19 | create_revision: 2524052335ec76bb03e04ede244b071f1b86d190 20 | base_revision: 2524052335ec76bb03e04ede244b071f1b86d190 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## 1.0.0 (2024-) 4 | 5 | - 🚀 功能:重构阅读页面渲染方式 6 | - 🚀 功能:全文搜索 7 | - 🚀 功能:代理设置 #25 8 | - 🚀 功能:启动时刷新 9 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | # VERSION := `sed -n 's/^version: \([^ ]*\).*/\1/p' pubspec.yaml` 2 | 3 | # Run Flutter project 4 | dev: 5 | @echo "------------------------------" 6 | @echo "Running Flutter project......" 7 | @flutter run 8 | 9 | # Flutter clean 10 | clean: 11 | @echo "------------------------------" 12 | @echo "Cleaning Flutter project......" 13 | @flutter clean 14 | @flutter pub get 15 | 16 | # Update Flutter project 17 | update: 18 | @echo "------------------------------" 19 | @echo "Update Flutter project......" 20 | @flutter pub get 21 | @flutter pub outdated 22 | @flutter pub upgrade --major-versions 23 | 24 | # Check Flutter project 25 | check: 26 | @echo "------------------------------" 27 | @echo "Checking Flutter project......" 28 | @dart format ./lib 29 | @flutter analyze 30 | 31 | # Add a new package 32 | add package: 33 | @echo "------------------------------" 34 | @echo "Add a new package: {{ package }}......" 35 | @flutter pub add {{ package }} 36 | 37 | # Build generated files 38 | isar: 39 | @echo "------------------------------" 40 | @echo "Building Isar......." 41 | @flutter pub run build_runner build 42 | 43 | # Build Android apk 44 | apk: 45 | @echo "------------------------------" 46 | @echo "Building Android apk......" 47 | @flutter build apk \ 48 | --dart-define AmapAndroidApiKey=$AmapAndroidApiKey 49 | 50 | # Build Android apks by splitting per abi 51 | apks: 52 | @echo "------------------------------" 53 | @echo "Building Android apks......" 54 | @flutter build apk --split-per-abi \ 55 | --dart-define AmapAndroidApiKey=$AmapAndroidApiKey 56 | 57 | # Share apks with dufs 58 | share: apks 59 | @echo "------------------------------" 60 | @echo "Share apks with dufs......" 61 | @cd ./build/app/outputs/flutter-apk && dufs -A 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

English | 简体中文

3 | MeRead 4 |

MeRead [Project Refactoring]

5 | 6 |

7 | License 8 | Release 9 | GitHub all releases 10 |

11 | 12 |

13 | A concise and easy-to-use RSS reader built with Flutter and designed with Material You 14 |

15 | 16 |

17 | MeRead 18 | MeRead 19 | MeRead 20 | MeRead 21 |

22 |
23 | 24 | ## Explain 25 | 26 | - [x] Migrate feeds by importing and exporting OPML. 27 | - [x] Automatically obtain full text. 28 | - [x] Three reading modes: reader, in app tab, and system browser. 29 | - [x] Supports unread filtering, article favorites, and feed grouping. 30 | - [x] Block articles through keywords. 31 | - [x] Customize global fonts and global scaling. 32 | - [x] Custom reading page font size, line spacing, page margins, text alignment, CSS. 33 | - [x] Adapt to dark mode. 34 | - [x] Adapt to Material You and support dynamic color. 35 | - [x] Multi language support. 36 | 37 | ## Thanks 38 | 39 | MeRead references the open source project [spacecowboy/feeder](https://gitlab.com/spacecowboy/Feeder) in terms of functionality and design. 40 | 41 | ## License 42 | 43 | [GNU GPL-3.0](./LICENSE) 44 | 45 | ## Star History 46 | 47 | 48 | 49 | 50 | 51 | Star History Chart 52 | 53 | 54 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 |
2 |

English | 简体中文

3 | MeRead 4 |

MeRead 悦读 [项目重构中!]

5 | 6 |

7 | License 8 | Release 9 | GitHub all releases 10 |

11 | 12 |

13 | 简洁、易用的 RSS 阅读器,使用 Flutter 构建和 Material You 设计 14 |

15 | 16 |

17 | MeRead 18 | MeRead 19 | MeRead 20 | MeRead 21 |

22 |
23 | 24 | ## 说明 25 | 26 | - [x] 通过导入和导出 OPML 迁移订阅源 27 | - [x] 自动获取全文 28 | - [x] 三种阅读模式:阅读器、应用内标签页、系统浏览器 29 | - [x] 支持未读筛选、文章收藏、订阅源分组 30 | - [x] 通过关键词屏蔽文章 31 | - [x] 自定义全局字体和全局缩放 32 | - [x] 自定义阅读页面字体大小、行间距、页面边距、文字对齐、CSS 33 | - [x] 适配深色模式 34 | - [x] 适配 Material You,支持壁纸动态取色 35 | - [x] 多语言支持 36 | 37 | ## 致谢 38 | 39 | MeRead 在功能和设计上参考了开源项目 [spacecowboy/Feeder](https://gitlab.com/spacecowboy/Feeder) ,在此表示感谢。 40 | 41 | ## License 42 | 43 | [GNU GPL-3.0](./LICENSE) 44 | 45 | ## Star History 46 | 47 | 48 | 49 | 50 | 51 | Star History Chart 52 | 53 | 54 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /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 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def localProperties = new Properties() 8 | def localPropertiesFile = rootProject.file('local.properties') 9 | if (localPropertiesFile.exists()) { 10 | localPropertiesFile.withReader('UTF-8') { reader -> 11 | localProperties.load(reader) 12 | } 13 | } 14 | 15 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 16 | if (flutterVersionCode == null) { 17 | flutterVersionCode = '1' 18 | } 19 | 20 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 21 | if (flutterVersionName == null) { 22 | flutterVersionName = '1.0' 23 | } 24 | 25 | def keystoreProperties = new Properties() 26 | def keystorePropertiesFile = rootProject.file('key.properties') 27 | if (keystorePropertiesFile.exists()) { 28 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 29 | } 30 | 31 | android { 32 | namespace "xin.liuyu.meread" 33 | compileSdkVersion flutter.compileSdkVersion 34 | ndkVersion flutter.ndkVersion 35 | 36 | compileOptions { 37 | sourceCompatibility JavaVersion.VERSION_1_8 38 | targetCompatibility JavaVersion.VERSION_1_8 39 | } 40 | 41 | kotlinOptions { 42 | jvmTarget = '1.8' 43 | } 44 | 45 | sourceSets { 46 | main.java.srcDirs += 'src/main/kotlin' 47 | } 48 | 49 | defaultConfig { 50 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 51 | applicationId "xin.liuyu.meread" 52 | // You can update the following values to match your application needs. 53 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. 54 | minSdkVersion 21 55 | targetSdkVersion flutter.targetSdkVersion 56 | versionCode flutterVersionCode.toInteger() 57 | versionName flutterVersionName 58 | multiDexEnabled true 59 | } 60 | 61 | signingConfigs { 62 | release { 63 | keyAlias keystoreProperties['keyAlias'] 64 | keyPassword keystoreProperties['keyPassword'] 65 | storeFile keystoreProperties['storeFile'] ? file (keystoreProperties['storeFile']) : null 66 | storePassword keystoreProperties['storePassword'] 67 | } 68 | } 69 | buildTypes { 70 | debug { 71 | signingConfig signingConfigs.debug 72 | applicationIdSuffix '.debug' 73 | } 74 | release { 75 | signingConfig signingConfigs.release 76 | } 77 | } 78 | } 79 | 80 | flutter { 81 | source '../..' 82 | } 83 | 84 | dependencies { 85 | implementation 'com.android.support:multidex:1.0.3' 86 | } 87 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java: -------------------------------------------------------------------------------- 1 | // Generated file. 2 | // 3 | // If you wish to remove Flutter's multidex support, delete this entire file. 4 | // 5 | // Modifications to this file should be done in a copy under a different name 6 | // as this file may be regenerated. 7 | 8 | package io.flutter.app; 9 | 10 | import android.app.Application; 11 | import android.content.Context; 12 | import androidx.annotation.CallSuper; 13 | import androidx.multidex.MultiDex; 14 | 15 | /** 16 | * Extension of {@link android.app.Application}, adding multidex support. 17 | */ 18 | public class FlutterMultiDexApplication extends Application { 19 | @Override 20 | @CallSuper 21 | protected void attachBaseContext(Context base) { 22 | super.attachBaseContext(base); 23 | MultiDex.install(this); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/xin/liuyu/meread/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package xin.liuyu.meread 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/android/app/src/main/res/play_store_512.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | maven { url 'https://maven.aliyun.com/repository/google' } 6 | maven { url 'https://maven.aliyun.com/repository/central' } 7 | maven { url 'https://maven.aliyun.com/repository/public/' } 8 | maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } 9 | } 10 | } 11 | 12 | rootProject.buildDir = '../build' 13 | subprojects { 14 | project.buildDir = "${rootProject.buildDir}/${project.name}" 15 | } 16 | subprojects { 17 | project.evaluationDependsOn(':app') 18 | } 19 | 20 | tasks.register("clean", Delete) { 21 | delete rootProject.buildDir 22 | } 23 | 24 | ext.kotlin_version = '1.9.23' -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | # distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip 4 | distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.1.1-bin.zip 5 | networkTimeout=10000 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | } 9 | settings.ext.flutterSdkPath = flutterSdkPath() 10 | 11 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | maven { url 'https://maven.aliyun.com/repository/google' } 18 | maven { url 'https://maven.aliyun.com/repository/central' } 19 | maven { url 'https://maven.aliyun.com/repository/public/' } 20 | maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } 21 | } 22 | } 23 | 24 | plugins { 25 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 26 | id "com.android.application" version "7.3.0" apply false 27 | id "org.jetbrains.kotlin.android" version "1.9.23" apply false 28 | } 29 | 30 | include ":app" 31 | -------------------------------------------------------------------------------- /assets/app_en_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_en_1.png -------------------------------------------------------------------------------- /assets/app_en_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_en_2.png -------------------------------------------------------------------------------- /assets/app_en_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_en_3.png -------------------------------------------------------------------------------- /assets/app_en_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_en_4.png -------------------------------------------------------------------------------- /assets/app_zh_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_zh_1.png -------------------------------------------------------------------------------- /assets/app_zh_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_zh_2.png -------------------------------------------------------------------------------- /assets/app_zh_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_zh_3.png -------------------------------------------------------------------------------- /assets/app_zh_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/app_zh_4.png -------------------------------------------------------------------------------- /assets/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/avatar.png -------------------------------------------------------------------------------- /assets/meread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/meread.png -------------------------------------------------------------------------------- /assets/meread_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gvenusleo/MeRead/fe8a59eaa581777ec70054c4cc62a56afb4f8609/assets/meread_round.png -------------------------------------------------------------------------------- /lib/common/init_app.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:meread/helpers/dio_helper.dart'; 6 | import 'package:meread/helpers/font_helper.dart'; 7 | import 'package:meread/helpers/isar_helper.dart'; 8 | import 'package:meread/helpers/log_helper.dart'; 9 | import 'package:meread/helpers/prefs_helper.dart'; 10 | 11 | /// Init App 12 | Future initApp() async { 13 | WidgetsFlutterBinding.ensureInitialized(); 14 | 15 | if (Platform.isAndroid) { 16 | SystemUiOverlayStyle systemUiOverlayStyle = const SystemUiOverlayStyle( 17 | statusBarColor: Colors.transparent, 18 | systemNavigationBarColor: Colors.transparent, 19 | systemNavigationBarDividerColor: Colors.transparent, 20 | ); 21 | SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); 22 | } 23 | SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); 24 | 25 | LogHelper.init(); 26 | await PrefsHelper.init(); 27 | await IsarHelper.init(); 28 | DioHelper.init(); 29 | 30 | FontHelper.readThemeFont(); 31 | } 32 | -------------------------------------------------------------------------------- /lib/helpers/constant_helper.dart: -------------------------------------------------------------------------------- 1 | class ConstantHelp { 2 | static String get appVersion => 'v1.0.0(20240531)'; 3 | static String get githubUrl => 'https://github.com/gvenusleo/MeRead'; 4 | static String get authorSite => 'https://jike.city/gvenusleo'; 5 | } 6 | -------------------------------------------------------------------------------- /lib/helpers/dio_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:dio/io.dart'; 5 | import 'package:meread/helpers/log_helper.dart'; 6 | import 'package:meread/helpers/prefs_helper.dart'; 7 | 8 | class DioHelper { 9 | static late Dio _dio; 10 | 11 | /// Init Dio 12 | static void init() { 13 | _dio = Dio(); 14 | String proxyAddress = PrefsHelper.proxyAddress; 15 | String proxyPort = PrefsHelper.proxyPort; 16 | bool isProxy = PrefsHelper.useProxy; 17 | if (isProxy && proxyAddress.isNotEmpty && proxyPort.isNotEmpty) { 18 | _dio.httpClientAdapter = IOHttpClientAdapter( 19 | createHttpClient: () { 20 | final client = HttpClient(); 21 | client.findProxy = (uri) { 22 | return 'PROXY $proxyAddress:$proxyPort'; 23 | }; 24 | return client; 25 | }, 26 | ); 27 | LogHelper.i('[dio]: Init Dio with proxy: $proxyAddress:$proxyPort.'); 28 | } else { 29 | LogHelper.i('[dio]: Init Dio without peoxy.'); 30 | } 31 | } 32 | 33 | /// GET 34 | static Future get(String url) async { 35 | return _dio.get(url); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/helpers/font_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:file_picker/file_picker.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:meread/helpers/log_helper.dart'; 6 | import 'package:meread/helpers/prefs_helper.dart'; 7 | import 'package:path_provider/path_provider.dart'; 8 | 9 | class FontHelper { 10 | /// Read all font files 11 | static Future> readAllFont() async { 12 | List fontNameList = []; 13 | final fontDir = await _getFontDir(); 14 | for (var fontFile in fontDir.listSync()) { 15 | final fontName = fontFile.path.split(_getDirSeparator()).last; 16 | _readFont(fontFile.path, fontName); 17 | fontNameList.add(fontName); 18 | } 19 | LogHelper.i('[font]: Read all font files: $fontNameList.'); 20 | return fontNameList; 21 | } 22 | 23 | /// Read theme font 24 | static Future readThemeFont() async { 25 | final String themeFontName = PrefsHelper.themeFont; 26 | if (themeFontName != 'system') { 27 | final fontFileDir = await _getFontDir(); 28 | _readFont('${fontFileDir.path}${_getDirSeparator()}$themeFontName', 29 | themeFontName); 30 | LogHelper.i('[font]: Read theme font: $themeFontName.'); 31 | } 32 | } 33 | 34 | /// Delete a font 35 | static Future deleteFont(String fontName) async { 36 | if (fontName == 'system') { 37 | return; 38 | } 39 | final fontFileDir = await _getFontDir(); 40 | final fontFile = File('${fontFileDir.path}${_getDirSeparator()}$fontName'); 41 | if (fontFile.existsSync()) { 42 | fontFile.deleteSync(); 43 | LogHelper.i('[font]: Delete a font: $fontName.'); 44 | } 45 | } 46 | 47 | /// Import font file 48 | static Future loadLocalFont() async { 49 | try { 50 | final fontFilePicker = await FilePicker.platform.pickFiles( 51 | type: FileType.custom, 52 | allowedExtensions: ['ttf', 'otf', '.ttc', '.TTF' '.OTF', '.TTC'], 53 | allowMultiple: true, 54 | ); 55 | if (fontFilePicker != null) { 56 | List fontFileList = 57 | fontFilePicker.paths.map((path) => File(path!)).toList(); 58 | final fontFileDir = await _getFontDir(); 59 | for (var fontFile in fontFileList) { 60 | final fontFileName = fontFile.path.split(_getDirSeparator()).last; 61 | final newFontPath = 62 | '${fontFileDir.path}${_getDirSeparator()}$fontFileName'; 63 | await fontFile.copy(newFontPath); 64 | LogHelper.i('[font]: Import a font: $fontFileName.'); 65 | // await fontFile.delete(); 66 | } 67 | } 68 | return true; 69 | } catch (e) { 70 | return false; 71 | } 72 | } 73 | 74 | /// Read a font file with file path and font name 75 | static Future _readFont(String fontFilePath, String fontName) async { 76 | final fontFile = File(fontFilePath); 77 | final fontFileBytes = await fontFile.readAsBytes(); 78 | final fontLoad = FontLoader(fontName); 79 | fontLoad.addFont(Future.value(ByteData.view(fontFileBytes.buffer))); 80 | fontLoad.load(); 81 | } 82 | 83 | /// Get directory seperator 84 | static String _getDirSeparator() { 85 | if (Platform.isWindows) { 86 | return '\\'; 87 | } else { 88 | return '/'; 89 | } 90 | } 91 | 92 | /// Get font diractory 93 | static Future _getFontDir() async { 94 | final Directory appWorkDir = Platform.isAndroid 95 | ? await getApplicationDocumentsDirectory() 96 | : await getApplicationSupportDirectory(); 97 | final String fontDirPath = '${appWorkDir.path}${_getDirSeparator()}fonts'; 98 | final Directory fontDir = Directory(fontDirPath); 99 | if (!(await fontDir.exists())) { 100 | await fontDir.create(recursive: true); 101 | } 102 | return fontDir; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/helpers/isar_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:isar/isar.dart'; 4 | import 'package:meread/helpers/log_helper.dart'; 5 | import 'package:meread/models/category.dart'; 6 | import 'package:meread/models/feed.dart'; 7 | import 'package:meread/models/post.dart'; 8 | import 'package:path_provider/path_provider.dart'; 9 | 10 | class IsarHelper { 11 | static late Isar _isar; 12 | 13 | /// Init Isar 14 | static Future init() async { 15 | final Directory dir = Platform.isAndroid 16 | ? await getApplicationDocumentsDirectory() 17 | : await getApplicationSupportDirectory(); 18 | _isar = await Isar.open( 19 | [FeedSchema, PostSchema, CategorySchema], 20 | directory: dir.path, 21 | ); 22 | LogHelper.i('[Isar]: Open isar database: ${dir.path}.'); 23 | } 24 | 25 | /// Get all Feeds from the Isar database 26 | static Future> getFeeds() async { 27 | final List feeds = await _isar.feeds.where().findAll(); 28 | return feeds; 29 | } 30 | 31 | /// Save a Feed to the Isar database 32 | static void saveFeed(Feed feed) { 33 | _isar.writeTxnSync(() { 34 | _isar.feeds.putSync(feed); 35 | }); 36 | } 37 | 38 | /// Determine if the Feed exists in the Isar database based on the URL 39 | static Future isExistsFeed(String url) async { 40 | final Feed? result = 41 | await _isar.feeds.where().filter().urlEqualTo(url).findFirst(); 42 | return result; 43 | } 44 | 45 | /// Get the latest pubDate of the Post to which the feed belongs 46 | static Future getLatesPubDate(Feed feed) async { 47 | final List posts = await _isar.posts 48 | .where() 49 | .filter() 50 | .feed((f) => f.idEqualTo(feed.id)) 51 | .sortByPubDateDesc() 52 | .findAll(); 53 | if (posts.isNotEmpty) { 54 | return posts.first.pubDate; 55 | } 56 | return null; 57 | } 58 | 59 | /// Delete a Feed from the Isar database 60 | static Future deleteFeed(Feed feed) async { 61 | final List posts = await _isar.posts 62 | .where() 63 | .filter() 64 | .feed((f) => f.idEqualTo(feed.id)) 65 | .findAll(); 66 | 67 | _isar.writeTxnSync(() { 68 | _isar.posts.deleteAllSync(posts.map((e) => e.id!).toList()); 69 | _isar.feeds.deleteSync(feed.id!); 70 | }); 71 | } 72 | 73 | /// Get all Posts from the Isar database 74 | static Future> getPosts() async { 75 | final List posts = await _isar.posts.where().findAll(); 76 | return posts; 77 | } 78 | 79 | /// Save a Post to the Isar database 80 | static void savePost(Post post) { 81 | _isar.writeTxnSync(() { 82 | _isar.posts.putSync(post); 83 | }); 84 | } 85 | 86 | /// Get the associated Post from the Isar database through List 87 | static Future> getPostsByFeeds(List feeds) async { 88 | final List result = []; 89 | for (final Feed feed in feeds) { 90 | final List posts = await _isar.posts 91 | .where() 92 | .filter() 93 | .feed((f) => f.idEqualTo(feed.id)) 94 | .findAll(); 95 | result.addAll(posts); 96 | } 97 | return result; 98 | } 99 | 100 | /// Search from title and content 101 | static Future> search(String value) async { 102 | if (value.isEmpty) { 103 | return []; 104 | } 105 | final List result = await _isar.posts 106 | .where() 107 | .filter() 108 | .titleContains(value) 109 | .or() 110 | .contentContains(value) 111 | .findAll(); 112 | return result; 113 | } 114 | 115 | /// Modify Post reading status 116 | static void updatePostRead(Post post) { 117 | _isar.writeTxnSync(() { 118 | post.read = !post.read; 119 | _isar.posts.putSync(post); 120 | }); 121 | } 122 | 123 | /// Mark all Posts as Read 124 | static void markAllRead(List posts) { 125 | for (var post in posts) { 126 | post.read = true; 127 | } 128 | _isar.writeTxnSync(() { 129 | _isar.posts.putAllSync(posts); 130 | }); 131 | } 132 | 133 | /// Get all Category from the Isar database 134 | static Future> getCategorys() async { 135 | final List categories = await _isar.categorys.where().findAll(); 136 | return categories; 137 | } 138 | 139 | /// Search Category by name from the Isar database 140 | static Future getCategoryByName(String name) async { 141 | final Category? result = 142 | await _isar.categorys.where().filter().nameEqualTo(name).findFirst(); 143 | return result; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /lib/helpers/log_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:logger/logger.dart'; 2 | 3 | class LogHelper { 4 | static late Logger _logger; 5 | 6 | static void init() { 7 | _logger = Logger( 8 | printer: PrettyPrinter( 9 | colors: true, 10 | printEmojis: false, 11 | dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, 12 | ), 13 | ); 14 | } 15 | 16 | /// Info 17 | static void i(dynamic message) { 18 | _logger.i(message); 19 | } 20 | 21 | /// Debug 22 | static void d(dynamic message) { 23 | _logger.d(message); 24 | } 25 | 26 | /// Warning 27 | static void w(dynamic message) { 28 | _logger.w(message); 29 | } 30 | 31 | /// Error 32 | static void e(dynamic message) { 33 | _logger.e(message); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/helpers/opml_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:file_picker/file_picker.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:get/get.dart'; 6 | import 'package:meread/helpers/isar_helper.dart'; 7 | import 'package:meread/helpers/log_helper.dart'; 8 | import 'package:meread/helpers/resolve_helper.dart'; 9 | import 'package:meread/models/category.dart'; 10 | import 'package:meread/models/feed.dart'; 11 | import 'package:opml/opml.dart'; 12 | import 'package:path_provider/path_provider.dart'; 13 | import 'package:share_plus/share_plus.dart'; 14 | 15 | class OpmlHelper { 16 | /// Import an OPML file 17 | static Future importOpml() async { 18 | LogHelper.i('[opml]: Start import the opml file.'); 19 | final result = await FilePicker.platform.pickFiles( 20 | type: FileType.any, 21 | // file_picker cannot filter opml format files correctly 22 | // allowedExtensions: ['opml', 'xml'], 23 | ); 24 | if (result != null) { 25 | if (result.files.first.extension != 'opml' && 26 | result.files.first.extension != 'xml') { 27 | Get.snackbar( 28 | 'error'.tr, 29 | 'importErrorInfo'.tr, 30 | snackPosition: SnackPosition.BOTTOM, 31 | margin: const EdgeInsets.all(12), 32 | ); 33 | LogHelper.i( 34 | '[opml]: Import error, only OPML or XML files are supported. ' 35 | 'The current file extension is: ${result.files.first.extension}.'); 36 | return; 37 | } else { 38 | Get.dialog( 39 | AlertDialog( 40 | icon: const Icon(Icons.file_download_outlined), 41 | title: Text('startImport'.tr), 42 | content: const SizedBox( 43 | width: 40, 44 | height: 40, 45 | child: Center( 46 | child: CircularProgressIndicator(), 47 | ), 48 | ), 49 | ), 50 | barrierDismissible: false, 51 | ); 52 | final List count = await _parseOpml(result); 53 | if (Get.isDialogOpen ?? false) { 54 | Get.back(); 55 | } 56 | Get.snackbar( 57 | 'info'.tr, 58 | 'importResultInfo'.trParams({ 59 | 'allCount': count[0].toString(), 60 | 'successCount': count[1].toString(), 61 | }), 62 | snackPosition: SnackPosition.BOTTOM, 63 | margin: const EdgeInsets.all(12), 64 | ); 65 | if (Get.isDialogOpen ?? false) { 66 | Get.back(); 67 | } 68 | LogHelper.i( 69 | '[opml]: The import is completed. A total of ${count[0]} feeds were found and ${count[1]} were imported successfully.'); 70 | } 71 | } 72 | } 73 | 74 | /// Export all Feeds as a Opml file 75 | static Future exportOpml() async { 76 | final Map> feedMap = {}; 77 | final head = OpmlHeadBuilder().title('Feeds From MeRead').build(); 78 | final body = []; 79 | for (var category in feedMap.keys) { 80 | var c = OpmlOutlineBuilder().title(category).text(category); 81 | for (var feed in feedMap[category]!) { 82 | c.addChild(OpmlOutlineBuilder() 83 | .title(feed.title) 84 | .text(feed.title) 85 | .type('rss') 86 | .xmlUrl(feed.url) 87 | .build()); 88 | } 89 | body.add(c.build()); 90 | } 91 | final opml = OpmlDocument( 92 | head: head, 93 | body: body, 94 | ); 95 | final String opmlString = opml.toXmlString(pretty: true); 96 | final Directory tempDir = await getTemporaryDirectory(); 97 | final File file = File('${tempDir.path}/feeds-from-MeRead.xml'); 98 | await file.writeAsString(opmlString); 99 | await Share.shareXFiles( 100 | [XFile(file.path)], 101 | text: 'exportOPML'.tr, 102 | ).then((value) { 103 | if (value.status == ShareResultStatus.success) { 104 | Get.snackbar( 105 | 'info'.tr, 106 | 'exportSuccess'.tr, 107 | snackPosition: SnackPosition.BOTTOM, 108 | margin: const EdgeInsets.all(12), 109 | ); 110 | } 111 | }); 112 | await file.delete(); 113 | LogHelper.i('[opml]: Export success.'); 114 | } 115 | 116 | /// Parse Opml file 117 | static Future> _parseOpml(FilePickerResult result) async { 118 | final file = result.files.first; 119 | final File opmlFile = File(file.path!); 120 | final String opmlString = await opmlFile.readAsString(); 121 | int allCount = 0; 122 | int successCount = 0; 123 | final opml = OpmlDocument.parse(opmlString); 124 | await Future.wait( 125 | opml.body.map( 126 | (categoryOpml) async { 127 | final String? categoryName = categoryOpml.title ?? categoryOpml.text; 128 | final Category? category = 129 | await IsarHelper.getCategoryByName(categoryName!); 130 | await Future.wait( 131 | categoryOpml.children!.map( 132 | (opmlOutline) async { 133 | allCount++; 134 | if (await IsarHelper.isExistsFeed(opmlOutline.xmlUrl!) == 135 | null) { 136 | Feed? feed = await ResolveHelper.parseFeed( 137 | opmlOutline.xmlUrl!, 138 | category, 139 | opmlOutline.title ?? opmlOutline.text, 140 | ); 141 | if (feed != null) { 142 | IsarHelper.saveFeed(feed); 143 | successCount++; 144 | } 145 | } 146 | }, 147 | ), 148 | ); 149 | }, 150 | ), 151 | ); 152 | return [allCount, successCount]; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/helpers/prefs_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:shared_preferences/shared_preferences.dart'; 2 | 3 | class PrefsHelper { 4 | static late SharedPreferences _prefs; 5 | 6 | static Future init() async { 7 | _prefs = await SharedPreferences.getInstance(); 8 | } 9 | 10 | /// App Language 11 | static String get language => _prefs.getString('language') ?? 'system'; 12 | static set language(String value) => _prefs.setString('language', value); 13 | 14 | // Theme mode 15 | static int get themeMode => _prefs.getInt('themeMode') ?? 0; 16 | static set themeMode(int value) => _prefs.setInt('themeMode', value); 17 | 18 | // Theme font 19 | static String get themeFont => _prefs.getString('themeFont') ?? 'system'; 20 | static set themeFont(String value) => _prefs.setString('themeFont', value); 21 | 22 | // Use dynamic color 23 | static bool get useDynamicColor => _prefs.getBool('useDynamicColor') ?? true; 24 | static set useDynamicColor(bool value) => 25 | _prefs.setBool('useDynamicColor', value); 26 | 27 | // Transition: {'cupertino': Transition.cupertino, 'fade': Transition.fade} 28 | static String get transition => _prefs.getString('transition') ?? 'cupertino'; 29 | static set transition(String value) => _prefs.setString('transition', value); 30 | 31 | // Text scale factor 32 | static double get textScaleFactor => 33 | _prefs.getDouble('textScaleFactor') ?? 1.0; 34 | static set textScaleFactor(double value) => 35 | _prefs.setDouble('textScaleFactor', value); 36 | 37 | // Read view font size 38 | static int get readFontSize => _prefs.getInt('readFontSize') ?? 18; 39 | static set readFontSize(int value) => _prefs.setInt('readFontSize', value); 40 | 41 | // Read view line height 42 | static double get readLineHeight => _prefs.getDouble('readLineHeight') ?? 1.5; 43 | static set readLineHeight(double value) => 44 | _prefs.setDouble('readLineHeight', value); 45 | 46 | // Read view page padding 47 | static int get readPagePadding => _prefs.getInt('readPagePadding') ?? 18; 48 | static set readPagePadding(int value) => 49 | _prefs.setInt('readPagePadding', value); 50 | 51 | // Read view text align 52 | static String get readTextAlign => 53 | _prefs.getString('readTextAlign') ?? 'justify'; 54 | static set readTextAlign(String value) => 55 | _prefs.setString('readTextAlign', value); 56 | 57 | /// Refresh feeds on startup 58 | static bool get refreshOnStartup => 59 | _prefs.getBool('refreshOnStartup') ?? true; 60 | static set refreshOnStartup(bool value) => 61 | _prefs.setBool('refreshOnStartup', value); 62 | 63 | /// Bolck list when refresh feeds 64 | static List get blockList => _prefs.getStringList('blockList') ?? []; 65 | static set blockList(List value) => 66 | _prefs.setStringList('blockList', value); 67 | 68 | /// Use proxy 69 | static bool get useProxy => _prefs.getBool('useProxy') ?? false; 70 | static set useProxy(bool value) => _prefs.setBool('useProxy', value); 71 | 72 | // Proxy address 73 | static String get proxyAddress => _prefs.getString('proxyAddress') ?? ''; 74 | static set proxyAddress(String value) => 75 | _prefs.setString('proxyAddress', value); 76 | 77 | // Proxy port 78 | static String get proxyPort => _prefs.getString('proxyPort') ?? ''; 79 | static set proxyPort(String value) => _prefs.setString('proxyPort', value); 80 | } 81 | -------------------------------------------------------------------------------- /lib/helpers/resolve_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_rss/dart_rss.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:intl/intl.dart'; 4 | import 'package:meread/helpers/dio_helper.dart'; 5 | import 'package:meread/helpers/isar_helper.dart'; 6 | import 'package:meread/helpers/log_helper.dart'; 7 | import 'package:meread/helpers/prefs_helper.dart'; 8 | import 'package:meread/models/category.dart'; 9 | import 'package:meread/models/feed.dart'; 10 | import 'package:meread/models/post.dart'; 11 | 12 | class ResolveHelper { 13 | /// Parse a Feed with a url. 14 | static Future parseFeed( 15 | String url, [ 16 | Category? category, 17 | String? feedTitle, 18 | ]) async { 19 | category ??= Category( 20 | name: 'defaultCategory'.tr, 21 | createdAt: DateTime.now(), 22 | updatedAt: DateTime.now(), 23 | ); 24 | try { 25 | final response = await DioHelper.get(url); 26 | final postXmlString = response.data; 27 | try { 28 | /* Parse on RSS */ 29 | final RssFeed rssFeed = RssFeed.parse(postXmlString); 30 | feedTitle = rssFeed.title; 31 | final Feed feed = Feed( 32 | title: feedTitle ?? '', 33 | url: url, 34 | description: rssFeed.description ?? '', 35 | fullText: false, 36 | openType: 0, 37 | ); 38 | feed.category.value = category; 39 | return feed; 40 | } catch (e) { 41 | /* Parse on Atom */ 42 | final AtomFeed atomFeed = AtomFeed.parse(postXmlString); 43 | final Feed feed = Feed( 44 | title: atomFeed.title ?? '', 45 | url: url, 46 | description: atomFeed.subtitle ?? '', 47 | fullText: false, 48 | openType: 0, 49 | ); 50 | feed.category.value = category; 51 | return feed; 52 | } 53 | } catch (e) { 54 | LogHelper.e('[feed] Parse Feed Error: $e'); 55 | return null; 56 | } 57 | } 58 | 59 | /// Parse List 60 | static Future> reslovePosts(List feeds) async { 61 | int errorCount = 0; 62 | for (final Feed feed in feeds) { 63 | bool res = await _reslovePost(feed); 64 | if (!res) { 65 | errorCount++; 66 | } 67 | } 68 | return [feeds.length, errorCount]; 69 | } 70 | 71 | /// Parse a Feed to get update 72 | static Future _reslovePost(Feed feed) async { 73 | try { 74 | final response = await DioHelper.get(feed.url); 75 | final postXmlString = response.data; 76 | final DateTime? feedLastUpdated = await IsarHelper.getLatesPubDate(feed); 77 | try { 78 | RssFeed rssFeed = RssFeed.parse(postXmlString); 79 | for (RssItem item in rssFeed.items) { 80 | if (!(_parsePubDate(item.pubDate) 81 | .isAfter(feedLastUpdated ?? DateTime(0)))) { 82 | break; 83 | } 84 | _parseRSSPostItem(item, feed); 85 | } 86 | return true; 87 | } catch (e) { 88 | AtomFeed atomFeed = AtomFeed.parse(postXmlString); 89 | for (AtomItem item in atomFeed.items) { 90 | if (!(_parsePubDate(item.updated) 91 | .isAfter(feedLastUpdated ?? DateTime(0)))) { 92 | break; 93 | } 94 | _parseAtomPostItem(item, feed); 95 | } 96 | return true; 97 | } 98 | } catch (e) { 99 | return false; 100 | } 101 | } 102 | 103 | /// Use RSS to parse RssItem and save to database 104 | static void _parseRSSPostItem(RssItem item, Feed feed) { 105 | String title = item.title!.trim(); 106 | bool blockStatue = _isBlock(title, item.description ?? ''); 107 | if (blockStatue) { 108 | return; 109 | } 110 | Post post = Post( 111 | title: title, 112 | link: item.link!, 113 | content: item.description ?? '', 114 | pubDate: _parsePubDate(item.pubDate), 115 | read: false, 116 | favorite: false, 117 | fullText: feed.fullText, 118 | ); 119 | post.feed.value = feed; 120 | IsarHelper.savePost(post); 121 | } 122 | 123 | /// Use Atom to parse RssItem and save to database 124 | static void _parseAtomPostItem(AtomItem item, Feed feed) { 125 | String title = item.title!.trim(); 126 | bool blockStatue = _isBlock(title, item.content ?? ''); 127 | if (blockStatue) { 128 | return; 129 | } 130 | Post post = Post( 131 | title: title, 132 | link: item.links[0].href!, 133 | content: item.content!, 134 | pubDate: _parsePubDate(item.updated), 135 | read: false, 136 | favorite: false, 137 | fullText: feed.fullText, 138 | ); 139 | post.feed.value = feed; 140 | IsarHelper.savePost(post); 141 | } 142 | 143 | /// Determine whether the Post is blocked by title and content 144 | static bool _isBlock(String title, String content) { 145 | List blockList = PrefsHelper.blockList; 146 | bool blockStatue = false; 147 | for (String block in blockList) { 148 | if (title.contains(block) || content.contains(block)) { 149 | blockStatue = true; 150 | break; 151 | } 152 | } 153 | return blockStatue; 154 | } 155 | 156 | /// Post pubDate format conversion 157 | static DateTime _parsePubDate(String? str) { 158 | if (str == null) { 159 | return DateTime.now(); 160 | } 161 | const dateFormatPatterns = [ 162 | 'EEE, d MMM yyyy HH:mm:ss Z', 163 | ]; 164 | try { 165 | return DateTime.parse(str); 166 | } catch (_) { 167 | for (final pattern in dateFormatPatterns) { 168 | try { 169 | final format = DateFormat(pattern); 170 | return format.parse(str); 171 | } catch (_) {} 172 | } 173 | } 174 | return DateTime.now(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/helpers/routes_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | import 'package:meread/ui/views/add_feed/add_feed_view.dart'; 3 | import 'package:meread/ui/views/edit_feed/edit_feed_view.dart'; 4 | import 'package:meread/ui/views/home_view.dart'; 5 | import 'package:meread/ui/views/post/post_view.dart'; 6 | import 'package:meread/ui/views/setting/about/about_view.dart'; 7 | import 'package:meread/ui/views/setting/data_manage/data_manage_view.dart'; 8 | import 'package:meread/ui/views/setting/display/display_setting_view.dart'; 9 | import 'package:meread/ui/views/setting/read/read_setting_view.dart'; 10 | import 'package:meread/ui/views/setting/resolve/resolve_setting_view.dart'; 11 | import 'package:meread/ui/views/setting/setting_view.dart'; 12 | 13 | class RouteHelp { 14 | static String get initRoute => '/'; 15 | 16 | static final List routes = [ 17 | GetPage(name: '/', page: () => const HomeView()), 18 | GetPage(name: '/post', page: () => const PostView()), 19 | GetPage(name: '/addFeed', page: () => const AddFeedView()), 20 | GetPage(name: '/editFeed', page: () => const EditFeedView()), 21 | GetPage(name: '/setting', page: () => const SettingView()), 22 | GetPage(name: '/setting/display', page: () => const DisplaySettingView()), 23 | GetPage(name: '/setting/read', page: () => const ReadSettingView()), 24 | GetPage(name: '/setting/resolve', page: () => const ResolveSettingView()), 25 | GetPage(name: '/setting/data_manage', page: () => const DataManageView()), 26 | GetPage(name: '/setting/about', page: () => const AboutView()), 27 | ]; 28 | } 29 | -------------------------------------------------------------------------------- /lib/helpers/theme_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:meread/helpers/prefs_helper.dart'; 3 | 4 | class ThemeHelp { 5 | static ThemeData buildLightTheme(ColorScheme? lightColorScheme) { 6 | ColorScheme defaultLightColorScheme = ColorScheme.fromSeed( 7 | seedColor: Colors.blue, 8 | brightness: Brightness.light, 9 | ); 10 | return ThemeData( 11 | brightness: Brightness.light, 12 | useMaterial3: true, 13 | fontFamily: PrefsHelper.themeFont, 14 | colorScheme: PrefsHelper.useDynamicColor 15 | ? lightColorScheme ?? defaultLightColorScheme 16 | : defaultLightColorScheme, 17 | ); 18 | } 19 | 20 | static ThemeData buildDarkTheme(ColorScheme? darkColorScheme) { 21 | ColorScheme defaultDarkColorScheme = ColorScheme.fromSeed( 22 | seedColor: Colors.blue, brightness: Brightness.dark); 23 | return ThemeData( 24 | brightness: Brightness.dark, 25 | useMaterial3: true, 26 | fontFamily: PrefsHelper.themeFont, 27 | colorScheme: PrefsHelper.useDynamicColor 28 | ? darkColorScheme ?? defaultDarkColorScheme 29 | : defaultDarkColorScheme, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/helpers/update_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/constant_helper.dart'; 4 | import 'package:meread/helpers/dio_helper.dart'; 5 | import 'package:meread/helpers/log_helper.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | 8 | class UpdateHelper { 9 | /// Check update 10 | static Future checkUpdate() async { 11 | LogHelper.i('[update]: Start check update.'); 12 | Get.snackbar( 13 | 'info'.tr, 14 | 'checkingForUpdates'.tr, 15 | snackPosition: SnackPosition.BOTTOM, 16 | margin: const EdgeInsets.all(12), 17 | ); 18 | try { 19 | final response = await DioHelper.get( 20 | 'https://github.com/gvenusleo/MeRead/releases/latest', 21 | ); 22 | final String title = 23 | response.data.split('')[1].split('')[0]; 24 | final String latestVersion = title.split(' ')[1]; 25 | if (latestVersion == ConstantHelp.appVersion.substring(1, 6)) { 26 | Get.closeAllSnackbars(); 27 | Get.snackbar( 28 | 'info'.tr, 29 | 'alreadyLatestVersion'.tr, 30 | snackPosition: SnackPosition.BOTTOM, 31 | margin: const EdgeInsets.all(12), 32 | ); 33 | LogHelper.i( 34 | '[update]: Already the latest version: ${ConstantHelp.appVersion}.'); 35 | } else { 36 | Get.closeAllSnackbars(); 37 | Get.dialog( 38 | AlertDialog( 39 | icon: const Icon(Icons.update_outlined), 40 | title: Text('newVersionAvailable'.tr), 41 | content: Text('downloadInfo'.tr), 42 | actions: [ 43 | TextButton( 44 | onPressed: () { 45 | launchUrl( 46 | Uri.parse( 47 | 'https://github.com/gvenusleo/MeRead/releases/latest'), 48 | mode: LaunchMode.externalApplication, 49 | ); 50 | }, 51 | child: Text('downloadNow'.tr), 52 | ), 53 | TextButton( 54 | onPressed: () => Get.back(), 55 | child: Text('temporarilyCancel'.tr), 56 | ), 57 | ], 58 | ), 59 | ); 60 | LogHelper.i( 61 | '[update]: Found new version: $latestVersion, current version: ${ConstantHelp.appVersion}'); 62 | } 63 | } catch (e) { 64 | Get.snackbar('error'.tr, 'checkUpdateError'.tr); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:dynamic_color/dynamic_color.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:get/get.dart'; 4 | import 'package:flutter_localizations/flutter_localizations.dart'; 5 | import 'package:meread/common/init_app.dart'; 6 | import 'package:meread/helpers/prefs_helper.dart'; 7 | import 'package:meread/helpers/routes_helper.dart'; 8 | import 'package:meread/helpers/theme_helper.dart'; 9 | import 'package:meread/translation/translation.dart'; 10 | 11 | Future main() async { 12 | await initApp(); 13 | runApp(const MyApp()); 14 | } 15 | 16 | class MyApp extends StatelessWidget { 17 | const MyApp({super.key}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return DynamicColorBuilder( 22 | builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { 23 | return GetMaterialApp( 24 | debugShowCheckedModeBanner: false, 25 | title: 'MeRead'.tr, 26 | localizationsDelegates: const [ 27 | GlobalWidgetsLocalizations.delegate, 28 | GlobalMaterialLocalizations.delegate, 29 | GlobalCupertinoLocalizations.delegate, 30 | ], 31 | supportedLocales: const [ 32 | Locale('en', 'US'), 33 | Locale('zh', 'CN'), 34 | ], 35 | locale: PrefsHelper.language == 'system' 36 | ? Get.deviceLocale 37 | : Locale(PrefsHelper.language.split('_').first, 38 | PrefsHelper.language.split('_').last), 39 | fallbackLocale: const Locale('en', 'US'), 40 | translations: AppTranslation(), 41 | theme: ThemeHelp.buildLightTheme(lightDynamic), 42 | darkTheme: ThemeHelp.buildDarkTheme(darkDynamic), 43 | themeMode: [ 44 | ThemeMode.system, 45 | ThemeMode.light, 46 | ThemeMode.dark, 47 | ][PrefsHelper.themeMode], 48 | initialRoute: RouteHelp.initRoute, 49 | getPages: RouteHelp.routes, 50 | defaultTransition: { 51 | 'cupertino': Transition.cupertino, 52 | 'fade': Transition.fade, 53 | }[PrefsHelper.transition], 54 | builder: (context, child) { 55 | return MediaQuery( 56 | data: MediaQuery.of(context).copyWith( 57 | textScaler: TextScaler.linear(PrefsHelper.textScaleFactor), 58 | ), 59 | child: child!, 60 | ); 61 | }, 62 | ); 63 | }, 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/models/category.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:meread/models/feed.dart'; 3 | 4 | part 'category.g.dart'; 5 | 6 | @collection 7 | class Category { 8 | Id? id = Isar.autoIncrement; 9 | String name; 10 | DateTime createdAt; 11 | DateTime updatedAt; 12 | @Backlink(to: 'category') 13 | final feeds = IsarLinks(); 14 | 15 | Category({ 16 | this.id, 17 | required this.name, 18 | required this.createdAt, 19 | required this.updatedAt, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /lib/models/category.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'category.dart'; 4 | 5 | // ************************************************************************** 6 | // IsarCollectionGenerator 7 | // ************************************************************************** 8 | 9 | // coverage:ignore-file 10 | // ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types 11 | 12 | extension GetCategoryCollection on Isar { 13 | IsarCollection get categorys => this.collection(); 14 | } 15 | 16 | const CategorySchema = CollectionSchema( 17 | name: r'Category', 18 | id: 5751694338128944171, 19 | properties: { 20 | r'createdAt': PropertySchema( 21 | id: 0, 22 | name: r'createdAt', 23 | type: IsarType.dateTime, 24 | ), 25 | r'name': PropertySchema( 26 | id: 1, 27 | name: r'name', 28 | type: IsarType.string, 29 | ), 30 | r'updatedAt': PropertySchema( 31 | id: 2, 32 | name: r'updatedAt', 33 | type: IsarType.dateTime, 34 | ) 35 | }, 36 | estimateSize: _categoryEstimateSize, 37 | serialize: _categorySerialize, 38 | deserialize: _categoryDeserialize, 39 | deserializeProp: _categoryDeserializeProp, 40 | idName: r'id', 41 | indexes: {}, 42 | links: { 43 | r'feeds': LinkSchema( 44 | id: 5088987566546768766, 45 | name: r'feeds', 46 | target: r'Feed', 47 | single: false, 48 | linkName: r'category', 49 | ) 50 | }, 51 | embeddedSchemas: {}, 52 | getId: _categoryGetId, 53 | getLinks: _categoryGetLinks, 54 | attach: _categoryAttach, 55 | version: '3.1.0+1', 56 | ); 57 | 58 | int _categoryEstimateSize( 59 | Category object, 60 | List offsets, 61 | Map> allOffsets, 62 | ) { 63 | var bytesCount = offsets.last; 64 | bytesCount += 3 + object.name.length * 3; 65 | return bytesCount; 66 | } 67 | 68 | void _categorySerialize( 69 | Category object, 70 | IsarWriter writer, 71 | List offsets, 72 | Map> allOffsets, 73 | ) { 74 | writer.writeDateTime(offsets[0], object.createdAt); 75 | writer.writeString(offsets[1], object.name); 76 | writer.writeDateTime(offsets[2], object.updatedAt); 77 | } 78 | 79 | Category _categoryDeserialize( 80 | Id id, 81 | IsarReader reader, 82 | List offsets, 83 | Map> allOffsets, 84 | ) { 85 | final object = Category( 86 | createdAt: reader.readDateTime(offsets[0]), 87 | id: id, 88 | name: reader.readString(offsets[1]), 89 | updatedAt: reader.readDateTime(offsets[2]), 90 | ); 91 | return object; 92 | } 93 | 94 | P _categoryDeserializeProp

( 95 | IsarReader reader, 96 | int propertyId, 97 | int offset, 98 | Map> allOffsets, 99 | ) { 100 | switch (propertyId) { 101 | case 0: 102 | return (reader.readDateTime(offset)) as P; 103 | case 1: 104 | return (reader.readString(offset)) as P; 105 | case 2: 106 | return (reader.readDateTime(offset)) as P; 107 | default: 108 | throw IsarError('Unknown property with id $propertyId'); 109 | } 110 | } 111 | 112 | Id _categoryGetId(Category object) { 113 | return object.id ?? Isar.autoIncrement; 114 | } 115 | 116 | List> _categoryGetLinks(Category object) { 117 | return [object.feeds]; 118 | } 119 | 120 | void _categoryAttach(IsarCollection col, Id id, Category object) { 121 | object.id = id; 122 | object.feeds.attach(col, col.isar.collection(), r'feeds', id); 123 | } 124 | 125 | extension CategoryQueryWhereSort on QueryBuilder { 126 | QueryBuilder anyId() { 127 | return QueryBuilder.apply(this, (query) { 128 | return query.addWhereClause(const IdWhereClause.any()); 129 | }); 130 | } 131 | } 132 | 133 | extension CategoryQueryWhere on QueryBuilder { 134 | QueryBuilder idEqualTo(Id id) { 135 | return QueryBuilder.apply(this, (query) { 136 | return query.addWhereClause(IdWhereClause.between( 137 | lower: id, 138 | upper: id, 139 | )); 140 | }); 141 | } 142 | 143 | QueryBuilder idNotEqualTo(Id id) { 144 | return QueryBuilder.apply(this, (query) { 145 | if (query.whereSort == Sort.asc) { 146 | return query 147 | .addWhereClause( 148 | IdWhereClause.lessThan(upper: id, includeUpper: false), 149 | ) 150 | .addWhereClause( 151 | IdWhereClause.greaterThan(lower: id, includeLower: false), 152 | ); 153 | } else { 154 | return query 155 | .addWhereClause( 156 | IdWhereClause.greaterThan(lower: id, includeLower: false), 157 | ) 158 | .addWhereClause( 159 | IdWhereClause.lessThan(upper: id, includeUpper: false), 160 | ); 161 | } 162 | }); 163 | } 164 | 165 | QueryBuilder idGreaterThan(Id id, 166 | {bool include = false}) { 167 | return QueryBuilder.apply(this, (query) { 168 | return query.addWhereClause( 169 | IdWhereClause.greaterThan(lower: id, includeLower: include), 170 | ); 171 | }); 172 | } 173 | 174 | QueryBuilder idLessThan(Id id, 175 | {bool include = false}) { 176 | return QueryBuilder.apply(this, (query) { 177 | return query.addWhereClause( 178 | IdWhereClause.lessThan(upper: id, includeUpper: include), 179 | ); 180 | }); 181 | } 182 | 183 | QueryBuilder idBetween( 184 | Id lowerId, 185 | Id upperId, { 186 | bool includeLower = true, 187 | bool includeUpper = true, 188 | }) { 189 | return QueryBuilder.apply(this, (query) { 190 | return query.addWhereClause(IdWhereClause.between( 191 | lower: lowerId, 192 | includeLower: includeLower, 193 | upper: upperId, 194 | includeUpper: includeUpper, 195 | )); 196 | }); 197 | } 198 | } 199 | 200 | extension CategoryQueryFilter 201 | on QueryBuilder { 202 | QueryBuilder createdAtEqualTo( 203 | DateTime value) { 204 | return QueryBuilder.apply(this, (query) { 205 | return query.addFilterCondition(FilterCondition.equalTo( 206 | property: r'createdAt', 207 | value: value, 208 | )); 209 | }); 210 | } 211 | 212 | QueryBuilder createdAtGreaterThan( 213 | DateTime value, { 214 | bool include = false, 215 | }) { 216 | return QueryBuilder.apply(this, (query) { 217 | return query.addFilterCondition(FilterCondition.greaterThan( 218 | include: include, 219 | property: r'createdAt', 220 | value: value, 221 | )); 222 | }); 223 | } 224 | 225 | QueryBuilder createdAtLessThan( 226 | DateTime value, { 227 | bool include = false, 228 | }) { 229 | return QueryBuilder.apply(this, (query) { 230 | return query.addFilterCondition(FilterCondition.lessThan( 231 | include: include, 232 | property: r'createdAt', 233 | value: value, 234 | )); 235 | }); 236 | } 237 | 238 | QueryBuilder createdAtBetween( 239 | DateTime lower, 240 | DateTime upper, { 241 | bool includeLower = true, 242 | bool includeUpper = true, 243 | }) { 244 | return QueryBuilder.apply(this, (query) { 245 | return query.addFilterCondition(FilterCondition.between( 246 | property: r'createdAt', 247 | lower: lower, 248 | includeLower: includeLower, 249 | upper: upper, 250 | includeUpper: includeUpper, 251 | )); 252 | }); 253 | } 254 | 255 | QueryBuilder idIsNull() { 256 | return QueryBuilder.apply(this, (query) { 257 | return query.addFilterCondition(const FilterCondition.isNull( 258 | property: r'id', 259 | )); 260 | }); 261 | } 262 | 263 | QueryBuilder idIsNotNull() { 264 | return QueryBuilder.apply(this, (query) { 265 | return query.addFilterCondition(const FilterCondition.isNotNull( 266 | property: r'id', 267 | )); 268 | }); 269 | } 270 | 271 | QueryBuilder idEqualTo(Id? value) { 272 | return QueryBuilder.apply(this, (query) { 273 | return query.addFilterCondition(FilterCondition.equalTo( 274 | property: r'id', 275 | value: value, 276 | )); 277 | }); 278 | } 279 | 280 | QueryBuilder idGreaterThan( 281 | Id? value, { 282 | bool include = false, 283 | }) { 284 | return QueryBuilder.apply(this, (query) { 285 | return query.addFilterCondition(FilterCondition.greaterThan( 286 | include: include, 287 | property: r'id', 288 | value: value, 289 | )); 290 | }); 291 | } 292 | 293 | QueryBuilder idLessThan( 294 | Id? value, { 295 | bool include = false, 296 | }) { 297 | return QueryBuilder.apply(this, (query) { 298 | return query.addFilterCondition(FilterCondition.lessThan( 299 | include: include, 300 | property: r'id', 301 | value: value, 302 | )); 303 | }); 304 | } 305 | 306 | QueryBuilder idBetween( 307 | Id? lower, 308 | Id? upper, { 309 | bool includeLower = true, 310 | bool includeUpper = true, 311 | }) { 312 | return QueryBuilder.apply(this, (query) { 313 | return query.addFilterCondition(FilterCondition.between( 314 | property: r'id', 315 | lower: lower, 316 | includeLower: includeLower, 317 | upper: upper, 318 | includeUpper: includeUpper, 319 | )); 320 | }); 321 | } 322 | 323 | QueryBuilder nameEqualTo( 324 | String value, { 325 | bool caseSensitive = true, 326 | }) { 327 | return QueryBuilder.apply(this, (query) { 328 | return query.addFilterCondition(FilterCondition.equalTo( 329 | property: r'name', 330 | value: value, 331 | caseSensitive: caseSensitive, 332 | )); 333 | }); 334 | } 335 | 336 | QueryBuilder nameGreaterThan( 337 | String value, { 338 | bool include = false, 339 | bool caseSensitive = true, 340 | }) { 341 | return QueryBuilder.apply(this, (query) { 342 | return query.addFilterCondition(FilterCondition.greaterThan( 343 | include: include, 344 | property: r'name', 345 | value: value, 346 | caseSensitive: caseSensitive, 347 | )); 348 | }); 349 | } 350 | 351 | QueryBuilder nameLessThan( 352 | String value, { 353 | bool include = false, 354 | bool caseSensitive = true, 355 | }) { 356 | return QueryBuilder.apply(this, (query) { 357 | return query.addFilterCondition(FilterCondition.lessThan( 358 | include: include, 359 | property: r'name', 360 | value: value, 361 | caseSensitive: caseSensitive, 362 | )); 363 | }); 364 | } 365 | 366 | QueryBuilder nameBetween( 367 | String lower, 368 | String upper, { 369 | bool includeLower = true, 370 | bool includeUpper = true, 371 | bool caseSensitive = true, 372 | }) { 373 | return QueryBuilder.apply(this, (query) { 374 | return query.addFilterCondition(FilterCondition.between( 375 | property: r'name', 376 | lower: lower, 377 | includeLower: includeLower, 378 | upper: upper, 379 | includeUpper: includeUpper, 380 | caseSensitive: caseSensitive, 381 | )); 382 | }); 383 | } 384 | 385 | QueryBuilder nameStartsWith( 386 | String value, { 387 | bool caseSensitive = true, 388 | }) { 389 | return QueryBuilder.apply(this, (query) { 390 | return query.addFilterCondition(FilterCondition.startsWith( 391 | property: r'name', 392 | value: value, 393 | caseSensitive: caseSensitive, 394 | )); 395 | }); 396 | } 397 | 398 | QueryBuilder nameEndsWith( 399 | String value, { 400 | bool caseSensitive = true, 401 | }) { 402 | return QueryBuilder.apply(this, (query) { 403 | return query.addFilterCondition(FilterCondition.endsWith( 404 | property: r'name', 405 | value: value, 406 | caseSensitive: caseSensitive, 407 | )); 408 | }); 409 | } 410 | 411 | QueryBuilder nameContains( 412 | String value, 413 | {bool caseSensitive = true}) { 414 | return QueryBuilder.apply(this, (query) { 415 | return query.addFilterCondition(FilterCondition.contains( 416 | property: r'name', 417 | value: value, 418 | caseSensitive: caseSensitive, 419 | )); 420 | }); 421 | } 422 | 423 | QueryBuilder nameMatches( 424 | String pattern, 425 | {bool caseSensitive = true}) { 426 | return QueryBuilder.apply(this, (query) { 427 | return query.addFilterCondition(FilterCondition.matches( 428 | property: r'name', 429 | wildcard: pattern, 430 | caseSensitive: caseSensitive, 431 | )); 432 | }); 433 | } 434 | 435 | QueryBuilder nameIsEmpty() { 436 | return QueryBuilder.apply(this, (query) { 437 | return query.addFilterCondition(FilterCondition.equalTo( 438 | property: r'name', 439 | value: '', 440 | )); 441 | }); 442 | } 443 | 444 | QueryBuilder nameIsNotEmpty() { 445 | return QueryBuilder.apply(this, (query) { 446 | return query.addFilterCondition(FilterCondition.greaterThan( 447 | property: r'name', 448 | value: '', 449 | )); 450 | }); 451 | } 452 | 453 | QueryBuilder updatedAtEqualTo( 454 | DateTime value) { 455 | return QueryBuilder.apply(this, (query) { 456 | return query.addFilterCondition(FilterCondition.equalTo( 457 | property: r'updatedAt', 458 | value: value, 459 | )); 460 | }); 461 | } 462 | 463 | QueryBuilder updatedAtGreaterThan( 464 | DateTime value, { 465 | bool include = false, 466 | }) { 467 | return QueryBuilder.apply(this, (query) { 468 | return query.addFilterCondition(FilterCondition.greaterThan( 469 | include: include, 470 | property: r'updatedAt', 471 | value: value, 472 | )); 473 | }); 474 | } 475 | 476 | QueryBuilder updatedAtLessThan( 477 | DateTime value, { 478 | bool include = false, 479 | }) { 480 | return QueryBuilder.apply(this, (query) { 481 | return query.addFilterCondition(FilterCondition.lessThan( 482 | include: include, 483 | property: r'updatedAt', 484 | value: value, 485 | )); 486 | }); 487 | } 488 | 489 | QueryBuilder updatedAtBetween( 490 | DateTime lower, 491 | DateTime upper, { 492 | bool includeLower = true, 493 | bool includeUpper = true, 494 | }) { 495 | return QueryBuilder.apply(this, (query) { 496 | return query.addFilterCondition(FilterCondition.between( 497 | property: r'updatedAt', 498 | lower: lower, 499 | includeLower: includeLower, 500 | upper: upper, 501 | includeUpper: includeUpper, 502 | )); 503 | }); 504 | } 505 | } 506 | 507 | extension CategoryQueryObject 508 | on QueryBuilder {} 509 | 510 | extension CategoryQueryLinks 511 | on QueryBuilder { 512 | QueryBuilder feeds( 513 | FilterQuery q) { 514 | return QueryBuilder.apply(this, (query) { 515 | return query.link(q, r'feeds'); 516 | }); 517 | } 518 | 519 | QueryBuilder feedsLengthEqualTo( 520 | int length) { 521 | return QueryBuilder.apply(this, (query) { 522 | return query.linkLength(r'feeds', length, true, length, true); 523 | }); 524 | } 525 | 526 | QueryBuilder feedsIsEmpty() { 527 | return QueryBuilder.apply(this, (query) { 528 | return query.linkLength(r'feeds', 0, true, 0, true); 529 | }); 530 | } 531 | 532 | QueryBuilder feedsIsNotEmpty() { 533 | return QueryBuilder.apply(this, (query) { 534 | return query.linkLength(r'feeds', 0, false, 999999, true); 535 | }); 536 | } 537 | 538 | QueryBuilder feedsLengthLessThan( 539 | int length, { 540 | bool include = false, 541 | }) { 542 | return QueryBuilder.apply(this, (query) { 543 | return query.linkLength(r'feeds', 0, true, length, include); 544 | }); 545 | } 546 | 547 | QueryBuilder 548 | feedsLengthGreaterThan( 549 | int length, { 550 | bool include = false, 551 | }) { 552 | return QueryBuilder.apply(this, (query) { 553 | return query.linkLength(r'feeds', length, include, 999999, true); 554 | }); 555 | } 556 | 557 | QueryBuilder feedsLengthBetween( 558 | int lower, 559 | int upper, { 560 | bool includeLower = true, 561 | bool includeUpper = true, 562 | }) { 563 | return QueryBuilder.apply(this, (query) { 564 | return query.linkLength( 565 | r'feeds', lower, includeLower, upper, includeUpper); 566 | }); 567 | } 568 | } 569 | 570 | extension CategoryQuerySortBy on QueryBuilder { 571 | QueryBuilder sortByCreatedAt() { 572 | return QueryBuilder.apply(this, (query) { 573 | return query.addSortBy(r'createdAt', Sort.asc); 574 | }); 575 | } 576 | 577 | QueryBuilder sortByCreatedAtDesc() { 578 | return QueryBuilder.apply(this, (query) { 579 | return query.addSortBy(r'createdAt', Sort.desc); 580 | }); 581 | } 582 | 583 | QueryBuilder sortByName() { 584 | return QueryBuilder.apply(this, (query) { 585 | return query.addSortBy(r'name', Sort.asc); 586 | }); 587 | } 588 | 589 | QueryBuilder sortByNameDesc() { 590 | return QueryBuilder.apply(this, (query) { 591 | return query.addSortBy(r'name', Sort.desc); 592 | }); 593 | } 594 | 595 | QueryBuilder sortByUpdatedAt() { 596 | return QueryBuilder.apply(this, (query) { 597 | return query.addSortBy(r'updatedAt', Sort.asc); 598 | }); 599 | } 600 | 601 | QueryBuilder sortByUpdatedAtDesc() { 602 | return QueryBuilder.apply(this, (query) { 603 | return query.addSortBy(r'updatedAt', Sort.desc); 604 | }); 605 | } 606 | } 607 | 608 | extension CategoryQuerySortThenBy 609 | on QueryBuilder { 610 | QueryBuilder thenByCreatedAt() { 611 | return QueryBuilder.apply(this, (query) { 612 | return query.addSortBy(r'createdAt', Sort.asc); 613 | }); 614 | } 615 | 616 | QueryBuilder thenByCreatedAtDesc() { 617 | return QueryBuilder.apply(this, (query) { 618 | return query.addSortBy(r'createdAt', Sort.desc); 619 | }); 620 | } 621 | 622 | QueryBuilder thenById() { 623 | return QueryBuilder.apply(this, (query) { 624 | return query.addSortBy(r'id', Sort.asc); 625 | }); 626 | } 627 | 628 | QueryBuilder thenByIdDesc() { 629 | return QueryBuilder.apply(this, (query) { 630 | return query.addSortBy(r'id', Sort.desc); 631 | }); 632 | } 633 | 634 | QueryBuilder thenByName() { 635 | return QueryBuilder.apply(this, (query) { 636 | return query.addSortBy(r'name', Sort.asc); 637 | }); 638 | } 639 | 640 | QueryBuilder thenByNameDesc() { 641 | return QueryBuilder.apply(this, (query) { 642 | return query.addSortBy(r'name', Sort.desc); 643 | }); 644 | } 645 | 646 | QueryBuilder thenByUpdatedAt() { 647 | return QueryBuilder.apply(this, (query) { 648 | return query.addSortBy(r'updatedAt', Sort.asc); 649 | }); 650 | } 651 | 652 | QueryBuilder thenByUpdatedAtDesc() { 653 | return QueryBuilder.apply(this, (query) { 654 | return query.addSortBy(r'updatedAt', Sort.desc); 655 | }); 656 | } 657 | } 658 | 659 | extension CategoryQueryWhereDistinct 660 | on QueryBuilder { 661 | QueryBuilder distinctByCreatedAt() { 662 | return QueryBuilder.apply(this, (query) { 663 | return query.addDistinctBy(r'createdAt'); 664 | }); 665 | } 666 | 667 | QueryBuilder distinctByName( 668 | {bool caseSensitive = true}) { 669 | return QueryBuilder.apply(this, (query) { 670 | return query.addDistinctBy(r'name', caseSensitive: caseSensitive); 671 | }); 672 | } 673 | 674 | QueryBuilder distinctByUpdatedAt() { 675 | return QueryBuilder.apply(this, (query) { 676 | return query.addDistinctBy(r'updatedAt'); 677 | }); 678 | } 679 | } 680 | 681 | extension CategoryQueryProperty 682 | on QueryBuilder { 683 | QueryBuilder idProperty() { 684 | return QueryBuilder.apply(this, (query) { 685 | return query.addPropertyName(r'id'); 686 | }); 687 | } 688 | 689 | QueryBuilder createdAtProperty() { 690 | return QueryBuilder.apply(this, (query) { 691 | return query.addPropertyName(r'createdAt'); 692 | }); 693 | } 694 | 695 | QueryBuilder nameProperty() { 696 | return QueryBuilder.apply(this, (query) { 697 | return query.addPropertyName(r'name'); 698 | }); 699 | } 700 | 701 | QueryBuilder updatedAtProperty() { 702 | return QueryBuilder.apply(this, (query) { 703 | return query.addPropertyName(r'updatedAt'); 704 | }); 705 | } 706 | } 707 | -------------------------------------------------------------------------------- /lib/models/feed.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:meread/models/category.dart'; 3 | import 'package:meread/models/post.dart'; 4 | 5 | part 'feed.g.dart'; 6 | 7 | @collection 8 | class Feed { 9 | Id? id = Isar.autoIncrement; 10 | String title; 11 | String url; 12 | String description; 13 | final category = IsarLink(); 14 | bool fullText; 15 | int openType; // 0: App Read, 1: In-app tab, 2: System browser 16 | @Backlink(to: 'feed') 17 | final posts = IsarLinks(); 18 | 19 | Feed({ 20 | this.id, 21 | required this.title, 22 | required this.url, 23 | required this.description, 24 | required this.fullText, 25 | required this.openType, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/models/post.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:meread/models/feed.dart'; 3 | 4 | part 'post.g.dart'; 5 | 6 | @collection 7 | class Post { 8 | Id? id = Isar.autoIncrement; 9 | final feed = IsarLink(); 10 | String title; 11 | String link; 12 | String content; 13 | DateTime pubDate; 14 | bool read; 15 | bool favorite; 16 | bool fullText; 17 | 18 | Post({ 19 | this.id, 20 | required this.title, 21 | required this.link, 22 | required this.content, 23 | required this.pubDate, 24 | required this.read, 25 | required this.favorite, 26 | required this.fullText, 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /lib/translation/translation.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | class AppTranslation extends Translations { 4 | @override 5 | Map> get keys => { 6 | 'zh_CN': { 7 | 'MeRead': 'MeRead', 8 | 'confirm': '确定', 9 | 'cancel': '取消', 10 | 'open': '开启', 11 | 'close': '关闭', 12 | 13 | // Route: / 14 | 'markAllAsRead': '全标已读', 15 | 'fullTextSearch': '全文搜索', 16 | 'FeedIsEmpty': '订阅源为空,请添加订阅源后再尝试', 17 | 'refreshFailed': '@count 个订阅源更新失败', 18 | 'refreshSuccess': '更新成功', 19 | 'allFeeds': '全部订阅', 20 | 21 | // Route: /add_feed 22 | 'addFeed': '添加订阅', 23 | 'feedAddress': '订阅源地址', 24 | 'pasteAddress': '粘贴地址', 25 | 'resloveAddress': '解析地址', 26 | 'feedAlreadyExists': '订阅源已存在', 27 | 'feedResolveError': '解析订阅源失败', 28 | 'feedCategory': '订阅源分类', 29 | 'defaultCategory': '默认分类', 30 | 'fullText': '获取全文', 31 | 'fullTextInfo': '自动抓取文章全文内容', 32 | 'openType': '打开方式', 33 | 'openInApp': '内置阅读器', 34 | 'openInAppTab': '内置标签页', 35 | 'openInBrowser': '系统浏览器', 36 | 'saveFeed': '保存', 37 | 'deleteFeed': '删除', 38 | 39 | // Route: /edit_feed 40 | 'editFeed': '编辑订阅源', 41 | 'feedName': '订阅源名称', 42 | 43 | // Route: /setting 44 | 'moreSetting': '更多设置', 45 | 46 | // Route: /setting/display 47 | 'displaySetting': '显示设置', 48 | 'displaySettingInfo': '主题,动效,缩放,语言', 49 | 'darkMode': '深色模式', 50 | 'followSystem': '跟随系统', 51 | 'dynamicColor': '动态颜色', 52 | 'dynamicColorInfo': '根据壁纸自动调整主题颜色', 53 | 'globalFont': '全局字体', 54 | 'defaultFont': '默认字体', 55 | 'importFont': '导入', 56 | 'animationEffect': '动画效果', 57 | 'animationEffectInfo': '重启应用后生效', 58 | 'smoothScrolling': '平滑滚动', 59 | 'fadeInAndOut': '淡入淡出', 60 | 'textScale': '字体缩放', 61 | 'textScaleFactor': '字体缩放系数:', 62 | 'language': '语言', 63 | 'systemLanguage': '系统语言', 64 | 'zh_CN': '简体中文', 65 | 'en_US': 'English', 66 | 67 | // Route: /setting/read 68 | 'readSetting': '阅读设置', 69 | 'readSettingInfo': '字体,行高,边距,对齐', 70 | 'fontSize': '字体大小', 71 | 'lineHeight': '行高', 72 | 'pagePadding': '页面边距', 73 | 'textAlign': '文本对齐', 74 | 'leftAlign': '左对齐', 75 | 'rightAlign': '右对齐', 76 | 'justifyAlign': '两端对齐', 77 | 'centerAlign': '居中对齐', 78 | 79 | // Route: /setting/resolve 80 | 'resolveSetting': '解析设置', 81 | 'resolveSettingInfo': '启动时刷新,屏蔽词,使用代理', 82 | 'refreshOnStartup': '启动时刷新', 83 | 'refreshOnStartupInfo': '启动应用时自动拉取订阅源更新', 84 | 'blockWords': '屏蔽词', 85 | 'blockWordsInfo': '屏蔽包含关键词的文章', 86 | 'add': '添加', 87 | 'addBlockWord': '添加屏蔽词', 88 | 'useProxy': '使用代理', 89 | 'useProxyInfo': '网络请求时使用代理服务器', 90 | 'useProxyFailedInfo': '代理地址和端口不能为空', 91 | 'proxyAddress': '代理地址', 92 | 'proxyPort': '代理端口', 93 | 'notSet': '未设置', 94 | 95 | // Route: /setting/data_manage 96 | 'dataManage': '数据管理', 97 | 'dataManageInfo': '导入,导出,清除数据', 98 | 'importOpml': '导入 OPML', 99 | 'importOpmlInfo': '从 OPML 文件导入订阅源', 100 | 'exportOpml': '导出 OPML', 101 | 'exportOpmlInfo': '导出所有订阅源到 OPML 文件', 102 | 103 | // Route: /setting/about 104 | 'aboutApp': '关于应用', 105 | 'aboutAppInfo': '版本,开源地址,联系作者', 106 | 'appInfo': 'Material You 风格的 RSS 阅读器', 107 | 'openSource': '开源地址', 108 | 'contactAuthor': '联系作者', 109 | }, 110 | 'en_US': {} 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/add_feed/add_feed_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:fluttertoast/fluttertoast.dart'; 4 | import 'package:get/get.dart'; 5 | import 'package:meread/helpers/isar_helper.dart'; 6 | import 'package:meread/helpers/resolve_helper.dart'; 7 | import 'package:meread/models/feed.dart'; 8 | 9 | class AddFeedController extends GetxController { 10 | RxBool isResolved = false.obs; 11 | 12 | final addressController = TextEditingController(); 13 | Feed? feed; 14 | 15 | Future pasteAddress() async { 16 | String? address = (await Clipboard.getData('text/plain'))?.text; 17 | if (address != null) { 18 | addressController.text = address; 19 | addressController.selection = TextSelection.fromPosition( 20 | TextPosition(offset: address.length), 21 | ); 22 | } 23 | } 24 | 25 | Future resolveAddress() async { 26 | final url = addressController.text; 27 | feed = await ResolveHelper.parseFeed(url); 28 | if (feed == null) { 29 | Fluttertoast.showToast(msg: 'feedResolveError'.tr); 30 | } 31 | isResolved.value = true; 32 | } 33 | 34 | Future isExists() async { 35 | final url = addressController.text; 36 | final result = await IsarHelper.isExistsFeed(url); 37 | if (result != null) { 38 | feed = result; 39 | } 40 | return result != null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/edit_feed/edit_feed_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/isar_helper.dart'; 4 | import 'package:meread/models/category.dart'; 5 | import 'package:meread/models/feed.dart'; 6 | 7 | class EditFeedCntroller extends GetxController { 8 | RxBool fullText = false.obs; 9 | RxInt openType = 0.obs; 10 | final titleController = TextEditingController(); 11 | final categoryController = TextEditingController(); 12 | Feed? feed; 13 | 14 | void initFeed(Feed value) { 15 | fullText.value = value.fullText; 16 | openType.value = value.openType; 17 | titleController.text = value.title; 18 | categoryController.text = value.category.value?.name ?? ''; 19 | feed = value; 20 | } 21 | 22 | void updateFullText(bool value) { 23 | fullText.value = value; 24 | } 25 | 26 | void updateOpenType(int value) { 27 | openType.value = value; 28 | } 29 | 30 | Future saveFeed() async { 31 | final newFeed = Feed( 32 | id: feed?.id, 33 | title: titleController.text, 34 | url: feed?.url ?? '', 35 | description: feed?.description ?? '', 36 | fullText: fullText.value, 37 | openType: openType.value, 38 | ); 39 | final Category category = 40 | await IsarHelper.getCategoryByName(categoryController.text) ?? 41 | Category( 42 | name: categoryController.text, 43 | createdAt: DateTime.now(), 44 | updatedAt: DateTime.now(), 45 | ); 46 | newFeed.category.value = category; 47 | IsarHelper.saveFeed(newFeed); 48 | Get.back(); 49 | } 50 | 51 | void deleteFeed() { 52 | if (feed == null || feed?.id == null) { 53 | Get.back(); 54 | } else { 55 | IsarHelper.deleteFeed(feed!); 56 | Get.back(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/home_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:fluttertoast/fluttertoast.dart'; 3 | import 'package:get/get.dart'; 4 | import 'package:meread/helpers/isar_helper.dart'; 5 | import 'package:meread/helpers/resolve_helper.dart'; 6 | import 'package:meread/models/category.dart'; 7 | import 'package:meread/models/feed.dart'; 8 | import 'package:meread/models/post.dart'; 9 | 10 | class HomeController extends GetxController { 11 | RxList categorys = [].obs; 12 | RxMap unreadCount = {}.obs; 13 | RxList feeds = [].obs; 14 | RxList postList = [].obs; 15 | RxBool onlyUnread = false.obs; 16 | RxBool onlyFavorite = false.obs; 17 | RxString appBarTitle = 'MeRead'.tr.obs; 18 | 19 | final searchController = SearchController(); 20 | 21 | @override 22 | void onInit() { 23 | super.onInit(); 24 | getFeeds().then((_) => getPosts()); 25 | getUnreadCount(); 26 | } 27 | 28 | Future getFeeds() async { 29 | feeds.value = await IsarHelper.getFeeds(); 30 | categorys.value = await IsarHelper.getCategorys(); 31 | appBarTitle.value = 'MeRead'.tr; 32 | } 33 | 34 | Future getUnreadCount() async { 35 | final List posts = await IsarHelper.getPosts(); 36 | final Map result = {}; 37 | for (final Feed feed in feeds) { 38 | final int count = 39 | posts.where((p) => p.feed.value?.id == feed.id && !p.read).length; 40 | result[feed] = count; 41 | } 42 | unreadCount.value = result; 43 | } 44 | 45 | Future getPosts() async { 46 | postList.value = await IsarHelper.getPostsByFeeds(feeds); 47 | } 48 | 49 | Future refreshPosts() async { 50 | if (feeds.isEmpty) { 51 | Fluttertoast.showToast(msg: 'FeedIsEmpty'.tr); 52 | return; 53 | } 54 | List result = await ResolveHelper.reslovePosts(feeds); 55 | getPosts(); 56 | if (result[1] > 0) { 57 | Fluttertoast.showToast( 58 | msg: 'refreshFailed'.trParams({'count': result[1].toString()}), 59 | ); 60 | } else { 61 | Fluttertoast.showToast(msg: 'refreshSuccess'.tr); 62 | } 63 | } 64 | 65 | Future focusAllFeeds() async { 66 | feeds.value = await IsarHelper.getFeeds(); 67 | await getPosts(); 68 | onlyUnread.value = false; 69 | onlyFavorite.value = false; 70 | appBarTitle.value = 'MeRead'.tr; 71 | Get.back(); 72 | } 73 | 74 | // Focus on a category 75 | Future focusCategory(Category category) async { 76 | feeds.value = category.feeds.toList(); 77 | await getPosts(); 78 | onlyUnread.value = false; 79 | onlyFavorite.value = false; 80 | appBarTitle.value = category.name; 81 | Get.back(); 82 | } 83 | 84 | // Focus on a feed 85 | Future focusFeed(Feed feed) async { 86 | feeds.value = [feed]; 87 | await getPosts(); 88 | onlyUnread.value = false; 89 | onlyFavorite.value = false; 90 | appBarTitle.value = feed.title; 91 | Get.back(); 92 | } 93 | 94 | // Filter unread 95 | Future filterUnread() async { 96 | if (onlyUnread.value) { 97 | onlyUnread.value = false; 98 | getPosts(); 99 | } else { 100 | onlyUnread.value = true; 101 | onlyFavorite.value = false; 102 | postList.value = (await IsarHelper.getPostsByFeeds(feeds)) 103 | .where((p) => p.read == false) 104 | .toList(); 105 | } 106 | } 107 | 108 | // Filter favorite 109 | Future filterFavorite() async { 110 | if (onlyFavorite.value) { 111 | onlyFavorite.value = false; 112 | getPosts(); 113 | } else { 114 | onlyFavorite.value = true; 115 | onlyUnread.value = false; 116 | postList.value = (await IsarHelper.getPostsByFeeds(feeds)) 117 | .where((p) => p.favorite) 118 | .toList(); 119 | } 120 | } 121 | 122 | // Update a Post read status 123 | void updateReadStatus(Post post) { 124 | final int index = postList.indexOf(post); 125 | IsarHelper.updatePostRead(post); 126 | postList[index] = post; 127 | } 128 | 129 | // Mark all posts as read 130 | void markAllRead() { 131 | IsarHelper.markAllRead(postList); 132 | getPosts(); 133 | } 134 | 135 | // Go to add feed view 136 | void toAddFeed() { 137 | Get.toNamed('/addFeed')?.then((_) => getFeeds().then((_) { 138 | getPosts(); 139 | getUnreadCount(); 140 | })); 141 | } 142 | 143 | // Go to setting view 144 | void toSetting() { 145 | Get.toNamed('/setting')?.then((_) => getFeeds().then((_) { 146 | getPosts(); 147 | getUnreadCount(); 148 | })); 149 | } 150 | 151 | // Go to edit feed view 152 | void toEditFeed(Feed value) { 153 | Get.toNamed('/editFeed', arguments: value) 154 | ?.then((_) => getFeeds().then((_) { 155 | getPosts(); 156 | getUnreadCount(); 157 | })); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/post/post_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | import 'package:html/parser.dart' as html_parser; 3 | import 'package:html_main_element/html_main_element.dart'; 4 | import 'package:meread/helpers/dio_helper.dart'; 5 | import 'package:meread/helpers/isar_helper.dart'; 6 | import 'package:meread/helpers/log_helper.dart'; 7 | import 'package:meread/models/post.dart'; 8 | import 'package:url_launcher/url_launcher_string.dart'; 9 | 10 | class PostController extends GetxController { 11 | late Rx post; 12 | RxBool fullTexting = false.obs; 13 | 14 | PostController(Post p) { 15 | p.read = true; 16 | IsarHelper.savePost(p); 17 | post = p.obs; 18 | if ((post.value.feed.value?.fullText ?? false) && !post.value.fullText) { 19 | fullTexting.value = true; 20 | getFullText(); 21 | } 22 | } 23 | 24 | // 在浏览器中打开 25 | void openInBrowser() { 26 | launchUrlString( 27 | post.value.link, 28 | mode: LaunchMode.externalApplication, 29 | ); 30 | } 31 | 32 | // 获取全文 33 | Future getFullText() async { 34 | fullTexting.value = true; 35 | try { 36 | final response = await DioHelper.get(post.value.link); 37 | final document = html_parser.parse(response.data.toString()); 38 | if (document.documentElement == null) return; 39 | final mainElement = readabilityMainElement(document.documentElement!); 40 | post.value = Post( 41 | id: post.value.id, 42 | title: post.value.title, 43 | link: post.value.link, 44 | content: mainElement.outerHtml, 45 | pubDate: post.value.pubDate, 46 | read: post.value.read, 47 | favorite: post.value.favorite, 48 | fullText: true, 49 | )..feed.value = post.value.feed.value; 50 | fullTexting.value = false; 51 | IsarHelper.savePost(post.value); 52 | } catch (e) { 53 | LogHelper.e(e); 54 | } 55 | } 56 | 57 | // 标记为未读 58 | void markAsUnread() { 59 | post.value.read = false; 60 | IsarHelper.savePost(post.value); 61 | } 62 | 63 | // 更改收藏状态 64 | void changeFavorite() { 65 | post.value = Post( 66 | id: post.value.id, 67 | title: post.value.title, 68 | link: post.value.link, 69 | content: post.value.content, 70 | pubDate: post.value.pubDate, 71 | read: post.value.read, 72 | favorite: !post.value.favorite, 73 | fullText: post.value.fullText, 74 | )..feed.value = post.value.feed.value; 75 | IsarHelper.savePost(post.value); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/settings/display/display_setting_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/font_helper.dart'; 4 | import 'package:meread/helpers/prefs_helper.dart'; 5 | 6 | class DisplaySettingController extends GetxController { 7 | RxInt themeMode = PrefsHelper.themeMode.obs; 8 | RxBool enableDynamicColor = PrefsHelper.useDynamicColor.obs; 9 | RxString globalFont = PrefsHelper.themeFont.obs; 10 | RxList fontList = ["system"].obs; 11 | RxString transition = PrefsHelper.transition.obs; 12 | RxDouble textScaleFactor = PrefsHelper.textScaleFactor.obs; 13 | RxString language = PrefsHelper.language.obs; 14 | 15 | List get languageList => [ 16 | 'system', 17 | 'zh_CN', 18 | 'en_US', 19 | ]; 20 | 21 | void changeThemeMode(int value) { 22 | if (value == themeMode.value) return; 23 | themeMode.value = value; 24 | PrefsHelper.themeMode = value; 25 | Get.changeThemeMode([ 26 | ThemeMode.system, 27 | ThemeMode.light, 28 | ThemeMode.dark, 29 | ][value]); 30 | } 31 | 32 | void changeEnableDynamicColor(bool value) { 33 | if (value == enableDynamicColor.value) return; 34 | enableDynamicColor.value = value; 35 | PrefsHelper.useDynamicColor = value; 36 | Get.forceAppUpdate(); 37 | } 38 | 39 | void changeGlobalFont(String value) { 40 | if (value == globalFont.value) return; 41 | globalFont.value = value; 42 | PrefsHelper.themeFont = value; 43 | Get.forceAppUpdate(); 44 | } 45 | 46 | void changeTransition(String value) { 47 | if (value == transition.value) return; 48 | transition.value = value; 49 | PrefsHelper.transition = value; 50 | Get.forceAppUpdate(); 51 | } 52 | 53 | void changeTextScaleFactor(double value) { 54 | if (value == textScaleFactor.value) return; 55 | textScaleFactor.value = value; 56 | PrefsHelper.textScaleFactor = value; 57 | Get.forceAppUpdate(); 58 | } 59 | 60 | void changeLanguage(String value) { 61 | if (value == language.value) return; 62 | language.value = value; 63 | PrefsHelper.language = value; 64 | if (value != 'system') { 65 | Get.updateLocale(Locale(value.split('_').first, value.split('_').last)); 66 | } else { 67 | Get.updateLocale(Get.deviceLocale ?? const Locale('en', 'US')); 68 | } 69 | } 70 | 71 | Future deleteFont(String font) async { 72 | await FontHelper.deleteFont(font); 73 | await refreshFontList(); 74 | changeGlobalFont("system"); 75 | } 76 | 77 | Future refreshFontList() async { 78 | await FontHelper.readAllFont().then( 79 | (value) => fontList.value = ["system", ...value], 80 | ); 81 | } 82 | 83 | // import font 84 | Future importFont() async { 85 | await FontHelper.loadLocalFont(); 86 | await refreshFontList(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/settings/read/read_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | import 'package:meread/helpers/prefs_helper.dart'; 3 | 4 | class ReadController extends GetxController { 5 | RxInt fontSize = PrefsHelper.readFontSize.obs; 6 | RxDouble lineHeight = PrefsHelper.readLineHeight.obs; 7 | RxInt pagePadding = PrefsHelper.readPagePadding.obs; 8 | RxString textAlign = PrefsHelper.readTextAlign.obs; 9 | 10 | void changeFontSize(int value) { 11 | if (value == fontSize.value) return; 12 | fontSize.value = value; 13 | PrefsHelper.readFontSize = value; 14 | } 15 | 16 | void changeLineHeight(double value) { 17 | if (value == lineHeight.value) return; 18 | lineHeight.value = value; 19 | PrefsHelper.readLineHeight = value; 20 | } 21 | 22 | void changePagePadding(int value) { 23 | if (value == pagePadding.value) return; 24 | pagePadding.value = value; 25 | PrefsHelper.readPagePadding = value; 26 | } 27 | 28 | void changeTextAlign(String value) { 29 | if (value == textAlign.value) return; 30 | textAlign.value = value; 31 | PrefsHelper.readTextAlign = value; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/ui/viewmodels/settings/resolve/resolve_setting_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:fluttertoast/fluttertoast.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/prefs_helper.dart'; 4 | 5 | class ResolveSettingController extends GetxController { 6 | RxBool refreshOnStartup = PrefsHelper.refreshOnStartup.obs; 7 | RxList blockWords = PrefsHelper.blockList.obs; 8 | RxBool useProxy = PrefsHelper.useProxy.obs; 9 | RxString proxyAddress = PrefsHelper.proxyAddress.obs; 10 | RxString proxyPort = PrefsHelper.proxyPort.obs; 11 | 12 | void changeRefreshOnStartup(bool value) { 13 | if (value == refreshOnStartup.value) return; 14 | refreshOnStartup.value = value; 15 | PrefsHelper.refreshOnStartup = value; 16 | } 17 | 18 | void changeBlockWords(List value) { 19 | if (value == blockWords) return; 20 | blockWords.assignAll(value); 21 | PrefsHelper.blockList = value; 22 | } 23 | 24 | void changeUseProxy(bool value) { 25 | if (proxyAddress.value.isEmpty || proxyPort.value.isEmpty) { 26 | Fluttertoast.showToast(msg: 'useProxyFailedInfo'.tr); 27 | return; 28 | } 29 | if (value == useProxy.value) return; 30 | useProxy.value = value; 31 | PrefsHelper.useProxy = value; 32 | } 33 | 34 | void changeProxyAddress(String value) { 35 | if (value == proxyAddress.value || value.isEmpty) return; 36 | proxyAddress.value = value; 37 | PrefsHelper.proxyAddress = value; 38 | } 39 | 40 | void changeProxyPort(String value) { 41 | if (value == proxyPort.value || value.isEmpty) return; 42 | proxyPort.value = value; 43 | PrefsHelper.proxyPort = value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/ui/views/add_feed/add_feed_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:fluttertoast/fluttertoast.dart'; 3 | import 'package:get/get.dart'; 4 | import 'package:meread/ui/viewmodels/add_feed/add_feed_controller.dart'; 5 | 6 | class AddFeedView extends StatelessWidget { 7 | const AddFeedView({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final c = Get.put(AddFeedController()); 12 | return Scaffold( 13 | body: CustomScrollView( 14 | physics: const BouncingScrollPhysics(), 15 | slivers: [ 16 | SliverAppBar.large( 17 | title: Text('addFeed'.tr), 18 | ), 19 | SliverList.list( 20 | children: [ 21 | Padding( 22 | padding: const EdgeInsets.symmetric(horizontal: 18), 23 | child: Text('feedAddress'.tr), 24 | ), 25 | Padding( 26 | padding: const EdgeInsets.symmetric(horizontal: 18), 27 | child: TextField( 28 | controller: c.addressController, 29 | ), 30 | ), 31 | Padding( 32 | padding: const EdgeInsets.symmetric( 33 | horizontal: 18, 34 | vertical: 12, 35 | ), 36 | child: Row( 37 | children: [ 38 | Expanded( 39 | flex: 1, 40 | child: FilledButton.tonal( 41 | onPressed: c.pasteAddress, 42 | child: Text('pasteAddress'.tr), 43 | ), 44 | ), 45 | const SizedBox(width: 12), 46 | Expanded( 47 | flex: 1, 48 | child: FilledButton.tonal( 49 | onPressed: c.resolveAddress, 50 | child: Text('resloveAddress'.tr), 51 | ), 52 | ), 53 | ], 54 | ), 55 | ), 56 | AnimatedSwitcher( 57 | duration: const Duration(milliseconds: 300), 58 | transitionBuilder: (child, animation) { 59 | return FadeTransition( 60 | opacity: animation, 61 | child: child, 62 | ); 63 | }, 64 | child: Obx(() { 65 | if (c.isResolved.value && c.feed != null) { 66 | return GestureDetector( 67 | onTap: () async { 68 | final bool isExist = await c.isExists(); 69 | if (isExist) { 70 | Fluttertoast.showToast( 71 | msg: 'feedAlreadyExists'.tr, 72 | ); 73 | return; 74 | } 75 | Get.toNamed('/editFeed', arguments: c.feed)! 76 | .then((_) => Get.back()); 77 | }, 78 | child: Container( 79 | width: double.maxFinite, 80 | padding: const EdgeInsets.all(12), 81 | margin: const EdgeInsets.symmetric(horizontal: 18), 82 | decoration: BoxDecoration( 83 | color: Get.theme.colorScheme.secondaryContainer, 84 | borderRadius: BorderRadius.circular(12), 85 | ), 86 | child: Column( 87 | crossAxisAlignment: CrossAxisAlignment.start, 88 | children: [ 89 | Text( 90 | c.feed!.title, 91 | style: const TextStyle(fontSize: 20), 92 | ), 93 | const SizedBox(height: 4), 94 | Text(c.feed!.description), 95 | ], 96 | ), 97 | ), 98 | ); 99 | } 100 | return const SizedBox.shrink(); 101 | }), 102 | ), 103 | ], 104 | ), 105 | ], 106 | ), 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/ui/views/edit_feed/edit_feed_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/models/feed.dart'; 4 | import 'package:meread/ui/viewmodels/edit_feed/edit_feed_controller.dart'; 5 | 6 | class EditFeedView extends StatelessWidget { 7 | const EditFeedView({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | Feed feed = Get.arguments; 12 | final c = Get.put(EditFeedCntroller()); 13 | c.initFeed(feed); 14 | final ColorScheme colorScheme = Get.theme.colorScheme; 15 | return Scaffold( 16 | body: CustomScrollView( 17 | physics: const BouncingScrollPhysics(), 18 | slivers: [ 19 | SliverAppBar.large( 20 | title: Text('editFeed'.tr), 21 | ), 22 | SliverList.list( 23 | children: [ 24 | Padding( 25 | padding: const EdgeInsets.symmetric(horizontal: 18), 26 | child: Text( 27 | 'feedAddress'.tr, 28 | style: TextStyle(color: colorScheme.primary), 29 | ), 30 | ), 31 | Padding( 32 | padding: const EdgeInsets.symmetric(horizontal: 18), 33 | child: TextField( 34 | controller: TextEditingController(text: feed.url), 35 | enabled: false, 36 | ), 37 | ), 38 | const SizedBox(height: 18), 39 | Padding( 40 | padding: const EdgeInsets.symmetric(horizontal: 18), 41 | child: Text( 42 | 'feedName'.tr, 43 | style: TextStyle(color: colorScheme.primary), 44 | ), 45 | ), 46 | Padding( 47 | padding: const EdgeInsets.symmetric(horizontal: 18), 48 | child: TextField( 49 | controller: c.titleController, 50 | ), 51 | ), 52 | const SizedBox(height: 18), 53 | Padding( 54 | padding: const EdgeInsets.symmetric(horizontal: 18), 55 | child: Text( 56 | 'feedCategory'.tr, 57 | style: TextStyle(color: colorScheme.primary), 58 | ), 59 | ), 60 | Padding( 61 | padding: const EdgeInsets.symmetric(horizontal: 18), 62 | child: TextField( 63 | controller: c.categoryController, 64 | ), 65 | ), 66 | const SizedBox(height: 18), 67 | Obx( 68 | () => SwitchListTile( 69 | value: c.fullText.value, 70 | onChanged: (value) => c.updateFullText(value), 71 | title: Text('fullText'.tr), 72 | subtitle: Text('fullTextInfo'.tr), 73 | ), 74 | ), 75 | const SizedBox(height: 18), 76 | Padding( 77 | padding: const EdgeInsets.fromLTRB(18, 0, 18, 8), 78 | child: Text( 79 | 'openType'.tr, 80 | style: TextStyle(color: colorScheme.primary), 81 | ), 82 | ), 83 | for (int i = 0; i < 3; i++) 84 | RadioListTile( 85 | value: i, 86 | groupValue: c.feed?.openType ?? 0, 87 | title: Text([ 88 | 'openInApp'.tr, 89 | 'openInAppTab'.tr, 90 | 'openInBrowser'.tr 91 | ][i]), 92 | onChanged: (value) { 93 | if (value != null) c.updateOpenType(value); 94 | }, 95 | ), 96 | Padding( 97 | padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), 98 | child: FilledButton.tonal( 99 | onPressed: c.saveFeed, 100 | child: Text('saveFeed'.tr), 101 | ), 102 | ), 103 | Padding( 104 | padding: const EdgeInsets.fromLTRB(12, 6, 12, 48), 105 | child: FilledButton.tonal( 106 | onPressed: c.deleteFeed, 107 | child: Text('deleteFeed'.tr), 108 | ), 109 | ), 110 | ], 111 | ), 112 | ], 113 | ), 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/ui/views/home_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_swipe_action_cell/flutter_swipe_action_cell.dart'; 3 | import 'package:get/get.dart'; 4 | import 'package:meread/helpers/isar_helper.dart'; 5 | import 'package:meread/helpers/prefs_helper.dart'; 6 | import 'package:meread/models/category.dart'; 7 | import 'package:meread/models/post.dart'; 8 | import 'package:meread/ui/viewmodels/home_controller.dart'; 9 | import 'package:meread/ui/widgets/feed_panel.dart'; 10 | import 'package:meread/ui/widgets/post_card.dart'; 11 | 12 | class HomeView extends StatefulWidget { 13 | const HomeView({super.key}); 14 | 15 | @override 16 | State createState() => _HomeViewState(); 17 | } 18 | 19 | class _HomeViewState extends State { 20 | final GlobalKey _refreshKey = GlobalKey(); 21 | final c = Get.put(HomeController()); 22 | 23 | @override 24 | void initState() { 25 | super.initState(); 26 | if (PrefsHelper.refreshOnStartup) { 27 | WidgetsBinding.instance.addPostFrameCallback((_) { 28 | _refreshKey.currentState?.show(); 29 | }); 30 | } 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | return Scaffold( 36 | appBar: AppBar( 37 | title: Obx(() => Text( 38 | c.appBarTitle.value, 39 | )), 40 | centerTitle: false, 41 | actions: [ 42 | IconButton( 43 | onPressed: c.filterUnread, 44 | icon: Obx(() => c.onlyUnread.value 45 | ? const Icon(Icons.radio_button_checked) 46 | : const Icon(Icons.radio_button_unchecked)), 47 | ), 48 | IconButton( 49 | onPressed: c.filterFavorite, 50 | icon: Obx(() => c.onlyFavorite.value 51 | ? const Icon(Icons.bookmark) 52 | : const Icon(Icons.bookmark_border_outlined)), 53 | ), 54 | PopupMenuButton( 55 | elevation: 1, 56 | position: PopupMenuPosition.under, 57 | itemBuilder: (BuildContext context) { 58 | return [ 59 | PopupMenuItem( 60 | onTap: c.markAllRead, 61 | padding: const EdgeInsets.symmetric(horizontal: 16), 62 | child: Row( 63 | crossAxisAlignment: CrossAxisAlignment.center, 64 | children: [ 65 | const Icon(Icons.done_all_outlined, size: 20), 66 | const SizedBox(width: 10), 67 | Text('markAllAsRead'.tr), 68 | ], 69 | ), 70 | ), 71 | const PopupMenuDivider(), 72 | PopupMenuItem( 73 | child: SearchAnchor( 74 | isFullScreen: true, 75 | searchController: c.searchController, 76 | builder: (context, controller) { 77 | return Row( 78 | crossAxisAlignment: CrossAxisAlignment.center, 79 | children: [ 80 | const Icon(Icons.search_outlined, size: 20), 81 | const SizedBox(width: 10), 82 | Text('fullTextSearch'.tr), 83 | ], 84 | ); 85 | }, 86 | suggestionsBuilder: (BuildContext context, 87 | SearchController controller) async { 88 | List results = 89 | await IsarHelper.search(controller.text); 90 | return results 91 | .map((e) => Padding( 92 | padding: const EdgeInsets.symmetric( 93 | horizontal: 12, vertical: 4), 94 | child: PostCard(post: e), 95 | )) 96 | .toList(); 97 | }, 98 | ), 99 | ), 100 | PopupMenuItem( 101 | onTap: c.toAddFeed, 102 | child: Row( 103 | crossAxisAlignment: CrossAxisAlignment.center, 104 | children: [ 105 | const Icon(Icons.add_outlined, size: 20), 106 | const SizedBox(width: 10), 107 | Text('addFeed'.tr), 108 | ], 109 | ), 110 | ), 111 | const PopupMenuDivider(), 112 | PopupMenuItem( 113 | onTap: c.toSetting, 114 | child: Row( 115 | crossAxisAlignment: CrossAxisAlignment.center, 116 | children: [ 117 | const Icon(Icons.settings_outlined, size: 20), 118 | const SizedBox(width: 10), 119 | Text('moreSetting'.tr), 120 | ], 121 | ), 122 | ), 123 | ]; 124 | }, 125 | ), 126 | ], 127 | ), 128 | body: SafeArea( 129 | child: RefreshIndicator( 130 | key: _refreshKey, 131 | onRefresh: c.refreshPosts, 132 | child: Obx( 133 | () => ListView.separated( 134 | physics: c.postList.isEmpty 135 | ? const AlwaysScrollableScrollPhysics() 136 | : const BouncingScrollPhysics(), 137 | padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), 138 | itemBuilder: (context, index) { 139 | return SwipeActionCell( 140 | key: ObjectKey(c.postList[index]), 141 | trailingActions: [ 142 | SwipeAction( 143 | color: Colors.transparent, 144 | content: Container( 145 | width: 50, 146 | height: 50, 147 | decoration: BoxDecoration( 148 | borderRadius: BorderRadius.circular(25), 149 | color: 150 | Theme.of(context).colorScheme.secondaryContainer, 151 | ), 152 | child: Icon( 153 | Icons.done_outline_rounded, 154 | color: Theme.of(context) 155 | .colorScheme 156 | .onSecondaryContainer, 157 | ), 158 | ), 159 | onTap: (handler) async { 160 | c.updateReadStatus(c.postList[index]); 161 | c.getUnreadCount(); 162 | await handler(false); 163 | }, 164 | ), 165 | ], 166 | child: InkWell( 167 | onTap: () { 168 | Get.toNamed('/post', arguments: c.postList[index])! 169 | .then((_) { 170 | c.getPosts(); 171 | c.getUnreadCount(); 172 | }); 173 | }, 174 | child: PostCard(post: c.postList[index]), 175 | ), 176 | ); 177 | }, 178 | separatorBuilder: (context, index) => const SizedBox(height: 8), 179 | itemCount: c.postList.length, 180 | ), 181 | ), 182 | ), 183 | ), 184 | drawerEdgeDragWidth: Get.width * 0.3, 185 | drawer: Drawer( 186 | child: SafeArea( 187 | child: Obx( 188 | () => ListView( 189 | physics: const BouncingScrollPhysics(), 190 | padding: const EdgeInsets.symmetric(vertical: 12), 191 | children: [ 192 | Padding( 193 | padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 194 | child: ListTile( 195 | title: Text('allFeeds'.tr), 196 | onTap: c.focusAllFeeds, 197 | tileColor: Theme.of(context) 198 | .colorScheme 199 | .secondaryContainer 200 | .withAlpha(100), 201 | visualDensity: VisualDensity.compact, 202 | shape: RoundedRectangleBorder( 203 | borderRadius: BorderRadius.circular(24), 204 | ), 205 | ), 206 | ), 207 | for (Category category in c.categorys) 208 | FeedPanel( 209 | category: category, 210 | categoryOnTap: () => c.focusCategory(category), 211 | feedOnTap: (feed) => c.focusFeed(feed), 212 | ), 213 | ], 214 | ), 215 | ), 216 | ), 217 | ), 218 | ); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /lib/ui/views/post/post_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; 4 | import 'package:get/get.dart'; 5 | import 'package:meread/helpers/prefs_helper.dart'; 6 | import 'package:meread/models/post.dart'; 7 | import 'package:meread/ui/viewmodels/post/post_controller.dart'; 8 | import 'package:share_plus/share_plus.dart'; 9 | import 'package:url_launcher/url_launcher_string.dart'; 10 | 11 | class PostView extends StatelessWidget { 12 | const PostView({super.key}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final Post p = Get.arguments; 17 | final c = Get.put(PostController(p)); 18 | return Scaffold( 19 | appBar: AppBar( 20 | title: Text(c.post.value.feed.value?.title ?? ''), 21 | actions: [ 22 | IconButton( 23 | onPressed: c.openInBrowser, 24 | icon: const Icon(Icons.open_in_browser_outlined), 25 | ), 26 | IconButton( 27 | onPressed: c.getFullText, 28 | icon: const Icon(Icons.article_outlined), 29 | ), 30 | PopupMenuButton( 31 | elevation: 1, 32 | position: PopupMenuPosition.under, 33 | itemBuilder: (BuildContext context) { 34 | return [ 35 | PopupMenuItem( 36 | padding: const EdgeInsets.symmetric(horizontal: 16), 37 | onTap: c.markAsUnread, 38 | child: Row( 39 | mainAxisSize: MainAxisSize.min, 40 | children: [ 41 | const Icon(Icons.visibility_off_outlined, size: 20), 42 | const SizedBox(width: 10), 43 | Text('markAsUnread'.tr), 44 | ], 45 | ), 46 | ), 47 | PopupMenuItem( 48 | onTap: c.changeFavorite, 49 | child: Row( 50 | mainAxisSize: MainAxisSize.min, 51 | children: [ 52 | const Icon(Icons.bookmark_border_outlined, size: 20), 53 | const SizedBox(width: 10), 54 | Obx(() => Text( 55 | c.post.value.favorite 56 | ? 'cancelFavorite'.tr 57 | : 'markAsFavorite'.tr, 58 | )), 59 | ], 60 | ), 61 | ), 62 | const PopupMenuDivider(height: 0), 63 | PopupMenuItem( 64 | onTap: () { 65 | Clipboard.setData(ClipboardData(text: c.post.value.link)); 66 | }, 67 | child: Row( 68 | mainAxisSize: MainAxisSize.min, 69 | children: [ 70 | const Icon(Icons.link_outlined, size: 20), 71 | const SizedBox(width: 10), 72 | Text('copyLink'.tr), 73 | ], 74 | ), 75 | ), 76 | PopupMenuItem( 77 | onTap: () { 78 | Share.share( 79 | '${c.post.value.title}\n${c.post.value.link}', 80 | subject: c.post.value.title, 81 | ); 82 | }, 83 | child: Row( 84 | mainAxisSize: MainAxisSize.min, 85 | children: [ 86 | const Icon(Icons.share_outlined, size: 20), 87 | const SizedBox(width: 10), 88 | Text('sharePost'.tr), 89 | ], 90 | ), 91 | ), 92 | ]; 93 | }, 94 | ), 95 | ], 96 | ), 97 | body: SafeArea( 98 | child: SelectionArea( 99 | child: SingleChildScrollView( 100 | physics: const BouncingScrollPhysics(), 101 | padding: EdgeInsets.symmetric( 102 | vertical: 18, 103 | horizontal: PrefsHelper.readPagePadding.toDouble(), 104 | ), 105 | child: Obx( 106 | () => c.fullTexting.value 107 | ? Center( 108 | child: SizedBox( 109 | height: 200, 110 | width: 200, 111 | child: Column( 112 | mainAxisAlignment: MainAxisAlignment.center, 113 | crossAxisAlignment: CrossAxisAlignment.center, 114 | children: [ 115 | const CircularProgressIndicator(), 116 | const SizedBox(height: 12), 117 | Text('fullTextLoading'.tr), 118 | ], 119 | ), 120 | ), 121 | ) 122 | : HtmlWidget( 123 | '

${c.post.value.title}

${c.post.value.content}', 124 | textStyle: TextStyle( 125 | fontSize: PrefsHelper.readFontSize.toDouble(), 126 | height: PrefsHelper.readLineHeight, 127 | ), 128 | onTapUrl: (url) { 129 | launchUrlString( 130 | url, 131 | mode: LaunchMode.externalApplication, 132 | ); 133 | return true; 134 | }, 135 | onLoadingBuilder: (context, element, progress) { 136 | return Center( 137 | child: SizedBox( 138 | height: 200, 139 | width: 200, 140 | child: Column( 141 | mainAxisAlignment: MainAxisAlignment.center, 142 | crossAxisAlignment: CrossAxisAlignment.center, 143 | children: [ 144 | const CircularProgressIndicator(), 145 | const SizedBox(height: 12), 146 | Text('loading'.tr), 147 | ], 148 | ), 149 | ), 150 | ); 151 | }, 152 | customStylesBuilder: (element) { 153 | if (element.localName == 'h1') { 154 | return { 155 | 'font-size': '1.8em', 156 | 'line-height': '1.3em', 157 | 'text-align': PrefsHelper.readTextAlign, 158 | }; 159 | } 160 | return { 161 | 'text-align': PrefsHelper.readTextAlign, 162 | }; 163 | }, 164 | customWidgetBuilder: (element) { 165 | if (element.localName == 'figure') { 166 | if (element.children.length == 1 && 167 | element.children[0].localName == 'img') { 168 | String? imgUrl = 169 | element.children[0].attributes['src']; 170 | if (imgUrl != null) { 171 | return ImgForRead( 172 | url: element.children[0].attributes['src']!, 173 | ); 174 | } 175 | } 176 | if (element.children.length == 2 && 177 | element.children[0].localName == 'img' && 178 | element.children[1].localName == 'figcaption') { 179 | String? imgUrl = 180 | element.children[0].attributes['src']; 181 | if (imgUrl != null) { 182 | return Column( 183 | children: [ 184 | ImgForRead( 185 | url: element.children[0].attributes['src']!, 186 | ), 187 | Text( 188 | element.children[1].text, 189 | style: TextStyle( 190 | fontSize: PrefsHelper.readFontSize - 4, 191 | color: 192 | Theme.of(context).colorScheme.outline, 193 | height: PrefsHelper.readLineHeight, 194 | ), 195 | ), 196 | ], 197 | ); 198 | } 199 | } 200 | } 201 | if (element.localName == 'img') { 202 | if (element.attributes['src'] != null) { 203 | return ImgForRead( 204 | url: element.attributes['src']!, 205 | ); 206 | } 207 | } 208 | return null; 209 | }, 210 | ), 211 | ), 212 | ), 213 | ), 214 | ), 215 | ); 216 | } 217 | } 218 | 219 | class ImgForRead extends StatelessWidget { 220 | const ImgForRead({super.key, required this.url}); 221 | 222 | final String? url; 223 | 224 | @override 225 | Widget build(BuildContext context) { 226 | if (url == null) { 227 | return const SizedBox.shrink(); 228 | } 229 | return Image.network( 230 | url!, 231 | fit: BoxFit.cover, 232 | width: double.infinity, 233 | loadingBuilder: (context, child, loadingProgress) { 234 | if (loadingProgress == null) { 235 | return child; 236 | } 237 | return Center( 238 | child: Container( 239 | height: 200, 240 | width: double.infinity, 241 | decoration: BoxDecoration( 242 | borderRadius: BorderRadius.circular(8), 243 | ), 244 | child: Column( 245 | mainAxisAlignment: MainAxisAlignment.center, 246 | crossAxisAlignment: CrossAxisAlignment.center, 247 | children: [ 248 | const CircularProgressIndicator( 249 | strokeWidth: 3, 250 | ), 251 | const SizedBox(height: 8), 252 | Text('imageLoading'.tr), 253 | ], 254 | ), 255 | ), 256 | ); 257 | }, 258 | errorBuilder: (context, error, stackTrace) { 259 | return Center( 260 | child: Container( 261 | height: 200, 262 | width: double.infinity, 263 | decoration: BoxDecoration( 264 | borderRadius: BorderRadius.circular(8), 265 | ), 266 | child: Column( 267 | mainAxisAlignment: MainAxisAlignment.center, 268 | crossAxisAlignment: CrossAxisAlignment.center, 269 | children: [ 270 | const Icon(Icons.broken_image_outlined), 271 | const SizedBox(height: 8), 272 | Text('imageLoadError'.tr), 273 | ], 274 | ), 275 | ), 276 | ); 277 | }, 278 | ); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /lib/ui/views/setting/about/about_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/constant_helper.dart'; 4 | import 'package:url_launcher/url_launcher_string.dart'; 5 | 6 | class AboutView extends StatelessWidget { 7 | const AboutView({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Scaffold( 12 | appBar: AppBar(), 13 | body: SafeArea( 14 | child: Center( 15 | child: Column( 16 | mainAxisAlignment: MainAxisAlignment.start, 17 | crossAxisAlignment: CrossAxisAlignment.center, 18 | children: [ 19 | Container( 20 | margin: const EdgeInsets.symmetric(vertical: 24), 21 | decoration: BoxDecoration( 22 | borderRadius: BorderRadius.circular(720), 23 | boxShadow: [ 24 | BoxShadow( 25 | color: 26 | Theme.of(context).colorScheme.primary.withAlpha(10), 27 | spreadRadius: 10, 28 | blurRadius: 10, 29 | offset: const Offset(0, 3), // changes position of shadow 30 | ), 31 | ], 32 | ), 33 | clipBehavior: Clip.antiAlias, 34 | child: Image.asset( 35 | 'assets/meread.png', 36 | height: Get.mediaQuery.size.width / 3, 37 | ), 38 | ), 39 | Text( 40 | 'MeRead'.tr, 41 | style: const TextStyle(fontSize: 36), 42 | ), 43 | Padding( 44 | padding: const EdgeInsets.fromLTRB(18, 4, 18, 4), 45 | child: Text( 46 | 'appInfo'.tr, 47 | textAlign: TextAlign.center, 48 | style: const TextStyle(fontSize: 20), 49 | ), 50 | ), 51 | Text( 52 | 'Version ${ConstantHelp.appVersion}', 53 | textAlign: TextAlign.center, 54 | style: const TextStyle(fontSize: 16), 55 | ), 56 | Padding( 57 | padding: 58 | const EdgeInsets.symmetric(horizontal: 88, vertical: 88), 59 | child: Row( 60 | mainAxisAlignment: MainAxisAlignment.center, 61 | crossAxisAlignment: CrossAxisAlignment.center, 62 | children: [ 63 | Column( 64 | mainAxisSize: MainAxisSize.min, 65 | mainAxisAlignment: MainAxisAlignment.center, 66 | crossAxisAlignment: CrossAxisAlignment.center, 67 | children: [ 68 | IconButton.outlined( 69 | onPressed: () { 70 | launchUrlString(ConstantHelp.githubUrl); 71 | }, 72 | icon: const Icon(Icons.code_rounded), 73 | iconSize: 36, 74 | ), 75 | const SizedBox(height: 6), 76 | Text('openSource'.tr), 77 | ], 78 | ), 79 | const SizedBox(width: 36), 80 | Column( 81 | mainAxisSize: MainAxisSize.min, 82 | mainAxisAlignment: MainAxisAlignment.center, 83 | crossAxisAlignment: CrossAxisAlignment.center, 84 | children: [ 85 | IconButton.outlined( 86 | onPressed: () { 87 | launchUrlString(ConstantHelp.authorSite); 88 | }, 89 | icon: const Icon(Icons.person_rounded), 90 | iconSize: 36, 91 | ), 92 | const SizedBox(height: 6), 93 | Text('contactAuthor'.tr), 94 | ], 95 | ), 96 | ], 97 | ), 98 | ), 99 | const Spacer(), 100 | Text( 101 | 'Released under the GUN GPL-3.0 License\n' 102 | 'Copyright © 2022-${DateTime.now().year} liuyuxin', 103 | textAlign: TextAlign.center, 104 | style: const TextStyle(fontSize: 14), 105 | ), 106 | const SizedBox(height: 4), 107 | ], 108 | ), 109 | ), 110 | ), 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/ui/views/setting/data_manage/data_manage_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/helpers/opml_helper.dart'; 4 | 5 | class DataManageView extends StatelessWidget { 6 | const DataManageView({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Scaffold( 11 | body: CustomScrollView( 12 | physics: const BouncingScrollPhysics(), 13 | slivers: [ 14 | SliverAppBar.large( 15 | title: Text('dataManage'.tr), 16 | ), 17 | SliverList.list( 18 | children: [ 19 | ListTile( 20 | leading: const Icon(Icons.download_rounded), 21 | title: Text('importOpml'.tr), 22 | subtitle: Text( 23 | 'importOpmlInfo'.tr, 24 | style: TextStyle(color: Get.theme.colorScheme.outline), 25 | ), 26 | onTap: OpmlHelper.importOpml, 27 | ), 28 | ListTile( 29 | leading: const Icon(Icons.publish_rounded), 30 | title: Text('exportOpml'.tr), 31 | subtitle: Text( 32 | 'exportOpmlInfo'.tr, 33 | style: TextStyle(color: Get.theme.colorScheme.outline), 34 | ), 35 | onTap: OpmlHelper.exportOpml, 36 | ), 37 | ], 38 | ), 39 | ], 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/ui/views/setting/display/display_setting_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/ui/viewmodels/settings/display/display_setting_controller.dart'; 4 | 5 | class DisplaySettingView extends StatelessWidget { 6 | const DisplaySettingView({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | final c = Get.put(DisplaySettingController()); 11 | return Scaffold( 12 | body: CustomScrollView( 13 | physics: const BouncingScrollPhysics(), 14 | slivers: [ 15 | SliverAppBar.large( 16 | title: Text('displaySetting'.tr), 17 | ), 18 | SliverList.list( 19 | children: [ 20 | ListTile( 21 | leading: const Icon(Icons.dark_mode_rounded), 22 | title: Text('darkMode'.tr), 23 | subtitle: Obx(() => Text( 24 | [ 25 | 'followSystem'.tr, 26 | 'close'.tr, 27 | 'open'.tr, 28 | ][c.themeMode.value], 29 | style: TextStyle(color: Get.theme.colorScheme.outline), 30 | )), 31 | trailing: Row( 32 | mainAxisSize: MainAxisSize.min, 33 | mainAxisAlignment: MainAxisAlignment.center, 34 | crossAxisAlignment: CrossAxisAlignment.center, 35 | children: [ 36 | const VerticalDivider( 37 | indent: 12, 38 | endIndent: 12, 39 | width: 24, 40 | ), 41 | Obx(() => Switch( 42 | value: c.themeMode.value == 2, 43 | onChanged: (value) => 44 | c.changeThemeMode(value ? 2 : 1), 45 | )), 46 | ], 47 | ), 48 | onTap: () { 49 | int mode = c.themeMode.value; 50 | Get.dialog(AlertDialog( 51 | icon: const Icon(Icons.dark_mode_rounded), 52 | title: Text('darkMode'.tr), 53 | content: StatefulBuilder( 54 | builder: (context, setState) { 55 | return Column( 56 | mainAxisSize: MainAxisSize.min, 57 | mainAxisAlignment: MainAxisAlignment.center, 58 | crossAxisAlignment: CrossAxisAlignment.center, 59 | children: [ 60 | for (int i = 0; i < 3; i++) 61 | RadioListTile( 62 | value: i, 63 | groupValue: mode, 64 | title: Text( 65 | [ 66 | 'followSystem'.tr, 67 | 'close'.tr, 68 | 'open'.tr, 69 | ][i], 70 | ), 71 | onChanged: (value) { 72 | if (value != null && value != mode) { 73 | setState(() { 74 | mode = value; 75 | }); 76 | } 77 | }, 78 | visualDensity: VisualDensity.compact, 79 | shape: RoundedRectangleBorder( 80 | borderRadius: BorderRadius.circular(80), 81 | ), 82 | ), 83 | ], 84 | ); 85 | }, 86 | ), 87 | actions: [ 88 | TextButton( 89 | onPressed: () { 90 | Get.back(); 91 | }, 92 | child: Text('cancel'.tr), 93 | ), 94 | TextButton( 95 | onPressed: () { 96 | c.changeThemeMode(mode); 97 | Get.back(); 98 | }, 99 | child: Text('confirm'.tr), 100 | ), 101 | ], 102 | )); 103 | }, 104 | ), 105 | Obx(() => SwitchListTile( 106 | value: c.enableDynamicColor.value, 107 | secondary: const Icon(Icons.color_lens_rounded), 108 | title: Text('dynamicColor'.tr), 109 | subtitle: Text( 110 | 'dynamicColorInfo'.tr, 111 | style: TextStyle(color: Get.theme.colorScheme.outline), 112 | ), 113 | onChanged: (value) => c.changeEnableDynamicColor(value), 114 | )), 115 | ListTile( 116 | leading: const Icon(Icons.font_download_rounded), 117 | title: Text('globalFont'.tr), 118 | subtitle: Obx(() => Text( 119 | c.globalFont.value == 'system' 120 | ? 'defaultFont'.tr 121 | : c.globalFont.value.split('.').first, 122 | style: TextStyle(color: Get.theme.colorScheme.outline), 123 | )), 124 | onTap: () async { 125 | String selestedFont = c.globalFont.value; 126 | await c.refreshFontList(); 127 | Get.dialog( 128 | StatefulBuilder(builder: (context, setState) { 129 | return AlertDialog( 130 | icon: const Icon(Icons.font_download_rounded), 131 | title: Text('globalFont'.tr), 132 | content: Column( 133 | mainAxisSize: MainAxisSize.min, 134 | mainAxisAlignment: MainAxisAlignment.center, 135 | crossAxisAlignment: CrossAxisAlignment.center, 136 | children: [ 137 | for (String font in c.fontList) 138 | RadioListTile( 139 | value: font, 140 | groupValue: selestedFont, 141 | title: Text( 142 | font == 'system' 143 | ? 'defaultFont'.tr 144 | : font.split('.').first, 145 | style: TextStyle(fontFamily: font), 146 | ), 147 | onChanged: (value) { 148 | if (value != null && 149 | value != selestedFont) { 150 | setState(() { 151 | selestedFont = value; 152 | }); 153 | } 154 | }, 155 | secondary: font == 'system' 156 | ? null 157 | : IconButton( 158 | icon: const Icon( 159 | Icons.remove_circle_rounded), 160 | onPressed: () async { 161 | await c.deleteFont(font); 162 | setState(() {}); 163 | }, 164 | ), 165 | visualDensity: VisualDensity.compact, 166 | shape: RoundedRectangleBorder( 167 | borderRadius: BorderRadius.circular(80), 168 | ), 169 | ), 170 | ], 171 | ), 172 | actions: [ 173 | Row( 174 | mainAxisSize: MainAxisSize.min, 175 | mainAxisAlignment: MainAxisAlignment.center, 176 | crossAxisAlignment: CrossAxisAlignment.center, 177 | children: [ 178 | TextButton( 179 | onPressed: () { 180 | c.importFont().then((_) { 181 | setState(() {}); 182 | }); 183 | }, 184 | child: Text('importFont'.tr), 185 | ), 186 | const Spacer(), 187 | TextButton( 188 | onPressed: () { 189 | Get.back(); 190 | }, 191 | child: Text('cancel'.tr), 192 | ), 193 | TextButton( 194 | onPressed: () { 195 | c.changeGlobalFont(selestedFont); 196 | Get.back(); 197 | }, 198 | child: Text('confirm'.tr), 199 | ), 200 | ], 201 | ), 202 | ]); 203 | }), 204 | ); 205 | }, 206 | ), 207 | ListTile( 208 | leading: const Icon(Icons.animation_rounded), 209 | title: Text('animationEffect'.tr), 210 | subtitle: Obx(() => Text( 211 | { 212 | 'cupertino': 'smoothScrolling'.tr, 213 | 'fade': 'fadeInAndOut'.tr, 214 | }[c.transition.value] ?? 215 | 'smoothScrolling'.tr, 216 | style: TextStyle(color: Get.theme.colorScheme.outline), 217 | )), 218 | onTap: () { 219 | String selectedTransition = c.transition.value; 220 | final Map transitions = { 221 | 'cupertino': 'smoothScrolling'.tr, 222 | 'fade': 'fadeInAndOut'.tr, 223 | }; 224 | Get.dialog( 225 | StatefulBuilder( 226 | builder: (context, setState) { 227 | return AlertDialog( 228 | icon: const Icon(Icons.animation_rounded), 229 | title: Text('animationEffect'.tr), 230 | content: Column( 231 | mainAxisSize: MainAxisSize.min, 232 | mainAxisAlignment: MainAxisAlignment.center, 233 | crossAxisAlignment: CrossAxisAlignment.start, 234 | children: [ 235 | for (String key in transitions.keys) 236 | RadioListTile( 237 | value: key, 238 | groupValue: selectedTransition, 239 | title: Text(transitions[key] ?? ''), 240 | onChanged: (value) { 241 | if (value != null && 242 | value != selectedTransition) { 243 | setState(() { 244 | selectedTransition = value; 245 | }); 246 | } 247 | }, 248 | visualDensity: VisualDensity.compact, 249 | shape: RoundedRectangleBorder( 250 | borderRadius: BorderRadius.circular(80), 251 | ), 252 | ), 253 | const SizedBox(height: 8), 254 | Text('* ${'animationEffectInfo'.tr}'), 255 | ], 256 | ), 257 | actions: [ 258 | TextButton( 259 | onPressed: () { 260 | Get.back(); 261 | }, 262 | child: Text('cancel'.tr), 263 | ), 264 | TextButton( 265 | onPressed: () { 266 | c.changeTransition(selectedTransition); 267 | Get.back(); 268 | }, 269 | child: Text('confirm'.tr), 270 | ), 271 | ], 272 | ); 273 | }, 274 | ), 275 | ); 276 | }, 277 | ), 278 | ListTile( 279 | leading: const Icon(Icons.text_fields_rounded), 280 | title: Text('textScale'.tr), 281 | subtitle: Obx(() => Text( 282 | 'textScaleFactor'.tr + 283 | c.textScaleFactor.value.toStringAsFixed(1), 284 | style: TextStyle(color: Get.theme.colorScheme.outline), 285 | )), 286 | onTap: () { 287 | double factor = c.textScaleFactor.value; 288 | Get.dialog(AlertDialog( 289 | icon: const Icon(Icons.text_fields_rounded), 290 | title: Text('textScale'.tr), 291 | content: StatefulBuilder( 292 | builder: (context, setState) { 293 | return SizedBox( 294 | height: 64, 295 | child: Slider( 296 | value: factor, 297 | min: 0.8, 298 | max: 2.0, 299 | divisions: 12, 300 | label: factor.toStringAsFixed(1), 301 | onChanged: (value) { 302 | setState(() { 303 | factor = value; 304 | }); 305 | }, 306 | ), 307 | ); 308 | }, 309 | ), 310 | actions: [ 311 | TextButton( 312 | onPressed: () { 313 | Get.back(); 314 | }, 315 | child: Text('cancel'.tr), 316 | ), 317 | TextButton( 318 | onPressed: () { 319 | c.changeTextScaleFactor(factor); 320 | Get.back(); 321 | }, 322 | child: Text('confirm'.tr), 323 | ), 324 | ], 325 | )); 326 | }), 327 | ListTile( 328 | leading: const Icon(Icons.language_rounded), 329 | title: Text('language'.tr), 330 | subtitle: Obx(() => Text( 331 | [ 332 | 'systemLanguage'.tr, 333 | 'zh_CN'.tr, 334 | 'en_US'.tr, 335 | ][c.languageList.indexOf(c.language.value)], 336 | style: TextStyle(color: Get.theme.colorScheme.outline), 337 | )), 338 | onTap: () { 339 | String selectedLanguage = c.language.value; 340 | Get.dialog(AlertDialog( 341 | icon: const Icon(Icons.language_rounded), 342 | title: Text('language'.tr), 343 | content: StatefulBuilder( 344 | builder: (context, setState) { 345 | return Column( 346 | mainAxisSize: MainAxisSize.min, 347 | mainAxisAlignment: MainAxisAlignment.center, 348 | crossAxisAlignment: CrossAxisAlignment.center, 349 | children: [ 350 | for (String language in c.languageList) 351 | RadioListTile( 352 | value: language, 353 | groupValue: selectedLanguage, 354 | title: Text( 355 | [ 356 | 'systemLanguage'.tr, 357 | 'zh_CN'.tr, 358 | 'en_US'.tr, 359 | ][c.languageList.indexOf(language)], 360 | ), 361 | onChanged: (value) { 362 | if (value != null && 363 | value != selectedLanguage) { 364 | setState(() { 365 | selectedLanguage = value; 366 | }); 367 | } 368 | }, 369 | visualDensity: VisualDensity.compact, 370 | shape: RoundedRectangleBorder( 371 | borderRadius: BorderRadius.circular(80), 372 | ), 373 | ), 374 | ], 375 | ); 376 | }, 377 | ), 378 | actions: [ 379 | TextButton( 380 | onPressed: () { 381 | Get.back(); 382 | }, 383 | child: Text('cancel'.tr), 384 | ), 385 | TextButton( 386 | onPressed: () { 387 | c.changeLanguage(selectedLanguage); 388 | Get.back(); 389 | }, 390 | child: Text('confirm'.tr), 391 | ), 392 | ], 393 | )); 394 | }, 395 | ), 396 | ], 397 | ), 398 | ], 399 | ), 400 | ); 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /lib/ui/views/setting/read/read_setting_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/ui/viewmodels/settings/read/read_controller.dart'; 4 | 5 | class ReadSettingView extends StatelessWidget { 6 | const ReadSettingView({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | final c = Get.put(ReadController()); 11 | final Map alignMap = { 12 | 'left': 'leftAlign'.tr, 13 | 'right': 'rightAlign'.tr, 14 | 'center': 'centerAlign'.tr, 15 | 'justify': 'justifyAlign'.tr, 16 | }; 17 | return Scaffold( 18 | body: CustomScrollView( 19 | physics: const BouncingScrollPhysics(), 20 | slivers: [ 21 | SliverAppBar.large( 22 | title: Text('readSetting'.tr), 23 | ), 24 | SliverList.list( 25 | children: [ 26 | ListTile( 27 | leading: const Icon(Icons.text_fields_rounded), 28 | title: Text('fontSize'.tr), 29 | subtitle: Obx(() => Text('${c.fontSize.value}')), 30 | onTap: () { 31 | int size = c.fontSize.value; 32 | Get.dialog(AlertDialog( 33 | icon: const Icon(Icons.text_fields_rounded), 34 | title: Text('fontSize'.tr), 35 | content: StatefulBuilder( 36 | builder: (context, setState) { 37 | return SizedBox( 38 | height: 64, 39 | child: Slider( 40 | value: size.toDouble(), 41 | min: 12, 42 | max: 24, 43 | divisions: 12, 44 | label: size.toInt().toString(), 45 | onChanged: (value) { 46 | setState(() { 47 | size = value.toInt(); 48 | }); 49 | }, 50 | ), 51 | ); 52 | }, 53 | ), 54 | actions: [ 55 | TextButton( 56 | onPressed: () { 57 | Get.back(); 58 | }, 59 | child: Text('cancel'.tr), 60 | ), 61 | TextButton( 62 | onPressed: () { 63 | c.changeFontSize(size); 64 | Get.back(); 65 | }, 66 | child: Text('confirm'.tr), 67 | ), 68 | ], 69 | )); 70 | }, 71 | ), 72 | ListTile( 73 | leading: const Icon(Icons.line_weight_rounded), 74 | title: Text('lineHeight'.tr), 75 | subtitle: 76 | Obx(() => Text(c.lineHeight.value.toStringAsFixed(1))), 77 | onTap: () { 78 | double height = c.lineHeight.value; 79 | Get.dialog(AlertDialog( 80 | icon: const Icon(Icons.line_weight_rounded), 81 | title: Text('lineHeight'.tr), 82 | content: StatefulBuilder( 83 | builder: (context, setState) { 84 | return SizedBox( 85 | height: 64, 86 | child: Slider( 87 | value: height, 88 | min: 1.0, 89 | max: 2.0, 90 | divisions: 10, 91 | label: height.toStringAsFixed(1), 92 | onChanged: (value) { 93 | setState(() { 94 | height = value; 95 | }); 96 | }, 97 | ), 98 | ); 99 | }, 100 | ), 101 | actions: [ 102 | TextButton( 103 | onPressed: () { 104 | Get.back(); 105 | }, 106 | child: Text('cancel'.tr), 107 | ), 108 | TextButton( 109 | onPressed: () { 110 | c.changeLineHeight(height); 111 | Get.back(); 112 | }, 113 | child: Text('confirm'.tr), 114 | ), 115 | ], 116 | )); 117 | }, 118 | ), 119 | ListTile( 120 | leading: const Icon(Icons.padding_rounded), 121 | title: Text('pagePadding'.tr), 122 | subtitle: Obx(() => Text('${c.pagePadding.value}')), 123 | onTap: () { 124 | int padding = c.pagePadding.value; 125 | Get.dialog(AlertDialog( 126 | icon: const Icon(Icons.padding_rounded), 127 | title: Text('pagePadding'.tr), 128 | content: StatefulBuilder( 129 | builder: (context, setState) { 130 | return SizedBox( 131 | height: 64, 132 | child: Slider( 133 | value: padding.toDouble(), 134 | min: 0, 135 | max: 40, 136 | divisions: 10, 137 | label: padding.toString(), 138 | onChanged: (value) { 139 | setState(() { 140 | padding = value.toInt(); 141 | }); 142 | }, 143 | ), 144 | ); 145 | }, 146 | ), 147 | actions: [ 148 | TextButton( 149 | onPressed: () { 150 | Get.back(); 151 | }, 152 | child: Text('cancel'.tr), 153 | ), 154 | TextButton( 155 | onPressed: () { 156 | c.changePagePadding(padding); 157 | Get.back(); 158 | }, 159 | child: Text('confirm'.tr), 160 | ), 161 | ], 162 | )); 163 | }, 164 | ), 165 | ListTile( 166 | leading: const Icon(Icons.format_align_left_rounded), 167 | title: Text('textAlign'.tr), 168 | subtitle: Obx( 169 | () => Text(alignMap[c.textAlign.value] ?? 'leftAlign'.tr)), 170 | onTap: () { 171 | String align = c.textAlign.value; 172 | Get.dialog(AlertDialog( 173 | icon: const Icon(Icons.format_align_left_rounded), 174 | title: Text('textAlign'.tr), 175 | content: StatefulBuilder( 176 | builder: (context, setState) { 177 | return Column( 178 | mainAxisSize: MainAxisSize.min, 179 | mainAxisAlignment: MainAxisAlignment.center, 180 | crossAxisAlignment: CrossAxisAlignment.center, 181 | children: [ 182 | for (String i in alignMap.keys) 183 | RadioListTile( 184 | value: i, 185 | groupValue: align, 186 | title: Text( 187 | alignMap[i] ?? '', 188 | ), 189 | onChanged: (value) { 190 | if (value != null) { 191 | setState(() { 192 | align = value; 193 | }); 194 | } 195 | }, 196 | visualDensity: VisualDensity.compact, 197 | shape: RoundedRectangleBorder( 198 | borderRadius: BorderRadius.circular(80), 199 | ), 200 | ), 201 | ], 202 | ); 203 | }, 204 | ), 205 | actions: [ 206 | TextButton( 207 | onPressed: () { 208 | Get.back(); 209 | }, 210 | child: Text('cancel'.tr), 211 | ), 212 | TextButton( 213 | onPressed: () { 214 | c.changeTextAlign(align); 215 | Get.back(); 216 | }, 217 | child: Text('confirm'.tr), 218 | ), 219 | ], 220 | )); 221 | }, 222 | ), 223 | ], 224 | ), 225 | ], 226 | ), 227 | ); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /lib/ui/views/setting/resolve/resolve_setting_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/ui/viewmodels/settings/resolve/resolve_setting_controller.dart'; 4 | 5 | class ResolveSettingView extends StatelessWidget { 6 | const ResolveSettingView({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | final c = Get.put(ResolveSettingController()); 11 | return Scaffold( 12 | body: CustomScrollView( 13 | physics: const BouncingScrollPhysics(), 14 | slivers: [ 15 | SliverAppBar.large( 16 | title: Text('resolveSetting'.tr), 17 | ), 18 | SliverList.list( 19 | children: [ 20 | Obx(() => SwitchListTile( 21 | value: c.refreshOnStartup.value, 22 | onChanged: c.changeRefreshOnStartup, 23 | title: Text('refreshOnStartup'.tr), 24 | subtitle: Text( 25 | 'refreshOnStartupInfo'.tr, 26 | style: TextStyle(color: Get.theme.colorScheme.outline), 27 | ), 28 | secondary: const Icon(Icons.refresh_rounded), 29 | )), 30 | ListTile( 31 | leading: const Icon(Icons.block_rounded), 32 | title: Text('blockWords'.tr), 33 | subtitle: Text( 34 | 'blockWordsInfo'.tr, 35 | style: TextStyle(color: Get.theme.colorScheme.outline), 36 | ), 37 | onTap: () { 38 | List words = c.blockWords; 39 | Get.dialog( 40 | StatefulBuilder( 41 | builder: (context, setState) { 42 | return AlertDialog( 43 | scrollable: true, 44 | icon: const Icon(Icons.block_rounded), 45 | title: Text('blockWords'.tr), 46 | content: Column( 47 | children: [ 48 | for (int i = 0; i < words.length; i++) 49 | ListTile( 50 | title: Text(words[i]), 51 | trailing: IconButton( 52 | icon: const Icon( 53 | Icons.do_not_disturb_on_rounded), 54 | onPressed: () => setState(() { 55 | words.removeAt(i); 56 | }), 57 | ), 58 | ), 59 | ], 60 | ), 61 | actions: [ 62 | Row( 63 | mainAxisSize: MainAxisSize.min, 64 | children: [ 65 | TextButton( 66 | child: Text('add'.tr), 67 | onPressed: () { 68 | final controller = TextEditingController(); 69 | Get.dialog( 70 | AlertDialog( 71 | icon: const Icon(Icons.add_rounded), 72 | title: Text('addBlockWord'.tr), 73 | content: TextField( 74 | controller: controller, 75 | autofocus: true, 76 | decoration: InputDecoration( 77 | border: 78 | const UnderlineInputBorder(), 79 | hintText: 'addBlockWord'.tr, 80 | ), 81 | ), 82 | actions: [ 83 | TextButton( 84 | child: Text('cancel'.tr), 85 | onPressed: () { 86 | Get.back(); 87 | }, 88 | ), 89 | TextButton( 90 | child: Text('confirm'.tr), 91 | onPressed: () { 92 | if (controller.text.isNotEmpty) { 93 | setState(() { 94 | words.add(controller.text); 95 | }); 96 | } 97 | Get.back(); 98 | }, 99 | ), 100 | ], 101 | ), 102 | ); 103 | }, 104 | ), 105 | const Spacer(), 106 | TextButton( 107 | child: Text('cancel'.tr), 108 | onPressed: () { 109 | Get.back(); 110 | }, 111 | ), 112 | TextButton( 113 | child: Text('confirm'.tr), 114 | onPressed: () { 115 | c.changeBlockWords(words); 116 | Get.back(); 117 | }, 118 | ), 119 | ], 120 | ), 121 | ], 122 | ); 123 | }, 124 | ), 125 | ); 126 | }, 127 | ), 128 | Obx( 129 | () => SwitchListTile( 130 | value: c.useProxy.value, 131 | onChanged: c.changeUseProxy, 132 | title: Text('useProxy'.tr), 133 | subtitle: Text( 134 | 'useProxyInfo'.tr, 135 | style: TextStyle(color: Get.theme.colorScheme.outline), 136 | ), 137 | secondary: const Icon(Icons.public_rounded), 138 | ), 139 | ), 140 | ListTile( 141 | leading: const Icon(Icons.link_rounded), 142 | title: Text('proxyAddress'.tr), 143 | subtitle: Obx(() => Text( 144 | c.proxyAddress.value.isEmpty 145 | ? 'notSet'.tr 146 | : c.proxyAddress.value, 147 | style: TextStyle(color: Get.theme.colorScheme.outline), 148 | )), 149 | onTap: () { 150 | final controller = 151 | TextEditingController(text: c.proxyAddress.value); 152 | Get.dialog( 153 | AlertDialog( 154 | icon: const Icon(Icons.link_rounded), 155 | title: Text('proxyAddress'.tr), 156 | content: TextField( 157 | controller: controller, 158 | autofocus: true, 159 | decoration: InputDecoration( 160 | border: const UnderlineInputBorder(), 161 | hintText: 'proxyAddress'.tr, 162 | ), 163 | ), 164 | actions: [ 165 | TextButton( 166 | child: Text('cancel'.tr), 167 | onPressed: () { 168 | Get.back(); 169 | }, 170 | ), 171 | TextButton( 172 | child: Text('confirm'.tr), 173 | onPressed: () { 174 | c.changeProxyAddress(controller.text); 175 | Get.back(); 176 | }, 177 | ), 178 | ], 179 | ), 180 | ); 181 | }, 182 | ), 183 | ListTile( 184 | leading: const Icon(Icons.tag_rounded), 185 | title: Text('proxyPort'.tr), 186 | subtitle: Obx(() => Text( 187 | c.proxyPort.value.toString().isEmpty 188 | ? 'notSet'.tr 189 | : c.proxyPort.value.toString(), 190 | style: TextStyle(color: Get.theme.colorScheme.outline), 191 | )), 192 | onTap: () { 193 | final controller = 194 | TextEditingController(text: c.proxyPort.value.toString()); 195 | Get.dialog( 196 | AlertDialog( 197 | icon: const Icon(Icons.tag_rounded), 198 | title: Text('proxyPort'.tr), 199 | content: TextField( 200 | controller: controller, 201 | autofocus: true, 202 | decoration: InputDecoration( 203 | border: const UnderlineInputBorder(), 204 | hintText: 'proxyPort'.tr, 205 | ), 206 | ), 207 | actions: [ 208 | TextButton( 209 | child: Text('cancel'.tr), 210 | onPressed: () { 211 | Get.back(); 212 | }, 213 | ), 214 | TextButton( 215 | child: Text('confirm'.tr), 216 | onPressed: () { 217 | c.changeProxyPort(controller.text); 218 | Get.back(); 219 | }, 220 | ), 221 | ], 222 | ), 223 | ); 224 | }, 225 | ), 226 | ], 227 | ), 228 | ], 229 | ), 230 | ); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /lib/ui/views/setting/setting_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | 4 | class SettingView extends StatelessWidget { 5 | const SettingView({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return Scaffold( 10 | body: CustomScrollView( 11 | physics: const BouncingScrollPhysics(), 12 | slivers: [ 13 | SliverAppBar.large( 14 | title: Text('moreSetting'.tr), 15 | ), 16 | SliverList.list( 17 | children: [ 18 | ListTile( 19 | leading: const Icon(Icons.color_lens_rounded), 20 | title: Text('displaySetting'.tr), 21 | subtitle: Text( 22 | 'displaySettingInfo'.tr, 23 | style: TextStyle(color: Get.theme.colorScheme.outline), 24 | ), 25 | onTap: () => Get.toNamed('/setting/display'), 26 | ), 27 | ListTile( 28 | leading: const Icon(Icons.article_outlined), 29 | title: Text('readSetting'.tr), 30 | subtitle: Text( 31 | 'readSettingInfo'.tr, 32 | style: TextStyle(color: Get.theme.colorScheme.outline), 33 | ), 34 | onTap: () => Get.toNamed('/setting/read'), 35 | ), 36 | ListTile( 37 | leading: const Icon(Icons.travel_explore_rounded), 38 | title: Text('resolveSetting'.tr), 39 | subtitle: Text( 40 | 'resolveSettingInfo'.tr, 41 | style: TextStyle(color: Get.theme.colorScheme.outline), 42 | ), 43 | onTap: () => Get.toNamed('/setting/resolve'), 44 | ), 45 | ListTile( 46 | leading: const Icon(Icons.data_usage_rounded), 47 | title: Text('dataManage'.tr), 48 | subtitle: Text( 49 | 'dataManageInfo'.tr, 50 | style: TextStyle(color: Get.theme.colorScheme.outline), 51 | ), 52 | onTap: () => Get.toNamed('/setting/data_manage'), 53 | ), 54 | ListTile( 55 | leading: const Icon(Icons.android_outlined), 56 | title: Text('aboutApp'.tr), 57 | subtitle: Text( 58 | 'aboutAppInfo'.tr, 59 | style: TextStyle(color: Get.theme.colorScheme.outline), 60 | ), 61 | onTap: () { 62 | Get.toNamed('/setting/about'); 63 | }, 64 | ), 65 | ], 66 | ), 67 | ], 68 | ), 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/ui/widgets/feed_panel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/models/category.dart'; 4 | import 'package:meread/models/feed.dart'; 5 | 6 | class FeedPanel extends StatefulWidget { 7 | final Category category; 8 | final Function() categoryOnTap; 9 | final Function(Feed feed) feedOnTap; 10 | 11 | const FeedPanel({ 12 | super.key, 13 | required this.category, 14 | required this.categoryOnTap, 15 | required this.feedOnTap, 16 | }); 17 | 18 | @override 19 | State createState() => _FeedPanelState(); 20 | } 21 | 22 | class _FeedPanelState extends State { 23 | bool _expanded = false; 24 | @override 25 | Widget build(BuildContext context) { 26 | return SizedBox( 27 | child: Column( 28 | children: [ 29 | ListTile( 30 | leading: IconButton( 31 | icon: Icon(_expanded ? Icons.expand_less : Icons.expand_more), 32 | onPressed: () { 33 | setState(() { 34 | _expanded = !_expanded; 35 | }); 36 | }, 37 | ), 38 | title: Text( 39 | widget.category.name, 40 | style: const TextStyle(fontSize: 16), 41 | ), 42 | contentPadding: const EdgeInsets.all(0), 43 | onTap: () => widget.categoryOnTap(), 44 | dense: true, 45 | ), 46 | AnimatedSwitcher( 47 | duration: const Duration(milliseconds: 100), 48 | transitionBuilder: (child, animation) { 49 | return SizeTransition( 50 | sizeFactor: animation, 51 | child: child, 52 | ); 53 | }, 54 | child: _expanded 55 | ? Container( 56 | margin: const EdgeInsets.symmetric(horizontal: 12), 57 | decoration: BoxDecoration( 58 | borderRadius: BorderRadius.circular(18), 59 | color: Get.theme.colorScheme.secondaryContainer 60 | .withAlpha(100), 61 | ), 62 | child: Column( 63 | children: widget.category.feeds 64 | .map((feed) => ListTile( 65 | title: Text(feed.title), 66 | dense: true, 67 | visualDensity: VisualDensity.compact, 68 | onTap: () => widget.feedOnTap(feed), 69 | shape: RoundedRectangleBorder( 70 | borderRadius: BorderRadius.circular(24), 71 | ), 72 | )) 73 | .toList(), 74 | ), 75 | ) 76 | : const SizedBox.shrink(), 77 | ), 78 | ], 79 | ), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/ui/widgets/post_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:meread/models/post.dart'; 4 | 5 | class PostCard extends StatelessWidget { 6 | final Post post; 7 | const PostCard({super.key, required this.post}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Container( 12 | padding: const EdgeInsets.all(12), 13 | decoration: BoxDecoration( 14 | borderRadius: BorderRadius.circular(12), 15 | color: Get.theme.colorScheme.secondaryContainer 16 | .withAlpha(post.read ? 20 : 60), 17 | ), 18 | child: Column( 19 | crossAxisAlignment: CrossAxisAlignment.start, 20 | mainAxisAlignment: MainAxisAlignment.center, 21 | children: [ 22 | Text( 23 | post.title, 24 | style: TextStyle( 25 | fontSize: 16, 26 | color: post.read 27 | ? Theme.of(context).colorScheme.outline.withAlpha(150) 28 | : null, 29 | ), 30 | ), 31 | const SizedBox(height: 4), 32 | Text( 33 | post.content, 34 | maxLines: 2, 35 | overflow: TextOverflow.ellipsis, 36 | style: TextStyle( 37 | fontSize: 13, 38 | color: Theme.of(context).colorScheme.outline.withAlpha( 39 | post.read ? 120 : 255, 40 | ), 41 | ), 42 | ), 43 | const SizedBox(height: 4), 44 | Row( 45 | mainAxisAlignment: MainAxisAlignment.center, 46 | crossAxisAlignment: CrossAxisAlignment.center, 47 | children: [ 48 | Text( 49 | post.feed.value?.title ?? '', 50 | style: TextStyle( 51 | fontSize: 12, 52 | color: Theme.of(context).colorScheme.secondary.withAlpha( 53 | post.read ? 120 : 255, 54 | ), 55 | ), 56 | ), 57 | const Spacer(), 58 | if (post.favorite) 59 | Container( 60 | width: 24, 61 | decoration: BoxDecoration( 62 | borderRadius: BorderRadius.circular(12), 63 | color: Theme.of(context).colorScheme.primaryContainer, 64 | ), 65 | child: Icon( 66 | Icons.bookmark_rounded, 67 | size: 16, 68 | color: Theme.of(context).colorScheme.secondary.withAlpha( 69 | post.read ? 120 : 255, 70 | ), 71 | ), 72 | ), 73 | const SizedBox(width: 8), 74 | Text( 75 | post.pubDate.toLocal().toString().substring(0, 16), 76 | style: TextStyle( 77 | fontSize: 12, 78 | color: Theme.of(context).colorScheme.secondary.withAlpha( 79 | post.read ? 120 : 255, 80 | ), 81 | ), 82 | ), 83 | ], 84 | ), 85 | ], 86 | ), 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: meread 2 | description: MeRead 3 | publish_to: "none" 4 | 5 | version: 0.6.1+23 6 | 7 | environment: 8 | sdk: ">=2.18.2 <3.0.0" 9 | 10 | dependencies: 11 | dart_rss: ^3.0.1 12 | dio: ^5.4.1 13 | dynamic_color: ^1.6.9 14 | file_picker: ^8.0.3 15 | flutter: 16 | sdk: flutter 17 | flutter_inappwebview: ^6.0.0 18 | flutter_localizations: 19 | sdk: flutter 20 | flutter_swipe_action_cell: ^3.1.3 21 | flutter_widget_from_html: ^0.15.0 22 | fluttertoast: ^8.2.5 23 | get: ^4.6.6 24 | html: ^0.15.4 25 | html_main_element: ^2.1.0 26 | intl: ^0.19.0 27 | isar: ^3.1.0+1 28 | isar_flutter_libs: ^3.1.0+1 29 | logger: ^2.0.2+1 30 | opml: ^0.4.0 31 | path_provider: ^2.1.2 32 | provider: ^6.1.1 33 | share_plus: ^10.0.0 34 | shared_preferences: ^2.2.2 35 | url_launcher: ^6.2.4 36 | 37 | dev_dependencies: 38 | flutter_test: 39 | sdk: flutter 40 | isar_generator: ^3.1.0+1 41 | build_runner: ^2.4.8 42 | flutter_lints: ^4.0.0 43 | 44 | flutter: 45 | uses-material-design: true 46 | assets: 47 | - assets/meread.png 48 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | void main() {} 2 | --------------------------------------------------------------------------------