~cytrogen/fluent-reader-mobile

28b99e817dde641dec7cce333f69d2911758df23 — Cytrogen 5 hours ago 34469ca master
feat: add subscription search/sort, rewrite README, upgrade infrastructure

- Subscription list: search by name (case-insensitive), sort by latest
  update / name A-Z / name Z-A / unread count, preference persisted
- README: add build instructions, changelog, reorganize sections
- Upgrade Flutter SDK, Gradle/AGP/Kotlin, migrate to Dart null safety
- Bundle built-in font assets (OpenSans/Roboto/SourceSerif)
- Rename Android package to icu.cytrogen.fluent_reader
- Add i18n strings for sort/search across all 9 locales
69 files changed, 1320 insertions(+), 834 deletions(-)

M README.md
M android/app/build.gradle
M android/app/src/main/AndroidManifest.xml
R android/app/src/main/kotlin/{com/example/fluent_reader_lite/MainActivity.kt => icu/cytrogen/fluent_reader/MainActivity.kt}
M android/build.gradle
M android/gradle.properties
M android/gradle/wrapper/gradle-wrapper.properties
M android/settings.gradle
A assets/fonts/OpenSans-Bold.ttf
A assets/fonts/OpenSans-Regular.ttf
A assets/fonts/Roboto-Bold.ttf
A assets/fonts/Roboto-Regular.ttf
A assets/fonts/SourceSerif-Bold.ttf
A assets/fonts/SourceSerif-Regular.ttf
M lib/components/article_item.dart
M lib/components/badge.dart
M lib/components/cupertino_toolbar.dart
M lib/components/dismissible_background.dart
M lib/components/favicon.dart
M lib/components/list_tile_group.dart
M lib/components/mark_all_action_sheet.dart
M lib/components/my_list_tile.dart
M lib/components/subscription_item.dart
M lib/components/sync_control.dart
M lib/components/time_text.dart
M lib/l10n/intl_de.arb
M lib/l10n/intl_en.arb
M lib/l10n/intl_es.arb
M lib/l10n/intl_fr.arb
M lib/l10n/intl_hr.arb
M lib/l10n/intl_pt.arb
M lib/l10n/intl_tr.arb
M lib/l10n/intl_uk.arb
M lib/l10n/intl_zh.arb
M lib/main.dart
M lib/models/feed.dart
M lib/models/feeds_model.dart
M lib/models/global_model.dart
M lib/models/groups_model.dart
M lib/models/item.dart
M lib/models/items_model.dart
M lib/models/service.dart
M lib/models/services/feedbin.dart
M lib/models/services/fever.dart
M lib/models/services/greader.dart
M lib/models/services/service_import.dart
M lib/models/source.dart
M lib/models/sources_model.dart
M lib/models/sync_model.dart
M lib/pages/article_page.dart
M lib/pages/group_list_page.dart
M lib/pages/home_page.dart
M lib/pages/item_list_page.dart
M lib/pages/settings/about_page.dart
M lib/pages/settings/feed_page.dart
M lib/pages/settings/general_page.dart
M lib/pages/settings/services/feedbin_page.dart
M lib/pages/settings/services/fever_page.dart
M lib/pages/settings/services/greader_page.dart
M lib/pages/settings/services/inoreader_page.dart
M lib/pages/settings/source_edit_page.dart
M lib/pages/settings/text_editor_page.dart
M lib/pages/subscription_list_page.dart
M lib/utils/db.dart
M lib/utils/global.dart
M lib/utils/store.dart
M lib/utils/utils.dart
M pubspec.lock
M pubspec.yaml
M README.md => README.md +80 -40
@@ 2,77 2,117 @@
  <img width="120" height="120" src="https://github.com/yang991178/fluent-reader/raw/master/build/icon.png">
</p>
<h3 align="center">Fluent Reader Lite</h3>
<p align="center">A simplistic mobile RSS client</p>
<p align="center">简洁的移动端 RSS 阅读器(个人修改版)</p>
<hr />

## Download
本仓库为 [yang991178/fluent-reader-lite](https://github.com/yang991178/fluent-reader-lite) 的个人修改版本,托管于 [git.cytrogen.icu](https://git.cytrogen.icu) 上的自部署 sourcehut 实例。

### iOS
## 从源码构建 (Build from Source)

- [Download from App Store](https://apps.apple.com/app/id1549611796) ($1.99. This will support development and help cover the $99 annual fee.)
- [Download from TestFlight](https://testflight.apple.com/join/9fwRtH8C) (Free. Inactive testers may be removed due to TestFlight restrictions.)
本仓库 **不提供** 预构建的 iOS 或 Android 应用,需要自行从源码构建。

### Android
前置条件:[Flutter SDK](https://flutter.dev/docs/get-started/install)(stable channel)

- [Download from Google Play](https://play.google.com/store/apps/details?id=me.hyliu.fluent_reader_lite) ($1.99)
- [Download APK from GitHub Releases](https://github.com/yang991178/fluent-reader-lite/releases) (Free)
```bash
git clone https://git.cytrogen.icu/~cytrogen/fluent-reader-mobile
cd fluent-reader-mobile
flutter pub get
```

### Desktop App
Android:

The repo of the full-featured desktop app [can be found here](https://github.com/yang991178/fluent-reader).
```bash
flutter build apk --release
```

## Features
iOS:

```bash
flutter build ios --release
```

## 功能特性

<p align="center">
  <img src="https://github.com/yang991178/fluent-reader-lite/raw/master/assets/demo/demo.png">
</p>

Fluent Reader Lite is a simplistic, cross-platform, and open-source RSS client.
Fluent Reader Lite 是一个简洁、跨平台、开源的 RSS 阅读器。

The following self-hosted and commercial RSS services are supported.
支持以下自托管及商业 RSS 服务:

- Fever API (TT-RSS Fever plugin, Miniflux, etc.)
- Google Reader API (Bazqux Reader, FreshRSS, The Old Reader, etc.)
- Fever API(TT-RSS Fever 插件、Miniflux 等)
- Google Reader API(Bazqux Reader、FreshRSS、The Old Reader 等)
- Inoreader
- Feedbin (official or self-hosted)
- Feedbin(官方或自托管)

### 本 fork 新增功能

- **自定义字体**:支持上传自定义字体文件(TTF/OTF/WOFF/WOFF2),文章 WebView 中通过 `@font-face` 注入渲染
- **字体预览与管理**:字体选择器中显示预览文字,支持删除已上传的自定义字体
- **内置字体修复**:修复原版中内置字体(OpenSans/Roboto/SourceSerif)在 WebView 中不渲染的问题
- **字体大小校验**:上传字体文件大小限制为 1KB–50MB
- **多语言补全**:为所有 9 种语言(中文、德语、西班牙语、法语、克罗地亚语、葡萄牙语、土耳其语、乌克兰语)补充了字体相关翻译
- **订阅源搜索**:支持按名称搜索订阅源(不区分大小写)
- **订阅源排序**:最近更新 / 名称 A→Z / 名称 Z→A / 未读数量,偏好持久化保存

### 其他主要功能

Other key features include:
- 界面与阅读的深色模式
- 可配置订阅源默认加载全文或网页
- 按最新更新排列的专用订阅标签页,显示文章标题
- 搜索本地文章或按已读状态筛选
- 使用分组管理订阅源
- iPad 和 Android 平板支持双栏视图与多任务

- Dark mode for UI and reading.
- Configure sources to load full content or webpage by default.
- A dedicated subscriptions tab organized by latest updates with article titles.
- Search for local articles or filter by read status.
- Organize subscriptions with groups.
- Support for two-pane view and multitasking on iPad and Android tablets.
以下桌面版功能**不包含**在移动版中:

The following features from the desktop app are **NOT** present:
- 本地 RSS 支持及订阅源/分组管理
- 导入导出 OPML 文件,完整应用数据备份与恢复
- 正则表达式规则自动标记文章
- 后台抓取文章并推送通知
- 键盘快捷键

- Local RSS support and source / group management.
- Importing or exporting OPML files, full application data backup & restoration.
- Regular expression rules that mark articles as they arrive.
- Fetch articles in the background and send push notifications.
- Keyboard shortcuts.
桌面端完整版请参阅 [fluent-reader](https://github.com/yang991178/fluent-reader)。

## Development
## 变更日志 (Changelog)

### Contribute
### 2026-02-22 — 订阅源搜索与排序

Help make Fluent Reader better by reporting bugs or opening feature requests through [GitHub issues](https://github.com/yang991178/fluent-reader-lite/issues). 
- 支持按名称搜索订阅源(不区分大小写)
- 排序选项:最近更新 / 名称 A→Z / 名称 Z→A / 未读数量
- 排序偏好保存至 SharedPreferences,重启后保留
- 为全部 9 种语言添加排序相关翻译

You can also help internationalize the app by providing [translations into additional languages](https://github.com/yang991178/fluent-reader-lite/tree/master/lib/l10n). 
You can read more about ARB files [here](https://localizely.com/flutter-arb) or [here](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification). 
### 2026-02-18 · `34469ca` — 自定义字体增强

If you enjoy using this app, consider supporting its development by donating through [Paypal](https://www.paypal.me/yang991178) or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg).
- 支持上传自定义字体文件(TTF/OTF/WOFF/WOFF2),文章 WebView 中通过 `@font-face` 注入渲染
- 修复原版中内置字体(OpenSans/Roboto/SourceSerif)在 WebView 中不渲染的问题
- 字体选择器中显示预览文字,支持删除已上传的自定义字体
- 字体文件大小限制为 1KB–50MB
- 为全部 9 种语言补充字体相关翻译

### Build from source
### 2026-02-18 — 基础设施升级

See [Flutter documentation](https://flutter.dev/docs).
Flutter SDK 及依赖升级、Android 构建配置更新(Gradle / AGP / Kotlin)、Dart null safety 迁移。

### Developed with
## 支持原作者

如果你想要直接下载安装,或希望支持原作者的开发工作,可以购买付费版本:

- **iOS**:[App Store](https://apps.apple.com/app/id1549611796)($1.99)
- **Android**:[Google Play](https://play.google.com/store/apps/details?id=me.hyliu.fluent_reader_lite)($1.99)
- **Android(免费)**:[GitHub Releases 下载 APK](https://github.com/yang991178/fluent-reader-lite/releases)

## 开发相关

- [Flutter](https://github.com/flutter/flutter)
- [Mercury Parser](https://github.com/postlight/mercury-parser)

### License
## 许可证

BSD 3-Clause — 详见 [LICENSE](LICENSE)

## 原始 README

BSD
原项目 README 请参阅 [GitHub 上的原始版本](https://github.com/yang991178/fluent-reader-lite/blob/master/README.md)。

M android/app/build.gradle => android/app/build.gradle +15 -13
@@ 1,5 1,7 @@
plugins {
    id "com.android.application"
    id "kotlin-android"
    id "dev.flutter.flutter-gradle-plugin"
}

def localProperties = new Properties()


@@ 10,11 12,6 @@ if (localPropertiesFile.exists()) {
    }
}

def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'


@@ 32,7 29,8 @@ if (keystorePropertiesFile.exists()) {
}

android {
    compileSdk 33
    compileSdk 36
    namespace "icu.cytrogen.fluent_reader"

    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'


@@ 42,11 40,19 @@ android {
        disable 'InvalidPackage'
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = '17'
    }

    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "me.hyliu.fluent_reader_lite"
        applicationId "icu.cytrogen.fluent_reader"
        minSdk 24
        targetSdk 33
        targetSdk 36
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }


@@ 72,7 78,3 @@ android {
flutter {
    source '../..'
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
\ No newline at end of file

M android/app/src/main/AndroidManifest.xml => android/app/src/main/AndroidManifest.xml +1 -1
@@ 1,5 1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    package="icu.cytrogen.fluent_reader">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

R android/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt => android/app/src/main/kotlin/icu/cytrogen/fluent_reader/MainActivity.kt +1 -1
@@ 1,4 1,4 @@
package com.example.fluent_reader_lite
package icu.cytrogen.fluent_reader

import io.flutter.embedding.android.FlutterActivity


M android/build.gradle => android/build.gradle +4 -21
@@ 1,35 1,18 @@
plugins {
    id "dev.flutter.flutter-gradle-plugin"
}

buildscript {
    ext.kotlin_version = '1.7.0'
    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:7.0.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        mavenCentral()
    }
}

rootProject.buildDir = '../build'
rootProject.buildDir = "../build"
subprojects {
    project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
    project.evaluationDependsOn(':app')
    project.evaluationDependsOn(":app")
}

tasks.register("clean", Delete) {
    delete rootProject.layout.buildDirectory
}
\ No newline at end of file
}

M android/gradle.properties => android/gradle.properties +2 -2
@@ 1,3 1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
android.enableJetifier=false

M android/gradle/wrapper/gradle-wrapper.properties => android/gradle/wrapper/gradle-wrapper.properties +1 -1
@@ 2,4 2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

M android/settings.gradle => android/settings.gradle +15 -1
@@ 22,4 22,18 @@ plugins {
    id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}

include ":app"
\ No newline at end of file
include ":app"

gradle.allprojects { project ->
    project.afterEvaluate {
        if (it.hasProperty('android') && it.android.namespace == null) {
            def manifest = file("${it.projectDir}/src/main/AndroidManifest.xml")
            if (manifest.exists()) {
                def pkg = new XmlParser().parse(manifest).attribute('package')
                if (pkg) {
                    it.android.namespace = pkg
                }
            }
        }
    }
}
\ No newline at end of file

A assets/fonts/OpenSans-Bold.ttf => assets/fonts/OpenSans-Bold.ttf +0 -0
A assets/fonts/OpenSans-Regular.ttf => assets/fonts/OpenSans-Regular.ttf +0 -0
A assets/fonts/Roboto-Bold.ttf => assets/fonts/Roboto-Bold.ttf +0 -0
A assets/fonts/Roboto-Regular.ttf => assets/fonts/Roboto-Regular.ttf +0 -0
A assets/fonts/SourceSerif-Bold.ttf => assets/fonts/SourceSerif-Bold.ttf +0 -0
A assets/fonts/SourceSerif-Regular.ttf => assets/fonts/SourceSerif-Regular.ttf +0 -0
M lib/components/article_item.dart => lib/components/article_item.dart +13 -14
@@ 11,7 11,7 @@ import 'package:fluent_reader_lite/utils/global.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share/share.dart';
import 'package:share_plus/share_plus.dart';
import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart';



@@ 20,7 20,7 @@ class ArticleItem extends StatefulWidget {
  final RSSSource source;
  final Function openActionSheet;

  ArticleItem(this.item, this.source, this.openActionSheet, {Key key}) : super(key: key);
  ArticleItem(this.item, this.source, this.openActionSheet, {Key? key}) : super(key: key);

  @override
  _ArticleItemState createState() => _ArticleItemState();


@@ 36,12 36,12 @@ class _ArticleItemState extends State<ArticleItem> {
    if (!widget.item.hasRead) {
      Global.itemsModel.updateItem(widget.item.id, read: true);
    }
    if (widget.source.openTarget == SourceOpenTarget.External) {
      launch(widget.item.link, forceSafariVC: false, forceWebView: false);
    if (Global.resolveOpenTarget(widget.source) == SourceOpenTarget.External) {
      launchUrl(Uri.parse(widget.item.link), mode: LaunchMode.externalApplication);
    } else {
      var isSource = Navigator.of(context).canPop();
      if (ArticlePage.state.currentWidget != null) {
        ArticlePage.state.currentState.loadNewItem(
        (ArticlePage.state.currentState as ArticlePageState?)?.loadNewItem(
          widget.item.id,
          isSource: isSource,
        );


@@ 49,7 49,7 @@ class _ArticleItemState extends State<ArticleItem> {
        var navigator = Global.responsiveNavigator(context);
        while (navigator.canPop()) navigator.pop();
        navigator.pushNamed(
          "/article", 
          "/article",
          arguments: Tuple2(widget.item.id, isSource)
        );
      }


@@ 101,7 101,6 @@ class _ArticleItemState extends State<ArticleItem> {
      case ItemSwipeOption.OpenExternal:
        return CupertinoIcons.square_arrow_right;
    }
    return null;
  }

  void _performSwipeAction(ItemSwipeOption option) async {


@@ 124,7 123,7 @@ class _ArticleItemState extends State<ArticleItem> {
        if (!widget.item.hasRead) {
          Global.itemsModel.updateItem(widget.item.id, read: true);
        }
        launch(widget.item.link, forceSafariVC: false, forceWebView: false);
        launchUrl(Uri.parse(widget.item.link), mode: LaunchMode.externalApplication);
        break;
    }
  }


@@ 183,10 182,10 @@ class _ArticleItemState extends State<ArticleItem> {
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            widget.item.title, 
            widget.item.title,
            style: _titleStyle,
          ),
          if (Global.feedsModel.showSnippet && widget.item.snippet.length > 0) Text(
          if (Global.feedsModel.showSnippet && widget.item.snippet.isNotEmpty) Text(
            widget.item.snippet,
            style: _snippetStyle,
            overflow: TextOverflow.ellipsis,


@@ 201,8 200,8 @@ class _ArticleItemState extends State<ArticleItem> {
      onLongPress: _openActionSheet,
      onTap: _openArticle,
      child: Container(
        color: pressed 
          ? CupertinoColors.systemGrey4.resolveFrom(context) 
        color: pressed
          ? CupertinoColors.systemGrey4.resolveFrom(context)
          : CupertinoColors.systemBackground.resolveFrom(context),
        child: Column(children: [
          Padding(


@@ 228,7 227,7 @@ class _ArticleItemState extends State<ArticleItem> {
                            child: ClipRRect(
                              borderRadius: BorderRadius.circular(4),
                              child: CachedNetworkImage(
                                imageUrl: widget.item.thumb,
                                imageUrl: widget.item.thumb!,
                                width: 64, height: 64, fit: BoxFit.cover,
                                placeholder: _imagePlaceholderBuilder,
                              ),


@@ 265,4 264,4 @@ class _ArticleItemState extends State<ArticleItem> {
      child: body,
    );
  }
}
\ No newline at end of file
}

M lib/components/badge.dart => lib/components/badge.dart +2 -2
@@ 1,7 1,7 @@
import 'package:flutter/cupertino.dart';

class Badge extends StatelessWidget {
  Badge(int count, {this.color : CupertinoColors.systemRed, Key key}) :
  Badge(int count, {this.color = CupertinoColors.systemRed, Key? key}) :
    label = count >= 1000 ? "999+" : count.toString(),
    super(key: key);



@@ 26,4 26,4 @@ class Badge extends StatelessWidget {
      ),
    )
  );
}
\ No newline at end of file
}

M lib/components/cupertino_toolbar.dart => lib/components/cupertino_toolbar.dart +10 -68
@@ 8,58 8,17 @@ import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:flutter/cupertino.dart';

/// Display a persistent bottom iOS styled toolbar for Cupertino theme
///
class CupertinoToolbar extends StatelessWidget {
  /// Creates a persistent bottom iOS styled toolbar for Cupertino
  /// themed app,
  ///
  /// Typically used as the [child] attribute of a [CupertinoPageScaffold].
  ///
  /// {@tool sample}
  ///
  /// A sample code implementing a typical iOS page with bottom toolbar.
  ///
  /// ```dart
  /// CupertinoPageScaffold(
  /// 	navigationBar: CupertinoNavigationBar(
  /// 		middle: Text('Cupertino Toolbar')
  /// 	),
  /// 	child: CupertinoToolbar(
  /// 		items: <CupertinoToolbarItem>[
  /// 			CupertinoToolbarItem(
  /// 				icon: CupertinoIcons.delete,
  /// 				onPressed: () {}
  /// 			),
  /// 			CupertinoToolbarItem(
  /// 				icon: CupertinoIcons.settings,
  /// 				onPressed: () {}
  /// 			)
  /// 		],
  /// 		body: Center(
  /// 			child: Text('Hello World')
  /// 		)
  /// 	)
  /// )
  /// ```
  /// {@end-tool}
  ///
  CupertinoToolbar({
    Key key,
    @required this.items,
    @required this.body
  }) : assert(items != null),
       assert(
        items.every((CupertinoToolbarItem item) => (item.icon != null)) == true,
        'Every item must have an icon and onPressed defined',
    Key? key,
    required this.items,
    required this.body
  }) : assert(
        items.every((CupertinoToolbarItem item) => true) == true,
       ),
       assert(body != null),
       super(key: key);

  /// The interactive items laid out within the toolbar where each item has an icon.
  final List<CupertinoToolbarItem> items;

  /// The body displayed above the toolbar.
  final Widget body;

  @override


@@ 97,7 56,6 @@ class CupertinoToolbar extends StatelessWidget {
        padding: EdgeInsets.zero,
        child: Icon(
          items[i].icon,
          // color: CupertinoColors.systemBlue,
          semanticLabel: items[i].semanticLabel,
        ),
        onPressed: items[i].onPressed


@@ 107,30 65,14 @@ class CupertinoToolbar extends StatelessWidget {
  }
}

/// An interactive button within iOS themed [CupertinoToolbar]
class CupertinoToolbarItem {
  /// Creates an item that is used with [CupertinoToolbar.items].
  ///
  /// The argument [icon] should not be null.
  const CupertinoToolbarItem({
    @required this.icon,
    required this.icon,
    this.onPressed,
    this.semanticLabel
  }) : assert(icon != null);
  });

  /// The icon of the item.
  ///
  /// This attribute must not be null.
  final IconData icon;

  /// The callback that is called when the item is tapped.
  ///
  /// This attribute must not be null.
  final VoidCallback onPressed;

  /// Semantic label for the icon.
  ///
  /// Announced in accessibility modes (e.g TalkBack/VoiceOver).
  /// This label does not show in the UI.
  final String semanticLabel;
}
\ No newline at end of file
  final VoidCallback? onPressed;
  final String? semanticLabel;
}

M lib/components/dismissible_background.dart => lib/components/dismissible_background.dart +2 -2
@@ 4,7 4,7 @@ class DismissibleBackground extends StatelessWidget {
  final IconData icon;
  final bool isToRight;

  DismissibleBackground(this.icon, this.isToRight, {Key key})
  DismissibleBackground(this.icon, this.isToRight, {Key? key})
    : super(key: key);

  @override


@@ 22,4 22,4 @@ class DismissibleBackground extends StatelessWidget {
      )],
    ),
  );
}
\ No newline at end of file
}

M lib/components/favicon.dart => lib/components/favicon.dart +6 -6
@@ 6,7 6,7 @@ class Favicon extends StatelessWidget {
  final RSSSource source;
  final double size;

  const Favicon(this.source, {this.size: 16, Key key}) : super(key: key);
  const Favicon(this.source, {this.size = 16, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {


@@ 14,10 14,10 @@ class Favicon extends StatelessWidget {
      fontSize: size - 5,
      color: CupertinoColors.systemGrey6,
    );
    
    if (source.iconUrl != null && source.iconUrl.length > 0) {

    if (source.iconUrl != null && source.iconUrl!.isNotEmpty) {
      return CachedNetworkImage(
        imageUrl: source.iconUrl,
        imageUrl: source.iconUrl!,
        width: size,
        height: size,
      );


@@ 27,10 27,10 @@ class Favicon extends StatelessWidget {
        height: size,
        color: CupertinoColors.systemGrey.resolveFrom(context),
        child: Center(child: Text(
          source.name.length > 0 ? source.name[0] : "?",
          source.name.isNotEmpty ? source.name[0] : "?",
          style: _textStyle,
        )),
      );
    }
  }
}
\ No newline at end of file
}

M lib/components/list_tile_group.dart => lib/components/list_tile_group.dart +8 -8
@@ 5,18 5,18 @@ import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';

class ListTileGroup extends StatelessWidget {
  ListTileGroup(this.children, {this.title, Key key}) : super(key: key);
  ListTileGroup(this.children, {this.title, Key? key}) : super(key: key);

  ListTileGroup.fromOptions(
    List<Tuple2<String, dynamic>> options, 
    dynamic selected, 
    List<Tuple2<String, dynamic>> options,
    dynamic selected,
    Function onSelected,
    {this.title, Key key}) :
    {this.title, Key? key}) :
    children = options.map((t) => MyListTile(
      title: Text(t.item1),
      trailing: t.item2 == selected
        ? Icon(Icons.done)
        : Icon(null),
        : null,
      trailingChevron: false,
      onTap: () { onSelected(t.item2); },
      withDivider: t.item2 != options.last.item2,


@@ 24,7 24,7 @@ class ListTileGroup extends StatelessWidget {
    super(key: key);

  final Iterable<Widget> children;
  final String title;
  final String? title;

  static const _titleStyle = TextStyle(
    fontSize: 12,


@@ 37,7 37,7 @@ class ListTileGroup extends StatelessWidget {
    children: [
      if (title != null) Padding(
        padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6),
        child: Text(title, style: _titleStyle),
        child: Text(title!, style: _titleStyle),
      ),
      Container(
        color: MyColors.tileBackground.resolveFrom(context),


@@ 57,4 57,4 @@ class ListTileGroup extends StatelessWidget {
      ),
    ),
  ],);
}
\ No newline at end of file
}

M lib/components/mark_all_action_sheet.dart => lib/components/mark_all_action_sheet.dart +6 -6
@@ 6,13 6,13 @@ import 'package:flutter/cupertino.dart';
class MarkAllActionSheet extends StatelessWidget {
  final Set<String> sids;

  MarkAllActionSheet(this.sids, {Key key}) : super(key: key);
  MarkAllActionSheet(this.sids, {Key? key}) : super(key: key);

  DateTime _offset(int days) {
    return DateTime.now().subtract(Duration(days: days));
  }

  void _markAll(BuildContext context, {DateTime date}) {
  void _markAll(BuildContext context, {DateTime? date}) {
    Navigator.of(context, rootNavigator: true).pop();
    Global.itemsModel.markAllRead(sids, date: date);
  }


@@ 42,11 42,11 @@ class MarkAllActionSheet extends StatelessWidget {
      ],
      cancelButton: CupertinoActionSheetAction(
        child: Text(S.of(context).cancel),
        onPressed: () { 
        onPressed: () {
          Navigator.of(context, rootNavigator: true).pop();
        },
      ),
    ); 
    return ResponsiveActionSheet(sheet);    
    );
    return ResponsiveActionSheet(sheet);
  }
}
\ No newline at end of file
}

M lib/components/my_list_tile.dart => lib/components/my_list_tile.dart +11 -11
@@ 3,23 3,23 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class MyListTile extends StatefulWidget {
  final Widget leading;
  final Widget? leading;
  final Widget title;
  final Widget trailing;
  final Widget? trailing;
  final bool trailingChevron;
  final bool withDivider;
  final Function onTap;
  final Function? onTap;
  final CupertinoDynamicColor background;

  MyListTile({
    this.leading,
    @required this.title,
    required this.title,
    this.trailing,
    this.trailingChevron : true,
    this.withDivider : true,
    this.trailingChevron = true,
    this.withDivider = true,
    this.onTap,
    this.background : MyColors.tileBackground,
    Key key,
    this.background = MyColors.tileBackground,
    Key? key,
  }) : super(key: key);

  @override


@@ 30,7 30,7 @@ class _MyListTileState extends State<MyListTile> {
  bool pressed = false;

  void _onTap() {
    if (widget.onTap != null) widget.onTap();
    if (widget.onTap != null) widget.onTap!();
  }

  @override


@@ 60,7 60,7 @@ class _MyListTileState extends State<MyListTile> {
    final rightPart = Row(
      children: [
        if (widget.trailing != null) DefaultTextStyle(
          child: widget.trailing,
          child: widget.trailing!,
          style: _labelStyle,
        ),
        if (widget.trailingChevron) Icon(


@@ 77,7 77,7 @@ class _MyListTileState extends State<MyListTile> {
      child: Column(children: [
        Container(
          color: (pressed && widget.onTap != null)
            ? CupertinoColors.systemGrey4.resolveFrom(context) 
            ? CupertinoColors.systemGrey4.resolveFrom(context)
            : widget.background.resolveFrom(context),
          padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6),
          constraints: BoxConstraints(minHeight: 48),

M lib/components/subscription_item.dart => lib/components/subscription_item.dart +1 -1
@@ 13,7 13,7 @@ import 'badge.dart';
class SubscriptionItem extends StatefulWidget {
  final RSSSource source;

  SubscriptionItem(this.source, {Key key}) : super(key: key);
  SubscriptionItem(this.source, {Key? key}) : super(key: key);

  @override
  _SubscriptionItemState createState() => _SubscriptionItemState();

M lib/components/sync_control.dart => lib/components/sync_control.dart +2 -3
@@ 11,13 11,12 @@ class SyncControl extends StatefulWidget {
class _SyncControlState extends State<SyncControl> {
  Future<void> _onRefresh() {
    var completer = Completer();
    Function listener;
    listener = () {
    void listener() {
      if (!Global.syncModel.syncing) {
        completer.complete();
        Global.syncModel.removeListener(listener);
      }
    };
    }
    Global.syncModel.addListener(listener);
    Global.syncModel.syncWithService();
    return completer.future;

M lib/components/time_text.dart => lib/components/time_text.dart +7 -7
@@ 4,17 4,17 @@ import 'package:flutter/cupertino.dart';

class TimeText extends StatefulWidget {
  final DateTime date;
  final TextStyle style;
  final TextStyle? style;

  TimeText(this.date, {this.style, Key key}) : super(key: key);
  TimeText(this.date, {this.style, Key? key}) : super(key: key);

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

class _TimeTextState extends State<TimeText> {
  Timer _timer;
  Duration _duration;
  Timer? _timer;
  Duration? _duration;

  int diffMinutes() {
    final now = DateTime.now();


@@ 37,9 37,9 @@ class _TimeTextState extends State<TimeText> {
    } else {
      duration = Duration(minutes: (60 * 24) - diff % (60 * 24));
    }
    if (_duration == null || duration.compareTo(_duration) != 0) {
    if (_duration == null || duration.compareTo(_duration!) != 0) {
      _duration = duration;
      if (_timer != null) _timer.cancel();
      _timer?.cancel();
      _timer = Timer.periodic(duration, (_) {
        setState(() {});
        updateTimer();


@@ 49,7 49,7 @@ class _TimeTextState extends State<TimeText> {

  @override
  void dispose() {
    if (_timer != null) _timer.cancel();
    _timer?.cancel();
    super.dispose();
  }


M lib/l10n/intl_de.arb => lib/l10n/intl_de.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Langdruck auf den Titel dieser Seite zum Wechsel der Ansicht zwischen aller oder ungelesender Abonnements.",
    "uncategorized": "Ohne Kategorie",
    "showUncategorized": "Zeige ohne Kategorie",
    "serviceExists": "Ein Service ist schon eingestellt. Bitte vor dem Import abmelden."
    "serviceExists": "Ein Service ist schon eingestellt. Bitte vor dem Import abmelden.",
    "inheritDefault": "Vererben",
    "sortBy": "Sortieren nach",
    "sortByLatest": "Neueste Aktualisierung",
    "sortByNameAsc": "Name A→Z",
    "sortByNameDesc": "Name Z→A",
    "sortByUnread": "Ungelesene Anzahl"
  }
\ No newline at end of file

M lib/l10n/intl_en.arb => lib/l10n/intl_en.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "You can long press on the title of this page to toggle between all and unread subscriptions.",
    "uncategorized": "Uncategorized",
    "showUncategorized": "Show uncategorized",
    "serviceExists": "A service already exists. Please log out before importing."
    "serviceExists": "A service already exists. Please log out before importing.",
    "inheritDefault": "Inherit",
    "sortBy": "Sort by",
    "sortByLatest": "Latest update",
    "sortByNameAsc": "Name A→Z",
    "sortByNameDesc": "Name Z→A",
    "sortByUnread": "Unread count"
  }
\ No newline at end of file

M lib/l10n/intl_es.arb => lib/l10n/intl_es.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Puede hacer una pulsación larga en el título de esta página para alternar entre todas las suscripciones y las no leídas.",
    "uncategorized": "Sin categorizar",
    "showUncategorized": "Mostrar sin categorizar",
    "serviceExists": "Ya existe este servicio. Por favor, cierre la sesión antes de importar."
    "serviceExists": "Ya existe este servicio. Por favor, cierre la sesión antes de importar.",
    "inheritDefault": "Heredar",
    "sortBy": "Ordenar por",
    "sortByLatest": "Última actualización",
    "sortByNameAsc": "Nombre A→Z",
    "sortByNameDesc": "Nombre Z→A",
    "sortByUnread": "Cantidad no leídos"
  }

M lib/l10n/intl_fr.arb => lib/l10n/intl_fr.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Vous pouvez appuyer longuement sur le titre de cette page pour basculer entre tous les abonnements et les abonnements non lus",
    "uncategorized": "Non classé(s)",
    "showUncategorized": "Voir les non classé(s)",
    "serviceExists": "Un service existe déjà. Veuillez vous déconnecter avant d'importer."
    "serviceExists": "Un service existe déjà. Veuillez vous déconnecter avant d'importer.",
    "inheritDefault": "Hériter",
    "sortBy": "Trier par",
    "sortByLatest": "Dernière mise à jour",
    "sortByNameAsc": "Nom A→Z",
    "sortByNameDesc": "Nom Z→A",
    "sortByUnread": "Nombre de non lus"
  }

M lib/l10n/intl_hr.arb => lib/l10n/intl_hr.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Možete dugo pritisnuti naslov ove stranice za prebacivanje između svih i nepročitanih pretplata.",
    "uncategorized": "Nekategorizirano",
    "showUncategorized": "Prikaži nekategorizirano",
    "serviceExists": "Servis već postoji. Odjavite se prije uvoza."
    "serviceExists": "Servis već postoji. Odjavite se prije uvoza.",
    "inheritDefault": "Naslijedi",
    "sortBy": "Sortiraj po",
    "sortByLatest": "Posljednje ažuriranje",
    "sortByNameAsc": "Naziv A→Z",
    "sortByNameDesc": "Naziv Z→A",
    "sortByUnread": "Broj nepročitanih"
  }

M lib/l10n/intl_pt.arb => lib/l10n/intl_pt.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Você pode pressionar de forma longa o título dessa página para alternar entre todas as inscrições e as não lidas.",
    "uncategorized": "Não categorizado",
    "showUncategorized": "Mostrar não categorizado",
    "serviceExists": "Um serviço já existe. Por favor saia antes de importar."
    "serviceExists": "Um serviço já existe. Por favor saia antes de importar.",
    "inheritDefault": "Herdar",
    "sortBy": "Ordenar por",
    "sortByLatest": "Última atualização",
    "sortByNameAsc": "Nome A→Z",
    "sortByNameDesc": "Nome Z→A",
    "sortByUnread": "Quantidade não lidos"
  }

M lib/l10n/intl_tr.arb => lib/l10n/intl_tr.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "üm ve okunmamış abonelikler arasında geçiş yapmak için bu sayfanın başlığına uzun süre basabilirsiniz.",
    "uncategorized": "Kategorize edilmemiş",
    "showUncategorized": "Kategorize edilmemişleri göster",
    "serviceExists": "Bir hizmet zaten var. Lütfen içe aktarmadan önce oturumu kapatın."
    "serviceExists": "Bir hizmet zaten var. Lütfen içe aktarmadan önce oturumu kapatın.",
    "inheritDefault": "Devral",
    "sortBy": "Sırala",
    "sortByLatest": "Son güncelleme",
    "sortByNameAsc": "Ad A→Z",
    "sortByNameDesc": "Ad Z→A",
    "sortByUnread": "Okunmamış sayısı"
  }
\ No newline at end of file

M lib/l10n/intl_uk.arb => lib/l10n/intl_uk.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "Ви можете довго натискати на заголовок цієї сторінки, щоб перемикатися між усіма та непрочитаними підписками.",
    "uncategorized": "Без категорії",
    "showUncategorized": "Показати без категорії",
    "serviceExists": "Служба вже існує. Будь ласка, вийдіть з системи перед імпортом."
    "serviceExists": "Служба вже існує. Будь ласка, вийдіть з системи перед імпортом.",
    "inheritDefault": "Успадкувати",
    "sortBy": "Сортувати за",
    "sortByLatest": "Останнє оновлення",
    "sortByNameAsc": "Назва А→Я",
    "sortByNameDesc": "Назва Я→А",
    "sortByUnread": "Кількість непрочитаних"
  }
\ No newline at end of file

M lib/l10n/intl_zh.arb => lib/l10n/intl_zh.arb +7 -1
@@ 104,5 104,11 @@
    "unreadSourceTip": "您可以长按此页面的标题来切换全部订阅源或仅未读订阅源。",
    "uncategorized": "未分组",
    "showUncategorized": "显示“未分组”",
    "serviceExists": "已登录至一个服务,请在导入前登出。"
    "serviceExists": "已登录至一个服务,请在导入前登出。",
    "inheritDefault": "继承",
    "sortBy": "排序方式",
    "sortByLatest": "最近更新",
    "sortByNameAsc": "名称 A→Z",
    "sortByNameDesc": "名称 Z→A",
    "sortByUnread": "未读数量"
  }
\ No newline at end of file

M lib/main.dart => lib/main.dart +9 -16
@@ 24,7 24,6 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'generated/l10n.dart';
import 'models/global_model.dart';



@@ 36,12 35,11 @@ void main() async {
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
    ));
    WebView.platform = SurfaceAndroidWebView();
  }
  runApp(MyApp());
  SystemChannels.lifecycle.setMessageHandler((msg) {
  SystemChannels.lifecycle.setMessageHandler((msg) async {
    if (msg == AppLifecycleState.resumed.toString()) {
      if (Global.server != null) Global.server.restart();
      if (Global.server != null) Global.server!.restart();
      if (Global.globalModel.syncOnStart &&
          DateTime.now().difference(Global.syncModel.lastSynced).inMinutes >=
              10) {


@@ 79,15 77,12 @@ class MyApp extends StatelessWidget {
          return FeedbinPage();
        case SyncService.GReader:
          return GReaderPage();
          break;
        case SyncService.Inoreader:
          return InoreaderPage();
          break;
      }
      return AboutPage();
    }
  };
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MultiProvider(


@@ 104,7 99,6 @@ class MyApp extends StatelessWidget {
          title: "Fluent Reader",
          debugShowCheckedModeBanner: false,
          localizationsDelegates: [
            // ... app-specific localization delegate[s] here
            S.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,


@@ 122,12 116,12 @@ class MyApp extends StatelessWidget {
            const Locale("pt"),
            const Locale("tr"),
          ],
          localeResolutionCallback: (_locale, supportedLocales) {
            _locale = Locale(_locale.languageCode);
          localeResolutionCallback: (locale, supportedLocales) {
            final resolvedLocale = Locale(locale?.languageCode ?? "en");
            if (globalModel.locale != null)
              return globalModel.locale;
            else if (supportedLocales.contains(_locale))
              return _locale;
            else if (supportedLocales.contains(resolvedLocale))
              return resolvedLocale;
            else
              return Locale("en");
          },


@@ 138,7 132,6 @@ class MyApp extends StatelessWidget {
          routes: {
            "/": (context) => CupertinoScaffold(
                body: CupertinoTheme(
                    // For fixing the bug with modal_bottom_sheet overriding primary color
                    data: CupertinoThemeData(
                        primaryColor: CupertinoColors.activeBlue),
                    child: HomePage())),


@@ 146,11 139,11 @@ class MyApp extends StatelessWidget {
          },
          builder: (context, child) {
            final mediaQueryData = MediaQuery.of(context);
            if (Global.globalModel.textScale == null) return child;
            if (Global.globalModel.textScale == null) return child!;
            return MediaQuery(
                data: mediaQueryData.copyWith(
                    textScaleFactor: Global.globalModel.textScale),
                child: child);
                    textScaler: TextScaler.linear(Global.globalModel.textScale!)),
                child: child!);
          },
        ),
      ),

M lib/models/feed.dart => lib/models/feed.dart +12 -8
@@ 19,19 19,22 @@ class RSSFeed {
  FilterType filterType;
  String search = "";

  RSSFeed({this.sids}) {
    if (sids == null) sids = Set();
    filterType = FilterType.values[Store.sp.getInt(_filterKey) ?? 0];
  }
  RSSFeed({Set<String>? sids})
    : sids = sids ?? Set(),
      filterType = FilterType.values[Store.sp.getInt(
        (sids == null || sids.isEmpty)
          ? StoreKeys.FEED_FILTER_ALL
          : StoreKeys.FEED_FILTER_SOURCE
      ) ?? 0];

  String get _filterKey => sids.length == 0
  String get _filterKey => sids.isEmpty
    ? StoreKeys.FEED_FILTER_ALL
    : StoreKeys.FEED_FILTER_SOURCE;

  Tuple2<String, List<String>> _getPredicates() {
    List<String> where = ["1 = 1"];
    List<String> whereArgs = [];
    if (sids.length > 0) {
    if (sids.isNotEmpty) {
      var placeholders = List.filled(sids.length, "?").join(" , ");
      where.add("source IN ($placeholders)");
      whereArgs.addAll(sids);


@@ 51,7 54,7 @@ class RSSFeed {
  }

  bool testItem(RSSItem item) {
    if (sids.length > 0 && !sids.contains(item.source)) return false;
    if (sids.isNotEmpty && !sids.contains(item.source)) return false;
    if (filterType == FilterType.Unread && item.hasRead) return false;
    if (filterType == FilterType.Starred && !item.starred) return false;
    if (search != "") {


@@ 88,7 91,8 @@ class RSSFeed {
    var predicates = _getPredicates();
    var offset = iids
      .map((iid) => Global.itemsModel.getItem(iid))
      .fold(0, (c, i) => c + (testItem(i) ? 1 : 0));
      .where((i) => i != null)
      .fold(0, (c, i) => c + (testItem(i!) ? 1 : 0));
    var items = (await Global.db.query(
      "items",
      orderBy: "date DESC",

M lib/models/feeds_model.dart => lib/models/feeds_model.dart +9 -6
@@ 12,7 12,7 @@ enum ItemSwipeOption {

class FeedsModel with ChangeNotifier {
  RSSFeed all = RSSFeed();
  RSSFeed source;
  RSSFeed? source;

  bool _showThumb = Store.sp.getBool(StoreKeys.SHOW_THUMB) ?? true;
  bool get showThumb => _showThumb;


@@ 59,20 59,23 @@ class FeedsModel with ChangeNotifier {
  Future<void> initSourcesFeed(Iterable<String> sids) async {
    Set<String> sidSet = Set.from(sids);
    source = RSSFeed(sids: sidSet);
    await source.init();
    await source!.init();
  }

  void addFetchedItems(Iterable<RSSItem> items) {
    for (var feed in [all, source]) {
      if (feed == null) continue;
      var lastDate = feed.iids.length > 0
        ? Global.itemsModel.getItem(feed.iids.last).date
      var lastDate = feed.iids.isNotEmpty
        ? Global.itemsModel.getItem(feed.iids.last)?.date
        : null;
      for (var item in items) {
        if (!feed.testItem(item)) continue;
        if (lastDate != null && item.date.isBefore(lastDate)) continue;
        var idx = Utils.binarySearch(feed.iids, item.id, (a, b) {
          return Global.itemsModel.getItem(b).date.compareTo(Global.itemsModel.getItem(a).date);
          final dateA = Global.itemsModel.getItem(a)?.date;
          final dateB = Global.itemsModel.getItem(b)?.date;
          if (dateA == null || dateB == null) return 0;
          return dateB.compareTo(dateA);
        });
        feed.iids.insert(idx, item.id);
      }


@@ 86,4 89,4 @@ class FeedsModel with ChangeNotifier {
      feed.init();
    }
  }
}
\ No newline at end of file
}

M lib/models/global_model.dart => lib/models/global_model.dart +19 -8
@@ 1,5 1,6 @@
import 'dart:io';

import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:flutter/material.dart';



@@ 9,12 10,13 @@ enum ThemeSetting {

class GlobalModel with ChangeNotifier {
  ThemeSetting _theme = Store.getTheme();
  Locale _locale = Store.getLocale();
  Locale? _locale = Store.getLocale();
  int _keepItemsDays = Store.sp.getInt(StoreKeys.KEEP_ITEMS_DAYS) ?? 21;
  bool _syncOnStart = Store.sp.getBool(StoreKeys.SYNC_ON_START) ?? true;
  bool _inAppBrowser = Store.sp.getBool(StoreKeys.IN_APP_BROWSER) ?? Platform.isIOS;
  double _textScale = Store.sp.getDouble(StoreKeys.TEXT_SCALE);
  double? _textScale = Store.sp.getDouble(StoreKeys.TEXT_SCALE);
  String _fontFamily = Store.getFontFamily();
  SourceOpenTarget _globalOpenTarget = Store.getGlobalOpenTarget();

  ThemeSetting get theme => _theme;
  set theme(ThemeSetting value) {


@@ 24,13 26,13 @@ class GlobalModel with ChangeNotifier {
      Store.setTheme(value);
    }
  }
  Brightness getBrightness() {
  Brightness? getBrightness() {
    if (_theme == ThemeSetting.Default) return null;
    else return _theme == ThemeSetting.Light ? Brightness.light : Brightness.dark;
  }

  Locale get locale => _locale;
  set locale(Locale value) {
  Locale? get locale => _locale;
  set locale(Locale? value) {
    if (value != _locale) {
      _locale = value;
      notifyListeners();


@@ 56,8 58,8 @@ class GlobalModel with ChangeNotifier {
    Store.sp.setBool(StoreKeys.IN_APP_BROWSER, value);
  }

  double get textScale => _textScale;
  set textScale(double value) {
  double? get textScale => _textScale;
  set textScale(double? value) {
    if (_textScale != value) {
      _textScale = value;
      notifyListeners();


@@ 77,4 79,13 @@ class GlobalModel with ChangeNotifier {
      Store.setFontFamily(value);
    }
  }
}
\ No newline at end of file

  SourceOpenTarget get globalOpenTarget => _globalOpenTarget;
  set globalOpenTarget(SourceOpenTarget value) {
    if (value != _globalOpenTarget) {
      _globalOpenTarget = value;
      notifyListeners();
      Store.setGlobalOpenTarget(value);
    }
  }
}

M lib/models/groups_model.dart => lib/models/groups_model.dart +28 -3
@@ 1,10 1,12 @@
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:flutter/cupertino.dart';

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

  Map<String, List<String>> get groups => _groups;
  set groups(Map<String, List<String>> groups) {


@@ 14,7 16,7 @@ class GroupsModel with ChangeNotifier {
    Store.setGroups(groups);
  }

  void updateUncategorized({force: false}) {
  void updateUncategorized({bool force = false}) {
    if (uncategorized != null || force) {
      final sids = Set<String>.from(
        Global.sourcesModel.getSources().map<String>((s) => s.id)


@@ 41,4 43,27 @@ class GroupsModel with ChangeNotifier {
      notifyListeners();
    }
  }
}
\ No newline at end of file

  SourceOpenTarget getGroupOpenTarget(String groupName) {
    var idx = _groupOpenTargets[groupName];
    if (idx == null || idx >= SourceOpenTarget.values.length) {
      return SourceOpenTarget.Inherit;
    }
    return SourceOpenTarget.values[idx];
  }

  void setGroupOpenTarget(String groupName, SourceOpenTarget target) {
    _groupOpenTargets[groupName] = target.index;
    Store.setGroupOpenTargets(_groupOpenTargets);
    notifyListeners();
  }

  String? findGroupForSource(String sourceId) {
    for (var entry in _groups.entries) {
      if (entry.value.contains(sourceId)) {
        return entry.key;
      }
    }
    return null;
  }
}

M lib/models/item.dart => lib/models/item.dart +19 -18
@@ 8,13 8,15 @@ class RSSItem {
  String snippet;
  bool hasRead;
  bool starred;
  String creator; // Optional
  String thumb; // Optional
  String creator;
  String? thumb;

  RSSItem({
    this.id, this.source, this.title, this.link, this.date,
    this.content, this.snippet, this.hasRead, this.starred,
    this.creator, this.thumb
    required this.id, required this.source, required this.title,
    required this.link, required this.date,
    required this.content, required this.snippet,
    required this.hasRead, required this.starred,
    this.creator = "", this.thumb
  });

  RSSItem clone() {


@@ 41,17 43,16 @@ class RSSItem {
    };
  }

  RSSItem.fromMap(Map<String, dynamic> map) {
    id = map["iid"];
    source = map["source"];
    title = map["title"];
    link = map["link"];
    date = DateTime.fromMillisecondsSinceEpoch(map["date"]);
    content = map["content"];
    snippet = map["snippet"];
    hasRead = map["hasRead"] != 0;
    starred = map["starred"] != 0;
    creator = map["creator"];
    thumb = map["thumb"];
  }
  RSSItem.fromMap(Map<String, dynamic> map)
    : id = map["iid"],
      source = map["source"],
      title = map["title"],
      link = map["link"],
      date = DateTime.fromMillisecondsSinceEpoch(map["date"]),
      content = map["content"],
      snippet = map["snippet"],
      hasRead = map["hasRead"] != 0,
      starred = map["starred"] != 0,
      creator = map["creator"] ?? "",
      thumb = map["thumb"];
}

M lib/models/items_model.dart => lib/models/items_model.dart +16 -17
@@ 8,7 8,7 @@ class ItemsModel with ChangeNotifier {

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

  RSSItem getItem(String id) => _items[id];
  RSSItem? getItem(String id) => _items[id];
  Iterable<RSSItem> getItems() => _items.values;

  void loadItems(Iterable<RSSItem> items) {


@@ 17,24 17,24 @@ class ItemsModel with ChangeNotifier {
    }
  }

  Future<void> updateItem(String iid, 
    {Batch batch, bool read, bool starred, local: false}) async {
  Future<void> updateItem(String iid,
    {Batch? batch, bool? read, bool? starred, bool local = false}) async {
    Map<String, dynamic> updateMap = Map();
    if (_items.containsKey(iid)) {
      final item = _items[iid].clone();
      final item = _items[iid]!.clone();
      if (read != null) {
        item.hasRead = read;
        if (!local) {
          if (read) Global.service.markRead(item);
          else Global.service.markUnread(item);
          if (read) Global.service?.markRead(item);
          else Global.service?.markUnread(item);
        }
        Global.sourcesModel.updateUnreadCount(item.source, read ? -1 : 1);
      }
      if (starred != null) {
        item.starred = starred;
        if (!local) {
          if (starred) Global.service.star(item);
          else Global.service.unstar(item);
          if (starred) Global.service?.star(item);
          else Global.service?.unstar(item);
        }
      }
      _items[iid] = item;


@@ 49,10 49,10 @@ class ItemsModel with ChangeNotifier {
    }
  }

  Future<void> markAllRead(Set<String> sids, {DateTime date, before = true}) async {
    Global.service.markAllRead(sids, date, before);
  Future<void> markAllRead(Set<String> sids, {DateTime? date, bool before = true}) async {
    Global.service?.markAllRead(sids, date, before);
    List<String> predicates = ["hasRead = 0"];
    if (sids.length > 0) {
    if (sids.isNotEmpty) {
      predicates.add("source IN (${List.filled(sids.length, "?").join(" , ")})");
    }
    if (date != null) {


@@ 65,8 65,8 @@ class ItemsModel with ChangeNotifier {
      whereArgs: sids.toList(),
    );
    for (var item in _items.values.toList()) {
      if (sids.length > 0 && !sids.contains(item.source)) continue;
      if (date != null && 
      if (sids.isNotEmpty && !sids.contains(item.source)) continue;
      if (date != null &&
        (before ? item.date.compareTo(date) > 0 : item.date.compareTo(date) < 0))
        continue;
      item.hasRead = true;


@@ 76,7 76,7 @@ class ItemsModel with ChangeNotifier {
  }

  Future<void> fetchItems() async {
    final items = await Global.service.fetchItems();
    final items = await Global.service!.fetchItems();
    final batch = Global.db.batch();
    for (var item in items) {
      if (!Global.sourcesModel.has(item.source)) continue;


@@ 88,13 88,12 @@ class ItemsModel with ChangeNotifier {
      );
    }
    await batch.commit(noResult: true);
    // notifyListeners();
    Global.sourcesModel.updateWithFetchedItems(items);
    Global.feedsModel.addFetchedItems(items);
  }

  Future<void> syncItems() async {
    final tuple = await Global.service.syncItems();
    final tuple = await Global.service!.syncItems();
    final unreadIds = tuple.item1;
    final starredIds = tuple.item2;
    final rows = await Global.db.query(


@@ 104,7 103,7 @@ class ItemsModel with ChangeNotifier {
    );
    final batch = Global.db.batch();
    for (var row in rows) {
      final id = row["iid"];
      final id = row["iid"] as String;
      if (row["hasRead"] == 0 && !unreadIds.remove(id)) {
        await updateItem(id, read: true, batch: batch, local: true);
      }

M lib/models/service.dart => lib/models/service.dart +1 -1
@@ 19,7 19,7 @@ abstract class ServiceHandler {
  Future<Tuple2<List<RSSSource>, Map<String, List<String>>>> getSources();
  Future<List<RSSItem>> fetchItems();
  Future<Tuple2<Set<String>, Set<String>>> syncItems();
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before);
  Future<void> markAllRead(Set<String> sids, DateTime? date, bool before);
  Future<void> markRead(RSSItem item);
  Future<void> markUnread(RSSItem item);
  Future<void> star(RSSItem item);

M lib/models/services/feedbin.dart => lib/models/services/feedbin.dart +29 -30
@@ 13,25 13,24 @@ import '../item.dart';
import '../source.dart';

class FeedbinServiceHandler extends ServiceHandler {
  String endpoint;
  String username;
  String password;
  int fetchLimit;
  late String endpoint;
  late String username;
  late String password;
  late int fetchLimit;
  int _lastId;
  Tuple2<Set<String>, Set<String>> _lastSynced;
  Tuple2<Set<String>, Set<String>>? _lastSynced;

  FeedbinServiceHandler() {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT);
    username = Store.sp.getString(StoreKeys.USERNAME);
    password = Store.sp.getString(StoreKeys.PASSWORD);
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT);
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
  FeedbinServiceHandler()
    : _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0 {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "";
    username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
    password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 500;
  }

  FeedbinServiceHandler.fromValues(
      this.endpoint, this.username, this.password, this.fetchLimit) {
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
  }
      this.endpoint, this.username, this.password, this.fetchLimit)
    : _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;

  void persist() {
    Store.sp.setInt(StoreKeys.SYNC_SERVICE, SyncService.Feedbin.index);


@@ 72,9 71,9 @@ class FeedbinServiceHandler extends ServiceHandler {
    final promises = List<Future>.empty(growable: true);
    final client = http.Client();
    try {
      while (refs.length > 0) {
      while (refs.isNotEmpty) {
        final batch = List<int>.empty(growable: true);
        while (batch.length < 1000 && refs.length > 0) {
        while (batch.length < 1000 && refs.isNotEmpty) {
          batch.add(int.parse(refs.removeLast()));
        }
        final bodyObject = {


@@ 125,7 124,7 @@ class FeedbinServiceHandler extends ServiceHandler {
    for (var tag in tags) {
      final name = tag["name"].trim();
      groupsMap.putIfAbsent(name, () => []);
      groupsMap[name].add(tag["feed_id"].toString());
      groupsMap[name]!.add(tag["feed_id"].toString());
    }
    final sources = subscriptions.map<RSSSource>((s) {
      return RSSSource(s["feed_id"].toString(), s["feed_url"], s["title"]);


@@ 138,7 137,7 @@ class FeedbinServiceHandler extends ServiceHandler {
    var page = 1;
    var minId = Utils.syncMaxId;
    var items = [];
    List lastFetched;
    List? lastFetched;
    do {
      try {
        final response = await _fetchAPI(


@@ 146,8 145,8 @@ class FeedbinServiceHandler extends ServiceHandler {
        assert(response.statusCode == 200);
        lastFetched = jsonDecode(response.body);
        items.addAll(
            lastFetched.where((i) => i["id"] > lastId && i["id"] < minId));
        minId = lastFetched.fold(minId, (m, n) => min(m, n["id"]));
            lastFetched!.where((i) => i["id"] > lastId && i["id"] < minId));
        minId = lastFetched.fold(minId, (m, n) => min(m as int, n["id"] as int));
        page += 1;
      } catch (exp) {
        break;


@@ 156,10 155,10 @@ class FeedbinServiceHandler extends ServiceHandler {
        lastFetched != null &&
        lastFetched.length >= 125 &&
        items.length < fetchLimit);
    lastId = items.fold(lastId, (m, n) => max(m, n["id"]));
    lastId = items.fold(lastId, (m, n) => max(m as int, n["id"] as int));
    final parsedItems = List<RSSItem>.empty(growable: true);
    final unread = _lastSynced.item1;
    final starred = _lastSynced.item2;
    final unread = _lastSynced!.item1;
    final starred = _lastSynced!.item2;
    for (var i in items) {
      if (i["content"] == null) continue;
      final dom = parse(i["content"]);


@@ 171,8 170,8 @@ class FeedbinServiceHandler extends ServiceHandler {
        link: i["url"],
        date: DateTime.parse(i["published"]),
        content: i["content"],
        snippet: dom.documentElement.text.trim(),
        creator: i["author"],
        snippet: dom.documentElement?.text.trim() ?? "",
        creator: i["author"] ?? "",
        hasRead: !unread.contains(iid),
        starred: starred.contains(iid),
      );


@@ 181,7 180,7 @@ class FeedbinServiceHandler extends ServiceHandler {
      } else {
        var img = dom.querySelector("img");
        if (img != null && img.attributes["src"] != null) {
          var thumb = img.attributes["src"];
          var thumb = img.attributes["src"]!;
          if (thumb.startsWith("http")) {
            item.thumb = thumb;
          }


@@ 207,13 206,13 @@ class FeedbinServiceHandler extends ServiceHandler {
      Set.from(unread.map((i) => i.toString())),
      Set.from(starred.map((i) => i.toString())),
    );
    return _lastSynced;
    return _lastSynced!;
  }

  @override
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
  Future<void> markAllRead(Set<String> sids, DateTime? date, bool before) async {
    List<String> predicates = ["hasRead = 0"];
    if (sids.length > 0) {
    if (sids.isNotEmpty) {
      predicates
          .add("source IN (${List.filled(sids.length, "?").join(" , ")})");
    }


@@ 227,7 226,7 @@ class FeedbinServiceHandler extends ServiceHandler {
      where: predicates.join(" AND "),
      whereArgs: sids.toList(),
    );
    final iids = rows.map((r) => r["iid"]);
    final iids = rows.map((r) => r["iid"] as String);
    await _markItems("unread", "DELETE", List.from(iids));
  }


M lib/models/services/fever.dart => lib/models/services/fever.dart +25 -28
@@ 13,28 13,26 @@ import 'package:tuple/tuple.dart';
import '../service.dart';

class FeverServiceHandler extends ServiceHandler {
  String endpoint;
  String apiKey;
  late String endpoint;
  late String apiKey;
  int _lastId;
  int fetchLimit;
  late int fetchLimit;
  bool _useInt32;

  FeverServiceHandler() {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT);
    apiKey = Store.sp.getString(StoreKeys.API_KEY);
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT);
    _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false;
  FeverServiceHandler()
    : _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0,
      _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "";
    apiKey = Store.sp.getString(StoreKeys.API_KEY) ?? "";
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 500;
  }

  FeverServiceHandler.fromValues(
    this.endpoint,
    this.apiKey,
    this.fetchLimit,
  ) {
    _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0;
    _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false;
  }
  ) : _lastId = Store.sp.getInt(StoreKeys.LAST_ID) ?? 0,
      _useInt32 = Store.sp.getBool(StoreKeys.FEVER_INT_32) ?? false;

  void persist(String username, String password) {
    Store.sp.setInt(StoreKeys.SYNC_SERVICE, SyncService.Fever.index);


@@ 61,7 59,7 @@ class FeverServiceHandler extends ServiceHandler {
    Global.service = null;
  }

  Future<Map<String, dynamic>> _fetchAPI({params: "", postparams: ""}) async {
  Future<Map<String, dynamic>> _fetchAPI({String params = "", String postparams = ""}) async {
    var uri = Uri.parse(endpoint + "?api" + params);
    final response = await http.post(
      uri,


@@ 111,9 109,10 @@ class FeverServiceHandler extends ServiceHandler {
    }
    for (var group in feedGroups) {
      var name = groupsIdMap[group["group_id"]];
      if (name == null) continue;
      for (var fid in group["feed_ids"].split(",")) {
        groupsMap.putIfAbsent(name, () => []);
        groupsMap[name].add(fid);
        groupsMap[name]!.add(fid);
      }
    }
    return Tuple2(sources, groupsMap);


@@ 122,7 121,7 @@ class FeverServiceHandler extends ServiceHandler {
  @override
  Future<List<RSSItem>> fetchItems() async {
    var minId = useInt32 ? 2147483647 : Utils.syncMaxId;
    List<dynamic> response;
    List<dynamic>? response;
    List<dynamic> items = [];
    do {
      response = (await _fetchAPI(params: "&items&max_id=$minId"))["items"];


@@ 131,12 130,12 @@ class FeverServiceHandler extends ServiceHandler {
        if (i["id"] is String) i["id"] = int.parse(i["id"]);
        if (i["id"] > lastId) items.add(i);
      }
      if (response.length == 0 && minId == Utils.syncMaxId) {
      if (response.isEmpty && minId == Utils.syncMaxId) {
        useInt32 = true;
        minId = 2147483647;
        response = null;
      } else {
        minId = response.fold(minId, (m, n) => min<int>(m, n["id"]));
        minId = response!.fold(minId, (m, n) => min<int>(m, n["id"]));
      }
    } while (minId > lastId &&
        (response == null || response.length >= 50) &&


@@ 150,20 149,18 @@ class FeverServiceHandler extends ServiceHandler {
        link: i["url"],
        date: DateTime.fromMillisecondsSinceEpoch(i["created_on_time"] * 1000),
        content: i["html"],
        snippet: dom.documentElement.text.trim(),
        creator: i["author"],
        snippet: dom.documentElement?.text.trim() ?? "",
        creator: i["author"] ?? "",
        hasRead: i["is_read"] == 1,
        starred: i["is_saved"] == 1,
      );
      // Try to get the thumbnail of the item
      var img = dom.querySelector("img");
      if (img != null && img.attributes["src"] != null) {
        var thumb = img.attributes["src"];
        var thumb = img.attributes["src"]!;
        if (thumb.startsWith("http")) {
          item.thumb = thumb;
        }
      } else if (useInt32) {
        // TTRSS Fever Plugin attachments
        var a = dom.querySelector("body>ul>li:first-child>a");
        if (a != null &&
            a.text.endsWith(", image\/generic") &&


@@ 181,8 178,8 @@ class FeverServiceHandler extends ServiceHandler {
      _fetchAPI(params: "&unread_item_ids"),
      _fetchAPI(params: "&saved_item_ids"),
    ]);
    final unreadIds = responses[0]["unread_item_ids"];
    final starredIds = responses[1]["saved_item_ids"];
    final unreadIds = responses[0]["unread_item_ids"] as String;
    final starredIds = responses[1]["saved_item_ids"] as String;
    return Tuple2(
        Set.from(unreadIds.split(",")), Set.from(starredIds.split(",")));
  }


@@ 196,10 193,10 @@ class FeverServiceHandler extends ServiceHandler {
  }

  @override
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
  Future<void> markAllRead(Set<String> sids, DateTime? date, bool before) async {
    if (date != null && !before) {
      var items = Global.itemsModel.getItems().where((i) =>
          (sids.length == 0 || sids.contains(i.source)) &&
          (sids.isEmpty || sids.contains(i.source)) &&
          i.date.compareTo(date) >= 0);
      await Future.wait(items.map((i) => markRead(i)));
    } else {


@@ 210,7 207,7 @@ class FeverServiceHandler extends ServiceHandler {
      try {
        await Future.wait(Global.sourcesModel
            .getSources()
            .where((s) => sids.length == 0 || sids.contains(s.id))
            .where((s) => sids.isEmpty || sids.contains(s.id))
            .map((s) => _fetchAPI(
                postparams:
                    "&mark=feed&as=read&id=${s.id}&before=$timestamp")));

M lib/models/services/greader.dart => lib/models/services/greader.dart +55 -57
@@ 15,27 15,27 @@ class GReaderServiceHandler extends ServiceHandler {
  static const _READ_TAG = "user/-/state/com.google/read";
  static const _STAR_TAG = "user/-/state/com.google/starred";

  String endpoint;
  String username;
  String password;
  int fetchLimit;
  int _lastFetched;
  String _lastId;
  String _auth;
  bool useInt64;
  String inoreaderId;
  String inoreaderKey;
  bool removeInoreaderAd;
  late String endpoint;
  late String username;
  late String password;
  late int fetchLimit;
  int? _lastFetched;
  String? _lastId;
  String? _auth;
  late bool useInt64;
  String? inoreaderId;
  String? inoreaderKey;
  bool? removeInoreaderAd;

  GReaderServiceHandler() {
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT);
    username = Store.sp.getString(StoreKeys.USERNAME);
    password = Store.sp.getString(StoreKeys.PASSWORD);
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT);
    endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "";
    username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
    password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
    fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 500;
    _lastFetched = Store.sp.getInt(StoreKeys.LAST_FETCHED);
    _lastId = Store.sp.getString(StoreKeys.LAST_ID);
    _auth = Store.sp.getString(StoreKeys.AUTH);
    useInt64 = Store.sp.getBool(StoreKeys.USE_INT_64);
    useInt64 = Store.sp.getBool(StoreKeys.USE_INT_64) ?? true;
    inoreaderId = Store.sp.getString(StoreKeys.API_ID);
    inoreaderKey = Store.sp.getString(StoreKeys.API_KEY);
    removeInoreaderAd = Store.sp.getBool(StoreKeys.INOREADER_REMOVE_AD);


@@ 69,9 69,9 @@ class GReaderServiceHandler extends ServiceHandler {
    Store.sp.setInt(StoreKeys.FETCH_LIMIT, fetchLimit);
    Store.sp.setBool(StoreKeys.USE_INT_64, useInt64);
    if (inoreaderId != null) {
      Store.sp.setString(StoreKeys.API_ID, inoreaderId);
      Store.sp.setString(StoreKeys.API_KEY, inoreaderKey);
      Store.sp.setBool(StoreKeys.INOREADER_REMOVE_AD, removeInoreaderAd);
      Store.sp.setString(StoreKeys.API_ID, inoreaderId!);
      Store.sp.setString(StoreKeys.API_KEY, inoreaderKey!);
      Store.sp.setBool(StoreKeys.INOREADER_REMOVE_AD, removeInoreaderAd ?? false);
    }
    Global.service = this;
  }


@@ 93,30 93,30 @@ class GReaderServiceHandler extends ServiceHandler {
    Global.service = null;
  }

  int get lastFetched => _lastFetched;
  set lastFetched(int value) {
  int? get lastFetched => _lastFetched;
  set lastFetched(int? value) {
    _lastFetched = value;
    Store.sp.setInt(StoreKeys.LAST_FETCHED, value);
    if (value != null) Store.sp.setInt(StoreKeys.LAST_FETCHED, value);
  }

  String get lastId => _lastId;
  set lastId(String value) {
  String? get lastId => _lastId;
  set lastId(String? value) {
    _lastId = value;
    Store.sp.setString(StoreKeys.LAST_ID, value);
    if (value != null) Store.sp.setString(StoreKeys.LAST_ID, value);
  }

  String get auth => _auth;
  set auth(String value) {
  String? get auth => _auth;
  set auth(String? value) {
    _auth = value;
    Store.sp.setString(StoreKeys.AUTH, value);
    if (value != null) Store.sp.setString(StoreKeys.AUTH, value);
  }

  Future<http.Response> _fetchAPI(String params, {dynamic body}) async {
    final headers = Map<String, String>();
    if (auth != null) headers["Authorization"] = auth;
    if (auth != null) headers["Authorization"] = auth!;
    if (inoreaderId != null) {
      headers["AppId"] = inoreaderId;
      headers["AppKey"] = inoreaderKey;
      headers["AppId"] = inoreaderId!;
      headers["AppKey"] = inoreaderKey!;
    }
    var uri = Uri.parse(endpoint + params);
    if (body == null) {


@@ 129,8 129,8 @@ class GReaderServiceHandler extends ServiceHandler {

  Future<Set<String>> _fetchAll(String params) async {
    final results = List<String>.empty(growable: true);
    List fetched;
    String continuation;
    List? fetched;
    String? continuation;
    do {
      var p = params;
      if (continuation != null) p += "&c=$continuation";


@@ 138,17 138,17 @@ class GReaderServiceHandler extends ServiceHandler {
      assert(response.statusCode == 200);
      final parsed = jsonDecode(response.body);
      fetched = parsed["itemRefs"];
      if (fetched != null && fetched.length > 0) {
      if (fetched != null && fetched.isNotEmpty) {
        for (var i in fetched) {
          results.add(i["id"]);
        }
      }
      continuation = parsed["continuation"];
    } while (continuation != null && fetched != null && fetched.length >= 1000);
    return new Set.from(results);
    return Set.from(results);
  }

  Future<http.Response> _editTag(String ref, String tag, {add: true}) async {
  Future<http.Response> _editTag(String ref, String tag, {bool add = true}) async {
    final body = "i=$ref&${add ? "a" : "r"}=$tag";
    return await _fetchAPI("/reader/api/0/edit-tag", body: body);
  }


@@ 199,7 199,7 @@ class GReaderServiceHandler extends ServiceHandler {
      if (categories != null) {
        for (var c in categories) {
          groupsMap.putIfAbsent(c["label"], () => []);
          groupsMap[c["label"]].add(s["id"]);
          groupsMap[c["label"]]!.add(s["id"]);
        }
      }
    }


@@ 212,8 212,8 @@ class GReaderServiceHandler extends ServiceHandler {
  @override
  Future<List<RSSItem>> fetchItems() async {
    List items = [];
    List fetchedItems;
    String continuation;
    List? fetchedItems;
    String? continuation;
    do {
      try {
        final limit = min(fetchLimit - items.length, 1000);


@@ 224,7 224,7 @@ class GReaderServiceHandler extends ServiceHandler {
        assert(response.statusCode == 200);
        final fetched = jsonDecode(response.body);
        fetchedItems = fetched["items"];
        for (var i in fetchedItems) {
        for (var i in fetchedItems!) {
          i["id"] = _compactId(i["id"]);
          if (i["id"] == lastId || items.length >= fetchLimit) {
            break;


@@ 237,15 237,15 @@ class GReaderServiceHandler extends ServiceHandler {
        break;
      }
    } while (continuation != null && items.length < fetchLimit);
    if (items.length > 0) {
    if (items.isNotEmpty) {
      lastId = items[0]["id"];
      lastFetched = int.parse(items[0]["crawlTimeMsec"]) ~/ 1000;
    }
    final parsedItems = items.map<RSSItem>((i) {
      final dom = parse(i["summary"]["content"]);
      if (removeInoreaderAd == true) {
        if (dom.documentElement.text.trim().startsWith("Ads from Inoreader")) {
          dom.body.firstChild.remove();
        if (dom.documentElement?.text.trim().startsWith("Ads from Inoreader") ?? false) {
          dom.body?.firstChild?.remove();
        }
      }
      final item = RSSItem(


@@ 254,19 254,19 @@ class GReaderServiceHandler extends ServiceHandler {
        title: i["title"],
        link: i["canonical"][0]["href"],
        date: DateTime.fromMillisecondsSinceEpoch(i["published"] * 1000),
        content: dom.body.innerHtml,
        snippet: dom.documentElement.text.trim(),
        creator: i["author"],
        content: dom.body?.innerHtml ?? "",
        snippet: dom.documentElement?.text.trim() ?? "",
        creator: i["author"] ?? "",
        hasRead: false,
        starred: false,
      );
      if (inoreaderId != null) {
        final titleDom = parse(item.title);
        item.title = titleDom.documentElement.text;
        item.title = titleDom.documentElement?.text ?? item.title;
      }
      var img = dom.querySelector("img");
      if (img != null && img.attributes["src"] != null) {
        var thumb = img.attributes["src"];
        var thumb = img.attributes["src"]!;
        if (thumb.startsWith("http")) {
          item.thumb = thumb;
        }


@@ 304,24 304,22 @@ class GReaderServiceHandler extends ServiceHandler {
  }

  @override
  Future<void> markAllRead(Set<String> sids, DateTime date, bool before) async {
  Future<void> markAllRead(Set<String> sids, DateTime? date, bool before) async {
    if (date != null) {
      List<String> predicates = ["hasRead = 0"];
      if (sids.length > 0) {
      if (sids.isNotEmpty) {
        predicates
            .add("source IN (${List.filled(sids.length, "?").join(" , ")})");
      }
      if (date != null) {
        predicates
            .add("date ${before ? "<=" : ">="} ${date.millisecondsSinceEpoch}");
      }
      predicates
          .add("date ${before ? "<=" : ">="} ${date.millisecondsSinceEpoch}");
      final rows = await Global.db.query(
        "items",
        columns: ["iid"],
        where: predicates.join(" AND "),
        whereArgs: sids.toList(),
      );
      final iids = rows.map((r) => r["iid"]).iterator;
      final iids = rows.map((r) => r["iid"] as String).iterator;
      List<String> refs = [];
      while (iids.moveNext()) {
        refs.add(iids.current);


@@ 330,9 328,9 @@ class GReaderServiceHandler extends ServiceHandler {
          refs = [];
        }
      }
      if (refs.length > 0) _editTag(refs.join("&i="), _READ_TAG);
      if (refs.isNotEmpty) _editTag(refs.join("&i="), _READ_TAG);
    } else {
      if (sids.length == 0)
      if (sids.isEmpty)
        sids = Set.from(Global.sourcesModel.getSources().map((s) => s.id));
      for (var sid in sids) {
        final body = {"s": sid};

M lib/models/services/service_import.dart => lib/models/services/service_import.dart +5 -5
@@ 1,9 1,9 @@
class ServiceImport {
  String endpoint;
  String username;
  String password;
  String apiId;
  String apiKey;
  String? endpoint;
  String? username;
  String? password;
  String? apiId;
  String? apiKey;

  static const typeMap = {
    "f": "/settings/service/fever",

M lib/models/source.dart => lib/models/source.dart +28 -18
@@ 1,23 1,32 @@
enum SourceOpenTarget {
    Local, FullContent, Webpage, External
    Local, FullContent, Webpage, External, Inherit
}

SourceOpenTarget resolveOpenTargetCascade(
  SourceOpenTarget sourceTarget,
  SourceOpenTarget groupTarget,
  SourceOpenTarget globalTarget,
) {
  if (sourceTarget != SourceOpenTarget.Inherit) return sourceTarget;
  if (groupTarget != SourceOpenTarget.Inherit) return groupTarget;
  return globalTarget;
}

class RSSSource {
  String id;
  String url;
  String iconUrl;
  String? iconUrl;
  String name;
  SourceOpenTarget openTarget;
  int unreadCount;
  DateTime latest;
  String lastTitle;

  RSSSource(this.id, this.url, this.name) {
    openTarget = SourceOpenTarget.Local;
    latest = DateTime.now();
    unreadCount = 0;
    lastTitle = "";
  }
  RSSSource(this.id, this.url, this.name)
    : openTarget = SourceOpenTarget.Inherit,
      latest = DateTime.now(),
      unreadCount = 0,
      lastTitle = "";

  RSSSource._privateConstructor(
    this.id, this.url, this.iconUrl, this.name, this.openTarget,


@@ 43,14 52,15 @@ class RSSSource {
    };
  }

  RSSSource.fromMap(Map<String, dynamic> map) {
    id = map["sid"];
    url = map["url"];
    iconUrl = map["iconUrl"];
    name = map["name"];
    openTarget = SourceOpenTarget.values[map["openTarget"]];
    latest = DateTime.fromMillisecondsSinceEpoch(map["latest"]);
    lastTitle = map["lastTitle"];
    unreadCount = 0;
  }
  RSSSource.fromMap(Map<String, dynamic> map)
    : id = map["sid"],
      url = map["url"],
      iconUrl = map["iconUrl"],
      name = map["name"],
      openTarget = (map["openTarget"] as int) < SourceOpenTarget.values.length
          ? SourceOpenTarget.values[map["openTarget"]]
          : SourceOpenTarget.Local,
      latest = DateTime.fromMillisecondsSinceEpoch(map["latest"]),
      lastTitle = map["lastTitle"],
      unreadCount = 0;
}

M lib/models/sources_model.dart => lib/models/sources_model.dart +17 -15
@@ 24,7 24,7 @@ class SourcesModel with ChangeNotifier {

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

  RSSSource getSource(String id) => _sources[id] ?? _deleted[id];
  RSSSource? getSource(String id) => _sources[id] ?? _deleted[id];

  Iterable<RSSSource> getSources() => _sources.values;



@@ 47,13 47,13 @@ class SourcesModel with ChangeNotifier {
      cloned.unreadCount = 0;
    }
    for (var row in rows) {
      _sources[row["source"]].unreadCount = row["COUNT(iid)"];
      _sources[row["source"]]?.unreadCount = row["COUNT(iid)"] as int;
    }
    notifyListeners();
  }

  void updateUnreadCount(String sid, int diff) {
    _sources[sid].unreadCount += diff;
    _sources[sid]?.unreadCount += diff;
    notifyListeners();
  }



@@ 61,19 61,21 @@ class SourcesModel with ChangeNotifier {
    Set<String> changed = Set();
    for (var item in items) {
      var source = _sources[item.source];
      if (source == null) continue;
      if (!item.hasRead) source.unreadCount += 1;
      if (item.date.compareTo(source.latest) > 0 ||
          source.lastTitle.length == 0) {
          source.lastTitle.isEmpty) {
        source.latest = item.date;
        source.lastTitle = item.title;
        changed.add(source.id);
      }
    }
    notifyListeners();
    if (changed.length > 0) {
    if (changed.isNotEmpty) {
      var batch = Global.db.batch();
      for (var sid in changed) {
        var source = _sources[sid];
        if (source == null) continue;
        batch.update(
          "sources",
          {


@@ 88,7 90,7 @@ class SourcesModel with ChangeNotifier {
    }
  }

  Future<void> put(RSSSource source, {force: false}) async {
  Future<void> put(RSSSource source, {bool force = false}) async {
    if (_deleted.containsKey(source.id) && !force) return;
    _sources[source.id] = source;
    notifyListeners();


@@ 99,7 101,7 @@ class SourcesModel with ChangeNotifier {
    );
  }

  Future<void> putAll(Iterable<RSSSource> sources, {force: false}) async {
  Future<void> putAll(Iterable<RSSSource> sources, {bool force = false}) async {
    Batch batch = Global.db.batch();
    for (var source in sources) {
      if (_deleted.containsKey(source.id) && !force) continue;


@@ 115,7 117,7 @@ class SourcesModel with ChangeNotifier {
  }

  Future<void> updateSources() async {
    final tuple = await Global.service.getSources();
    final tuple = await Global.service!.getSources();
    final sources = tuple.item1;
    var curr = Set<String>.from(_sources.keys);
    List<RSSSource> newSources = [];


@@ 136,7 138,7 @@ class SourcesModel with ChangeNotifier {
    final batch = Global.db.batch();
    for (var id in ids) {
      if (!_sources.containsKey(id)) continue;
      var source = _sources[id];
      var source = _sources[id]!;
      batch.delete(
        "items",
        where: "source = ?",


@@ 157,18 159,18 @@ class SourcesModel with ChangeNotifier {

  Future<void> fetchFavicons() async {
    for (var key in _sources.keys) {
      if (_sources[key].iconUrl == null) {
        _fetchFavicon(_sources[key].url).then((url) {
      if (_sources[key]!.iconUrl == null) {
        _fetchFavicon(_sources[key]!.url).then((url) {
          if (!_sources.containsKey(key)) return;
          var source = _sources[key].clone();
          source.iconUrl = url == null ? "" : url;
          var source = _sources[key]!.clone();
          source.iconUrl = url ?? "";
          put(source);
        });
      }
    }
  }

  Future<String> _fetchFavicon(String url) async {
  Future<String?> _fetchFavicon(String url) async {
    try {
      url = url.split("/").getRange(0, 3).join("/");
      var uri = Uri.parse(url);


@@ 181,7 183,7 @@ class SourcesModel with ChangeNotifier {
          var rel = link.attributes["rel"];
          if ((rel == "icon" || rel == "shortcut icon") &&
              link.attributes.containsKey("href")) {
            var href = link.attributes["href"];
            var href = link.attributes["href"]!;
            var parsedUrl = Uri.parse(url);
            if (href.startsWith("//"))
              return parsedUrl.scheme + ":" + href;

M lib/models/sync_model.dart => lib/models/sync_model.dart +3 -3
@@ 26,7 26,7 @@ class SyncModel with ChangeNotifier {
      .map((s) => s.id)
      .toList();
    await Global.sourcesModel.removeSources(sids);
    Global.service.remove();
    Global.service!.remove();
    hasService = false;
    syncing = false;
    notifyListeners();


@@ 49,7 49,7 @@ class SyncModel with ChangeNotifier {
    syncing = true;
    notifyListeners();
    try {
      await Global.service.reauthenticate();
      await Global.service!.reauthenticate();
      await Global.sourcesModel.updateSources();
      await Global.itemsModel.syncItems();
      await Global.itemsModel.fetchItems();


@@ 63,4 63,4 @@ class SyncModel with ChangeNotifier {
    syncing = false;
    notifyListeners();
  }
}
\ No newline at end of file
}

M lib/pages/article_page.dart => lib/pages/article_page.dart +59 -46
@@ 17,7 17,7 @@ import 'package:intl/intl.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
import 'package:share/share.dart';
import 'package:share_plus/share_plus.dart';
import 'package:fluent_reader_lite/components/cupertino_toolbar.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';


@@ 34,16 34,27 @@ class ArticlePage extends StatefulWidget {
enum _ArticleLoadState { Loading, Success, Failure }

class ArticlePageState extends State<ArticlePage> {
  WebViewController _controller;
  WebViewController? _controller;
  int requestId = 0;
  _ArticleLoadState loaded = _ArticleLoadState.Loading;
  bool navigated = false;
  SourceOpenTarget _target;
  String iid;
  bool isSourceFeed;
  SourceOpenTarget? _target;
  String? iid;
  bool? isSourceFeed;

  void loadNewItem(String id, {bool isSource}) {
    if (!Global.itemsModel.getItem(id).hasRead) {
  WebViewController _createController() {
    final controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onNavigationRequest: _onNavigate,
        onPageStarted: _onPageReady,
        onPageFinished: _onWebpageReady,
      ));
    return controller;
  }

  void loadNewItem(String id, {bool? isSource}) {
    if (!Global.itemsModel.getItem(id)!.hasRead) {
      Global.itemsModel.updateItem(id, read: true);
    }
    setState(() {


@@ 55,18 66,20 @@ class ArticlePageState extends State<ArticlePage> {
    });
  }

  Future<NavigationDecision> _onNavigate(NavigationRequest request) async {
    if (navigated && request.isForMainFrame) {
  NavigationDecision _onNavigate(NavigationRequest request) {
    if (navigated && request.isMainFrame) {
      final internal = Global.globalModel.inAppBrowser;
      await launch(request.url,
          forceSafariVC: internal, forceWebView: internal);
      launchUrl(
        Uri.parse(request.url),
        mode: internal ? LaunchMode.inAppWebView : LaunchMode.externalApplication,
      );
      return NavigationDecision.prevent;
    } else {
      return NavigationDecision.navigate;
    }
  }

  void _loadHtml(RSSItem item, RSSSource source, {loadFull: false}) async {
  void _loadHtml(RSSItem item, RSSSource source, {bool loadFull = false}) async {
    var localUrl = "http://127.0.0.1:9000/article/article.html";
    var currId = requestId;
    String a;


@@ 88,7 101,7 @@ class ArticlePageState extends State<ArticlePage> {
    }
    if (!mounted || currId != requestId) return;
    var h =
        '<p id="source">${source.name}${(item.creator != null && item.creator.length > 0) ? ' / ' + item.creator : ''}</p>';
        '<p id="source">${source.name}${item.creator.isNotEmpty ? ' / ' + item.creator : ''}</p>';
    h += '<p id="title">${item.title}</p>';
    h +=
        '<p id="date">${DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(item.date)}</p>';


@@ 108,10 121,10 @@ class ArticlePageState extends State<ArticlePage> {
      var brightness = Global.currentBrightness(context);
      localUrl += "&t=${brightness.index}";
    }
    _controller.loadUrl(localUrl);
    _controller?.loadRequest(Uri.parse(localUrl));
  }

  void _onPageReady(_) async {
  void _onPageReady(String url) async {
    if (Platform.isAndroid || Global.globalModel.getBrightness() != null) {
      await Future.delayed(Duration(milliseconds: 300));
    }


@@ 124,14 137,13 @@ class ArticlePageState extends State<ArticlePage> {
    }
  }

  void _onWebpageReady(_) {
  void _onWebpageReady(String url) {
    if (loaded == _ArticleLoadState.Success) navigated = true;
  }

  void _setOpenTarget(RSSSource source, {SourceOpenTarget target}) {
    setState(() {
      _target = target ?? source.openTarget;
    });
  void _setOpenTarget(RSSItem item, RSSSource source, {SourceOpenTarget? target}) {
    _target = target ?? Global.resolveOpenTarget(source);
    _loadOpenTarget(item, source);
  }

  void _loadOpenTarget(RSSItem item, RSSSource source) {


@@ 140,7 152,7 @@ class ArticlePageState extends State<ArticlePage> {
      loaded = _ArticleLoadState.Loading;
      navigated = false;
    });
    switch (_target) {
    switch (_target!) {
      case SourceOpenTarget.Local:
        _loadHtml(item, source);
        break;


@@ 149,7 161,10 @@ class ArticlePageState extends State<ArticlePage> {
        break;
      case SourceOpenTarget.Webpage:
      case SourceOpenTarget.External:
        _controller.loadUrl(item.link);
        _controller?.loadRequest(Uri.parse(item.link));
        break;
      case SourceOpenTarget.Inherit:
        _loadHtml(item, source);
        break;
    }
  }


@@ 157,9 172,9 @@ class ArticlePageState extends State<ArticlePage> {
  @override
  Widget build(BuildContext context) {
    final Tuple2<String, bool> arguments =
        ModalRoute.of(context).settings.arguments;
    if (iid == null) iid = arguments.item1;
    if (isSourceFeed == null) isSourceFeed = arguments.item2;
        ModalRoute.of(context)!.settings.arguments as Tuple2<String, bool>;
    iid ??= arguments.item1;
    isSourceFeed ??= arguments.item2;
    final resolvedDarkGrey = MyColors.dynamicDarkGrey.resolveFrom(context);
    final viewOptions = {
      0: Padding(


@@ 183,29 198,26 @@ class ArticlePageState extends State<ArticlePage> {
    };
    return Selector2<ItemsModel, SourcesModel, Tuple2<RSSItem, RSSSource>>(
      selector: (context, itemsModel, sourcesModel) {
        var item = itemsModel.getItem(iid);
        var source = sourcesModel.getSource(item.source);
        var item = itemsModel.getItem(iid!)!;
        var source = sourcesModel.getSource(item.source)!;
        return Tuple2(item, source);
      },
      builder: (context, tuple, child) {
        var item = tuple.item1;
        var source = tuple.item2;
        if (_target == null) _target = source.openTarget;
        _target ??= Global.resolveOpenTarget(source);
        if (_controller == null) {
          _controller = _createController();
          Future.microtask(() => _loadOpenTarget(item, source));
        }
        final body = SafeArea(
          child: IndexedStack(
            index: loaded.index,
            children: [
              Center(child: CupertinoActivityIndicator()),
              WebView(
                key: Key("a-$iid-${_target.index}"),
                javascriptMode: JavascriptMode.unrestricted,
                onWebViewCreated: (WebViewController webViewController) {
                  _controller = webViewController;
                  _loadOpenTarget(item, source);
                },
                onPageStarted: _onPageReady,
                onPageFinished: _onWebpageReady,
                navigationDelegate: _onNavigate,
              WebViewWidget(
                key: Key("a-$iid-${_target!.index}"),
                controller: _controller!,
              ),
              Center(
                child: Column(


@@ 232,19 244,19 @@ class ArticlePageState extends State<ArticlePage> {
        return CupertinoPageScaffold(
          navigationBar: CupertinoNavigationBar(
            backgroundColor: CupertinoColors.systemBackground,
            middle: CupertinoSlidingSegmentedControl(
            middle: CupertinoSlidingSegmentedControl<int>(
              children: viewOptions,
              onValueChanged: (v) {
                _setOpenTarget(source, target: SourceOpenTarget.values[v]);
                _setOpenTarget(item, source, target: SourceOpenTarget.values[v!]);
              },
              groupValue: _target.index,
              groupValue: _target!.index > 2 ? 2 : _target!.index,
            ),
          ),
          child: Consumer<FeedsModel>(
            child: body,
            builder: (context, feedsModel, child) {
              final feed = isSourceFeed ? feedsModel.source : feedsModel.all;
              var idx = feed.iids.indexOf(iid);
              final feed = isSourceFeed! ? feedsModel.source! : feedsModel.all;
              var idx = feed.iids.indexOf(iid!);
              return CupertinoToolbar(
                items: [
                  CupertinoToolbarItem(


@@ 281,7 293,8 @@ class ArticlePageState extends State<ArticlePage> {
                      Share.share(item.link,
                          sharePositionOrigin: Rect.fromLTWH(
                              media.size.width -
                                  ArticlePage.state.currentContext.size.width /
                                  (ArticlePage.state.currentContext?.size?.width ??
                                      0) /
                                      2,
                              media.size.height - media.padding.bottom - 54,
                              0,


@@ 307,14 320,14 @@ class ArticlePageState extends State<ArticlePage> {
                            if (idx == feed.iids.length - 1) {
                              await feed.loadMore();
                            }
                            idx = feed.iids.indexOf(iid);
                            idx = feed.iids.indexOf(iid!);
                            if (idx != feed.iids.length - 1) {
                              loadNewItem(feed.iids[idx + 1]);
                            }
                          },
                  ),
                ],
                body: child,
                body: child!,
              );
            },
          ),

M lib/pages/group_list_page.dart => lib/pages/group_list_page.dart +72 -20
@@ 1,16 1,19 @@
import 'package:fluent_reader_lite/components/badge.dart';
import 'package:fluent_reader_lite/components/dismissible_background.dart';
import 'package:fluent_reader_lite/components/list_tile_group.dart';
import 'package:fluent_reader_lite/components/mark_all_action_sheet.dart';
import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/models/groups_model.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';

class GroupListPage extends StatefulWidget {
  @override


@@ 18,12 21,19 @@ class GroupListPage extends StatefulWidget {
}

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

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

  void _showGroupSettings(BuildContext context, String groupName) {
    HapticFeedback.mediumImpact();
    Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => _GroupSettingsPage(groupName: groupName),
    ));
  }

  static const _dismissThresholds = {
    DismissDirection.startToEnd: 0.25,
  };


@@ 55,35 65,40 @@ class _GroupListPageState extends State<GroupListPage> {
    final dismissBg = DismissibleBackground(CupertinoIcons.checkmark_circle, true);
    final groupList = Consumer2<GroupsModel, SourcesModel>(
      builder: (context, groupsModel, sourcesModel, child) {
        final groupNames = groupsModel.groups.keys.toList();
        groupNames.sort(Utils.localStringCompare);
        if (groupsModel.uncategorized != null) {
          groupNames.insert(0, null);
        }
        final sortedKeys = groupsModel.groups.keys.toList();
        sortedKeys.sort(Utils.localStringCompare);
        final hasUncategorized = groupsModel.uncategorized != null;
        final totalCount = sortedKeys.length + (hasUncategorized ? 1 : 0);
        return SliverList(
          delegate: SliverChildBuilderDelegate((context, index) {
            String groupName;
            List<String> group;
            final isUncategorized = groupsModel.showUncategorized && index == 0;
            final isUncategorized = groupsModel.showUncategorized && hasUncategorized && index == 0;
            if (isUncategorized) {
              groupName = S.of(context).uncategorized;
              group = groupsModel.uncategorized;
              group = groupsModel.uncategorized!;
            } else {
              groupName = groupNames[index];
              group = groupsModel.groups[groupName];
              final adjustedIndex = hasUncategorized ? index - 1 : index;
              groupName = sortedKeys[adjustedIndex];
              group = groupsModel.groups[groupName]!;
            }
            final count = _unreadCount(
              group.map((sid) => sourcesModel.getSource(sid))
            );
            final tile = MyListTile(
              title: Flexible(child: Text(groupName, overflow: TextOverflow.ellipsis)),
              trailing: count > 0 ? Badge(count) : null,
              onTap: () { 
                Navigator.of(context).pop(
                  isUncategorized ? _uncategorizedIndicator : [groupName]
                );
            final tile = GestureDetector(
              onLongPress: isUncategorized ? null : () {
                _showGroupSettings(context, groupName);
              },
              background: CupertinoColors.systemBackground,
              child: MyListTile(
                title: Flexible(child: Text(groupName, overflow: TextOverflow.ellipsis)),
                trailing: count > 0 ? Badge(count) : null,
                onTap: () {
                  Navigator.of(context).pop(
                    isUncategorized ? _uncategorizedIndicator : [groupName]
                  );
                },
                background: CupertinoColors.systemBackground,
              ),
            );
            return Dismissible(
              key: Key("$groupName$index"),


@@ 101,7 116,7 @@ class _GroupListPageState extends State<GroupListPage> {
                return false;
              },
            );
          }, childCount: groupNames.length),
          }, childCount: totalCount),
        );
      },
    );


@@ 121,3 136,40 @@ class _GroupListPageState extends State<GroupListPage> {
    );
  }
}

class _GroupSettingsPage extends StatelessWidget {
  final String groupName;

  const _GroupSettingsPage({required this.groupName});

  @override
  Widget build(BuildContext context) {
    return Consumer<GroupsModel>(
      builder: (context, groupsModel, child) {
        final openTarget = ListTileGroup.fromOptions(
          [
            Tuple2(S.of(context).inheritDefault, SourceOpenTarget.Inherit),
            Tuple2(S.of(context).rssText, SourceOpenTarget.Local),
            Tuple2(S.of(context).loadFull, SourceOpenTarget.FullContent),
            Tuple2(S.of(context).loadWebpage, SourceOpenTarget.Webpage),
            Tuple2(S.of(context).openExternal, SourceOpenTarget.External),
          ],
          groupsModel.getGroupOpenTarget(groupName),
          (v) {
            groupsModel.setGroupOpenTarget(groupName, v);
          },
          title: S.of(context).openTarget,
        );
        return CupertinoPageScaffold(
          backgroundColor: MyColors.background,
          navigationBar: CupertinoNavigationBar(
            middle: Text(groupName, overflow: TextOverflow.ellipsis),
          ),
          child: ListView(children: [
            openTarget,
          ]),
        );
      },
    );
  }
}

M lib/pages/home_page.dart => lib/pages/home_page.dart +9 -8
@@ 12,7 12,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:responsive_builder/responsive_builder.dart';
import 'package:uni_links/uni_links.dart';
import 'package:app_links/app_links.dart';

import 'item_list_page.dart';



@@ 40,9 40,9 @@ class _HomePageState extends State<HomePage> {
    GlobalKey(),
    GlobalKey(),
  ];
  StreamSubscription _uriSub;
  StreamSubscription? _uriSub;

  void _uriStreamListener(Uri uri) {
  void _uriStreamListener(Uri? uri) {
    if (uri == null) return;
    if (uri.host == "import") {
      if (Global.syncModel.hasService) {


@@ 75,10 75,11 @@ class _HomePageState extends State<HomePage> {
  @override
  void initState() {
    super.initState();
    _uriSub = uriLinkStream.listen(_uriStreamListener);
    final appLinks = AppLinks();
    _uriSub = appLinks.uriLinkStream.listen(_uriStreamListener);
    Future.delayed(Duration.zero, () async {
      try {
        final uri = await getInitialUri();
        final uri = await appLinks.getInitialLink();
        if (uri != null) {
          _uriStreamListener(uri);
        }


@@ 90,7 91,7 @@ class _HomePageState extends State<HomePage> {

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



@@ 107,7 108,7 @@ class _HomePageState extends State<HomePage> {
          );
  }

  Widget buildLeft(BuildContext context, {isMobile: true}) {
  Widget buildLeft(BuildContext context, {bool isMobile = true}) {
    final leftTabs = CupertinoTabScaffold(
      controller: _controller,
      backgroundColor: CupertinoColors.systemBackground,


@@ 147,7 148,7 @@ class _HomePageState extends State<HomePage> {
      child: leftTabs,
      onWillPop: () async {
        return !(await _tabNavigatorKeys[_controller.index]
            .currentState
            .currentState!
            .maybePop());
      },
    );

M lib/pages/item_list_page.dart => lib/pages/item_list_page.dart +11 -11
@@ 15,7 15,7 @@ import 'package:fluent_reader_lite/utils/global.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:provider/provider.dart';
import 'package:share/share.dart';
import 'package:share_plus/share_plus.dart';
import 'package:tuple/tuple.dart';

import 'home_page.dart';


@@ 23,14 23,14 @@ import 'home_page.dart';
class ItemListPage extends StatefulWidget {
  final ScrollTopNotifier scrollTopNotifier;

  ItemListPage(this.scrollTopNotifier, {Key key}) : super(key: key);
  ItemListPage(this.scrollTopNotifier, {Key? key}) : super(key: key);

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

class _ItemListPageState extends State<ItemListPage> {
  DateTime lastLoadedMore;
  DateTime? lastLoadedMore;

  void _onScrollTop() {
    var expectedCanPop = widget.scrollTopNotifier.index == 1;


@@ 56,14 56,14 @@ class _ItemListPageState extends State<ItemListPage> {
  }

  RSSFeed getFeed() {
    return ModalRoute.of(context).settings.arguments != null
        ? Global.feedsModel.source
    return ModalRoute.of(context)!.settings.arguments != null
        ? Global.feedsModel.source!
        : Global.feedsModel.all;
  }

  bool _onScroll(ScrollNotification scrollInfo) {
    var feed = getFeed();
    if (!ModalRoute.of(context).isCurrent ||
    if (!ModalRoute.of(context)!.isCurrent ||
        !feed.initialized ||
        feed.loading ||
        feed.allLoaded) {


@@ 72,7 72,7 @@ class _ItemListPageState extends State<ItemListPage> {
    if (scrollInfo.metrics.extentAfter == 0.0 &&
        scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent * 0.8 &&
        (lastLoadedMore == null ||
            DateTime.now().difference(lastLoadedMore).inSeconds > 1)) {
            DateTime.now().difference(lastLoadedMore!).inSeconds > 1)) {
      lastLoadedMore = DateTime.now();
      feed.loadMore();
    }


@@ 175,7 175,7 @@ class _ItemListPageState extends State<ItemListPage> {
  }

  void _editSearchKeyword() async {
    String keyword = await Navigator.of(context).push(CupertinoPageRoute(
    String? keyword = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).editKeyword,
        (v) => v.trim().length > 0,


@@ 296,7 296,7 @@ class _ItemListPageState extends State<ItemListPage> {

  @override
  Widget build(BuildContext context) {
    final String title = ModalRoute.of(context).settings.arguments;
    final String? title = ModalRoute.of(context)!.settings.arguments as String?;
    final titleWidget = Row(
      mainAxisSize: MainAxisSize.min,
      children: [


@@ 374,8 374,8 @@ class _ItemListPageState extends State<ItemListPage> {
            return Selector2<ItemsModel, SourcesModel,
                Tuple2<RSSItem, RSSSource>>(
              selector: (context, itemsModel, sourcesModel) {
                var item = itemsModel.getItem(feed.iids[index]);
                var source = sourcesModel.getSource(item.source);
                var item = itemsModel.getItem(feed.iids[index])!;
                var source = sourcesModel.getSource(item.source)!;
                return Tuple2(item, source);
              },
              builder: (context, tuple, child) =>

M lib/pages/settings/about_page.dart => lib/pages/settings/about_page.dart +1 -1
@@ 8,7 8,7 @@ import 'package:flutter/cupertino.dart';
class AboutPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final String version = ModalRoute.of(context).settings.arguments ?? "1.0.0";
    final String version = (ModalRoute.of(context)!.settings.arguments as String?) ?? "1.0.0";
    final nameStyle = TextStyle(
      color: CupertinoColors.label.resolveFrom(context),
      fontSize: 18,

M lib/pages/settings/feed_page.dart => lib/pages/settings/feed_page.dart +2 -2
@@ 106,12 106,12 @@ class FeedPage extends StatelessWidget {
              ListTileGroup([
                MyListTile(
                  title: Text(S.of(context).swipeRight),
                  trailing: Text(swipeOptons[feedsModel.swipeR]),
                  trailing: Text(swipeOptons[feedsModel.swipeR]!),
                  onTap: () { _openGestureOptions(context, true); },
                ),
                MyListTile(
                  title: Text(S.of(context).swipeLeft),
                  trailing: Text(swipeOptons[feedsModel.swipeL]),
                  trailing: Text(swipeOptons[feedsModel.swipeL]!),
                  onTap: () { _openGestureOptions(context, false); },
                  withDivider: false,
                ),

M lib/pages/settings/general_page.dart => lib/pages/settings/general_page.dart +17 -2
@@ 2,6 2,7 @@ import 'package:fluent_reader_lite/components/list_tile_group.dart';
import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/models/global_model.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';


@@ 15,7 16,7 @@ class GeneralPage extends StatefulWidget {

class _GeneralPageState extends State<GeneralPage> {
  bool _clearingCache = false;
  double textScale;
  double? textScale;

  void _clearCache() async {
    setState(() {


@@ 57,7 58,7 @@ class _GeneralPageState extends State<GeneralPage> {
                    min: 0.5,
                    max: 1.5,
                    divisions: 8,
                    value: textScale ?? globalModel.textScale,
                    value: textScale ?? globalModel.textScale ?? 1.0,
                    onChanged: (v) {
                      setState(() {
                        textScale = v;


@@ 139,6 140,19 @@ class _GeneralPageState extends State<GeneralPage> {
              },
              title: S.of(context).theme,
            );
            final openTargetItems = ListTileGroup.fromOptions(
              [
                Tuple2(S.of(context).rssText, SourceOpenTarget.Local),
                Tuple2(S.of(context).loadFull, SourceOpenTarget.FullContent),
                Tuple2(S.of(context).loadWebpage, SourceOpenTarget.Webpage),
                Tuple2(S.of(context).openExternal, SourceOpenTarget.External),
              ],
              globalModel.globalOpenTarget,
              (v) {
                globalModel.globalOpenTarget = v;
              },
              title: S.of(context).openTarget,
            );
            final localeItems = ListTileGroup.fromOptions(
              [
                Tuple2(S.of(context).followSystem, null),


@@ 161,6 175,7 @@ class _GeneralPageState extends State<GeneralPage> {
            return ListView(
              children: [
                syncItems,
                openTargetItems,
                textScaleItems,
                storageItems,
                themeItems,

M lib/pages/settings/services/feedbin_page.dart => lib/pages/settings/services/feedbin_page.dart +12 -12
@@ 32,16 32,16 @@ class _FeedbinPageState extends State<FeedbinPage> {
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      final import = ModalRoute.of(context)!.settings.arguments as ServiceImport?;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
        setState(() { _endpoint = import.endpoint!; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
        setState(() { _username = import.username!; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final bytes = base64.decode(import.password!);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }


@@ 49,9 49,9 @@ class _FeedbinPageState extends State<FeedbinPage> {
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
    final String? endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).endpoint, 
        S.of(context).endpoint,
        Utils.testUrl,
        initialValue: _endpoint,
        inputType: TextInputType.url,


@@ 66,9 66,9 @@ class _FeedbinPageState extends State<FeedbinPage> {
  }

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
    final String? username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).username, 
        S.of(context).username,
        Utils.notEmpty,
        initialValue: _username,
      ),


@@ 78,9 78,9 @@ class _FeedbinPageState extends State<FeedbinPage> {
  }

  void _editPassword() async {
    final String password = await Navigator.of(context).push(CupertinoPageRoute(
    final String? password = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).password, 
        S.of(context).password,
        Utils.notEmpty,
        inputType: TextInputType.visiblePassword,
      ),


@@ 123,7 123,7 @@ class _FeedbinPageState extends State<FeedbinPage> {
  }

  void _logOut() async {
    final bool confirmed = await showCupertinoDialog(
    final bool? confirmed = await showCupertinoDialog<bool>(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).logOutWarning),


@@ 145,7 145,7 @@ class _FeedbinPageState extends State<FeedbinPage> {
        ],
      ),
    );
    if (confirmed != null) {
    if (confirmed == true) {
      setState(() { _validating = true; });
      DialogHelper().show(
        context,

M lib/pages/settings/services/fever_page.dart => lib/pages/settings/services/fever_page.dart +12 -12
@@ 25,7 25,7 @@ class FeverPage extends StatefulWidget {
class _FeverPageState extends State<FeverPage> {
  String _endpoint = Store.sp.getString(StoreKeys.ENDPOINT) ?? "";
  String _username = Store.sp.getString(StoreKeys.USERNAME) ?? "";
  String _apiKey = Store.sp.getString(StoreKeys.API_KEY);
  String? _apiKey = Store.sp.getString(StoreKeys.API_KEY);
  String _password = Store.sp.getString(StoreKeys.PASSWORD) ?? "";
  int _fetchLimit = Store.sp.getInt(StoreKeys.FETCH_LIMIT) ?? 250;
  bool _validating = false;


@@ 34,13 34,13 @@ class _FeverPageState extends State<FeverPage> {
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      final import = ModalRoute.of(context)!.settings.arguments as ServiceImport?;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
        setState(() { _endpoint = import.endpoint!; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
        setState(() { _username = import.username!; });
      }
      if (Utils.notEmpty(import.apiKey)) {
        setState(() { _apiKey = import.apiKey; });


@@ 49,9 49,9 @@ class _FeverPageState extends State<FeverPage> {
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
    final String? endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).endpoint, 
        S.of(context).endpoint,
        Utils.testUrl,
        initialValue: _endpoint,
        inputType: TextInputType.url,


@@ 62,9 62,9 @@ class _FeverPageState extends State<FeverPage> {
  }

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
    final String? username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).username, 
        S.of(context).username,
        Utils.notEmpty,
        initialValue: _username,
      ),


@@ 77,9 77,9 @@ class _FeverPageState extends State<FeverPage> {
  }

  void _editPassword() async {
    final String password = await Navigator.of(context).push(CupertinoPageRoute(
    final String? password = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).password, 
        S.of(context).password,
        Utils.notEmpty,
        inputType: TextInputType.visiblePassword,
      ),


@@ 127,7 127,7 @@ class _FeverPageState extends State<FeverPage> {
  }

  void _logOut() async {
    final bool confirmed = await showCupertinoDialog(
    final bool? confirmed = await showCupertinoDialog<bool>(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).logOutWarning),


@@ 149,7 149,7 @@ class _FeverPageState extends State<FeverPage> {
        ],
      ),
    );
    if (confirmed != null) {
    if (confirmed == true) {
      setState(() { _validating = true; });
      DialogHelper().show(
        context,

M lib/pages/settings/services/greader_page.dart => lib/pages/settings/services/greader_page.dart +12 -12
@@ 33,16 33,16 @@ class _GReaderPageState extends State<GReaderPage> {
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      final import = ModalRoute.of(context)!.settings.arguments as ServiceImport?;
      if (import == null) return;
      if (Utils.testUrl(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
        setState(() { _endpoint = import.endpoint!; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
        setState(() { _username = import.username!; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final bytes = base64.decode(import.password!);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }


@@ 50,9 50,9 @@ class _GReaderPageState extends State<GReaderPage> {
  }

  void _editEndpoint() async {
    final String endpoint = await Navigator.of(context).push(CupertinoPageRoute(
    final String? endpoint = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).endpoint, 
        S.of(context).endpoint,
        Utils.testUrl,
        initialValue: _endpoint,
        inputType: TextInputType.url,


@@ 67,9 67,9 @@ class _GReaderPageState extends State<GReaderPage> {
  }

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
    final String? username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).username, 
        S.of(context).username,
        Utils.notEmpty,
        initialValue: _username,
      ),


@@ 79,9 79,9 @@ class _GReaderPageState extends State<GReaderPage> {
  }

  void _editPassword() async {
    final String password = await Navigator.of(context).push(CupertinoPageRoute(
    final String? password = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).password, 
        S.of(context).password,
        Utils.notEmpty,
        inputType: TextInputType.visiblePassword,
      ),


@@ 127,7 127,7 @@ class _GReaderPageState extends State<GReaderPage> {
  }

  void _logOut() async {
    final bool confirmed = await showCupertinoDialog(
    final bool? confirmed = await showCupertinoDialog<bool>(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).logOutWarning),


@@ 149,7 149,7 @@ class _GReaderPageState extends State<GReaderPage> {
        ],
      ),
    );
    if (confirmed != null) {
    if (confirmed == true) {
      setState(() { _validating = true; });
      DialogHelper().show(
        context,

M lib/pages/settings/services/inoreader_page.dart => lib/pages/settings/services/inoreader_page.dart +20 -17
@@ 44,32 44,32 @@ class _InoreaderPageState extends State<InoreaderPage> {
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      ServiceImport import = ModalRoute.of(context).settings.arguments;
      final import = ModalRoute.of(context)!.settings.arguments as ServiceImport?;
      if (import == null) return;
      if (_endpointOptions.contains(import.endpoint)) {
        setState(() { _endpoint = import.endpoint; });
        setState(() { _endpoint = import.endpoint!; });
      }
      if (Utils.notEmpty(import.username)) {
        setState(() { _username = import.username; });
        setState(() { _username = import.username!; });
      }
      if (Utils.notEmpty(import.password)) {
        final bytes = base64.decode(import.password);
        final bytes = base64.decode(import.password!);
        final password = utf8.decode(bytes);
        setState(() { _password = password; });
      }
      if (Utils.notEmpty(import.apiId)) {
        setState(() { _apiId = import.apiId; });
        setState(() { _apiId = import.apiId!; });
      }
      if (Utils.notEmpty(import.apiKey)) {
        setState(() { _apiKey = import.apiKey; });
        setState(() { _apiKey = import.apiKey!; });
      }
    });
  }

  void _editUsername() async {
    final String username = await Navigator.of(context).push(CupertinoPageRoute(
    final String? username = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).username, 
        S.of(context).username,
        Utils.notEmpty,
        initialValue: _username,
      ),


@@ 79,9 79,9 @@ class _InoreaderPageState extends State<InoreaderPage> {
  }

  void _editPassword() async {
    final String password = await Navigator.of(context).push(CupertinoPageRoute(
    final String? password = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).password, 
        S.of(context).password,
        Utils.notEmpty,
        inputType: TextInputType.visiblePassword,
      ),


@@ 91,9 91,9 @@ class _InoreaderPageState extends State<InoreaderPage> {
  }

  void _editAPIId() async {
    final String apiId = await Navigator.of(context).push(CupertinoPageRoute(
    final String? apiId = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        "API ID", 
        "API ID",
        Utils.notEmpty,
        initialValue: _apiId,
        inputType: TextInputType.number,


@@ 104,9 104,9 @@ class _InoreaderPageState extends State<InoreaderPage> {
  }

  void _editAPIKey() async {
    final String apiKey = await Navigator.of(context).push(CupertinoPageRoute(
    final String? apiKey = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        "API Key", 
        "API Key",
        Utils.notEmpty,
        initialValue: _apiKey,
      ),


@@ 156,7 156,7 @@ class _InoreaderPageState extends State<InoreaderPage> {
  }

  void _logOut() async {
    final bool confirmed = await showCupertinoDialog(
    final bool? confirmed = await showCupertinoDialog<bool>(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).logOutWarning),


@@ 178,7 178,7 @@ class _InoreaderPageState extends State<InoreaderPage> {
        ],
      ),
    );
    if (confirmed != null) {
    if (confirmed == true) {
      setState(() { _validating = true; });
      DialogHelper().show(
        context,


@@ 193,7 193,10 @@ class _InoreaderPageState extends State<InoreaderPage> {
  }

  void _getKey() {
    launch(_endpoint + "/all_articles#preferences-developer", forceSafariVC: false, forceWebView: false);
    launchUrl(
      Uri.parse(_endpoint + "/all_articles#preferences-developer"),
      mode: LaunchMode.externalApplication,
    );
  }

  @override

M lib/pages/settings/source_edit_page.dart => lib/pages/settings/source_edit_page.dart +6 -5
@@ 14,7 14,7 @@ import 'package:tuple/tuple.dart';

class SourceEditPage extends StatelessWidget {
  void _editName(BuildContext context, RSSSource source) async {
    final String name = await Navigator.of(context).push(CupertinoPageRoute(
    final String? name = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).name,
        (v) => v.trim().length > 0,


@@ 28,7 28,7 @@ class SourceEditPage extends StatelessWidget {
  }

  void _editIcon(BuildContext context, RSSSource source) async {
    final String iconUrl = await Navigator.of(context).push(CupertinoPageRoute(
    final String? iconUrl = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).icon,
        (v) async {


@@ 36,7 36,7 @@ class SourceEditPage extends StatelessWidget {
          if (trimmed.length == 0) return false;
          return await Utils.validateFavicon(trimmed);
        },
        initialValue: source.iconUrl,
        initialValue: source.iconUrl ?? "",
      ),
    ));
    if (iconUrl == null || iconUrl == source.iconUrl) return;


@@ 47,9 47,9 @@ class SourceEditPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    final String sid = ModalRoute.of(context).settings.arguments;
    final String sid = ModalRoute.of(context)!.settings.arguments as String;
    return Selector<SourcesModel, RSSSource>(
      selector: (context, sourcesModel) => sourcesModel.getSource(sid),
      selector: (context, sourcesModel) => sourcesModel.getSource(sid)!,
      builder: (context, source, child) {
        final urlStyle = TextStyle(
          color: CupertinoColors.secondaryLabel.resolveFrom(context),


@@ 79,6 79,7 @@ class SourceEditPage extends StatelessWidget {
        ], title: S.of(context).edit);
        final openTarget = ListTileGroup.fromOptions(
          [
            Tuple2(S.of(context).inheritDefault, SourceOpenTarget.Inherit),
            Tuple2(S.of(context).rssText, SourceOpenTarget.Local),
            Tuple2(S.of(context).loadFull, SourceOpenTarget.FullContent),
            Tuple2(S.of(context).loadWebpage, SourceOpenTarget.Webpage),

M lib/pages/settings/text_editor_page.dart => lib/pages/settings/text_editor_page.dart +10 -10
@@ 8,13 8,13 @@ import 'package:flutter/cupertino.dart';

class TextEditorPage extends StatefulWidget {
  final String title;
  final String saveText;
  final String? saveText;
  final String initialValue;
  final Color navigationBarColor;
  final Color? navigationBarColor;
  final FutureOr<bool> Function(String) validate;
  final TextInputType inputType;
  final TextInputType? inputType;
  final bool autocorrect;
  final List<String> suggestions;
  final List<String>? suggestions;

  TextEditorPage(
    this.title,


@@ 22,11 22,11 @@ class TextEditorPage extends StatefulWidget {
    {
      this.navigationBarColor,
      this.saveText,
      this.initialValue: "",
      this.initialValue = "",
      this.inputType,
      this.autocorrect: false,
      this.autocorrect = false,
      this.suggestions,
      Key key,
      Key? key,
    })
    : super(key: key);



@@ 35,7 35,7 @@ class TextEditorPage extends StatefulWidget {
}

class _TextEditorPage extends State<TextEditorPage> {
  TextEditingController _controller;
  late TextEditingController _controller;
  bool _validating = false;

  @override


@@ 68,7 68,7 @@ class _TextEditorPage extends State<TextEditorPage> {
      );
    }
  }
  

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(


@@ 100,7 100,7 @@ class _TextEditorPage extends State<TextEditorPage> {
            enableSuggestions: widget.autocorrect,
          ),
        ]),
        if (widget.suggestions != null) ...widget.suggestions.map((s) {
        if (widget.suggestions != null) ...widget.suggestions!.map((s) {
          return MyListTile(
            title: Flexible(child: Text(
              s,

M lib/pages/subscription_list_page.dart => lib/pages/subscription_list_page.dart +183 -16
@@ 1,6 1,7 @@
import 'package:fluent_reader_lite/components/badge.dart';
import 'package:fluent_reader_lite/components/mark_all_action_sheet.dart';
import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/components/responsive_action_sheet.dart';
import 'package:fluent_reader_lite/components/subscription_item.dart';
import 'package:fluent_reader_lite/components/sync_control.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';


@@ 9,6 10,7 @@ import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';
import 'package:fluent_reader_lite/pages/group_list_page.dart';
import 'package:fluent_reader_lite/pages/home_page.dart';
import 'package:fluent_reader_lite/pages/settings/text_editor_page.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';


@@ 19,10 21,17 @@ import 'package:intl/intl.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';

enum SubscriptionSortType {
  ByLatest,
  ByNameAsc,
  ByNameDesc,
  ByUnread,
}

class SubscriptionListPage extends StatefulWidget {
  final ScrollTopNotifier scrollTopNotifier;

  SubscriptionListPage(this.scrollTopNotifier, {Key key}) : super(key: key);
  SubscriptionListPage(this.scrollTopNotifier, {Key? key}) : super(key: key);

  @override
  _SubscriptionListPageState createState() {


@@ 31,10 40,13 @@ class SubscriptionListPage extends StatefulWidget {
}

class _SubscriptionListPageState extends State<SubscriptionListPage> {
  List<String> sids;
  String title;
  List<String>? sids;
  String? title;
  bool transitioning = false;
  bool unreadOnly = Store.sp.getBool(StoreKeys.UNREAD_SUBS_ONLY) ?? false;
  String _search = "";
  SubscriptionSortType _sortType = SubscriptionSortType.values[
      Store.sp.getInt(StoreKeys.SUBSCRIPTION_SORT) ?? 0];

  void _onScrollTop() {
    if (widget.scrollTopNotifier.index == 1 &&


@@ 60,7 72,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
  }

  void _openGroups() async {
    List<String> result;
    List<String>? result;
    if (Global.isTablet) {
      result = await Navigator.of(context).push(CupertinoPageRoute(
        builder: (context) => GroupListPage(),


@@ 90,7 102,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
        });
      } else {
        setState(() {
          title = result[0];
          title = result![0];
          sids = Global.groupsModel.groups[title];
        });
      }


@@ 105,7 117,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
    showCupertinoModalPopup(
      context: context,
      builder: (context) =>
          MarkAllActionSheet(sids == null ? {} : Set.from(sids)),
          MarkAllActionSheet(sids == null ? {} : Set.from(sids!)),
    );
  }



@@ 136,6 148,129 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
    }
  }

  static const _iconPadding = Padding(padding: EdgeInsets.only(left: 24));

  void _setSortType(SubscriptionSortType type) {
    setState(() {
      _sortType = type;
    });
    _onScrollTop();
    Store.sp.setInt(StoreKeys.SUBSCRIPTION_SORT, type.index);
  }

  void _editSearchKeyword() async {
    String? keyword = await Navigator.of(context).push(CupertinoPageRoute(
      builder: (context) => TextEditorPage(
        S.of(context).editKeyword,
        (v) => v.trim().length > 0,
        saveText: S.of(context).search,
        initialValue: _search,
        navigationBarColor: CupertinoColors.systemBackground,
        autocorrect: true,
      ),
    ));
    if (keyword == null) return;
    setState(() {
      _search = keyword;
    });
    _onScrollTop();
  }

  void _openFilterModal() {
    showCupertinoModalPopup(
        context: context,
        builder: (context) {
          final sheet = CupertinoActionSheet(
            title: Text(S.of(context).sortBy),
            actions: [
              CupertinoActionSheetAction(
                child: Row(children: [
                  Icon(CupertinoIcons.time),
                  Text(S.of(context).sortByLatest),
                  _iconPadding,
                ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                onPressed: () {
                  Navigator.of(context, rootNavigator: true).pop();
                  _setSortType(SubscriptionSortType.ByLatest);
                },
              ),
              CupertinoActionSheetAction(
                child: Row(children: [
                  Icon(CupertinoIcons.sort_up),
                  Text(S.of(context).sortByNameAsc),
                  _iconPadding,
                ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                onPressed: () {
                  Navigator.of(context, rootNavigator: true).pop();
                  _setSortType(SubscriptionSortType.ByNameAsc);
                },
              ),
              CupertinoActionSheetAction(
                child: Row(children: [
                  Icon(CupertinoIcons.sort_down),
                  Text(S.of(context).sortByNameDesc),
                  _iconPadding,
                ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                onPressed: () {
                  Navigator.of(context, rootNavigator: true).pop();
                  _setSortType(SubscriptionSortType.ByNameDesc);
                },
              ),
              CupertinoActionSheetAction(
                child: Row(children: [
                  Icon(Icons.radio_button_checked),
                  Text(S.of(context).sortByUnread),
                  _iconPadding,
                ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                onPressed: () {
                  Navigator.of(context, rootNavigator: true).pop();
                  _setSortType(SubscriptionSortType.ByUnread);
                },
              ),
              CupertinoActionSheetAction(
                isDestructiveAction: true,
                child: Row(children: [
                  Icon(CupertinoIcons.search,
                      color: CupertinoColors.destructiveRed),
                  Text(_search.length > 0
                      ? S.of(context).editKeyword
                      : S.of(context).search),
                  _iconPadding,
                ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                onPressed: () {
                  Navigator.of(context, rootNavigator: true).pop();
                  _editSearchKeyword();
                },
              ),
              if (_search.length > 0)
                CupertinoActionSheetAction(
                  isDestructiveAction: true,
                  child: Row(children: [
                    Icon(CupertinoIcons.clear_fill,
                        color: CupertinoColors.destructiveRed),
                    Text(S.of(context).clearSearch),
                    _iconPadding,
                  ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                  onPressed: () {
                    Navigator.of(context, rootNavigator: true).pop();
                    setState(() {
                      _search = "";
                    });
                    _onScrollTop();
                  },
                ),
            ],
            cancelButton: CupertinoActionSheetAction(
              child: Text(S.of(context).cancel),
              onPressed: () {
                Navigator.of(context, rootNavigator: true).pop();
              },
            ),
          );
          return ResponsiveActionSheet(sheet);
        });
  }

  Widget _buildUnreadTip() {
    return SliverToBoxAdapter(
        child: Container(


@@ 202,6 337,11 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
              padding: EdgeInsets.only(left: 4),
              child: Icon(Icons.radio_button_checked, size: 18),
            ),
          if (_search.length > 0)
            Padding(
              padding: EdgeInsets.only(left: 4),
              child: Icon(CupertinoIcons.search, size: 18),
            ),
        ],
      ),
    );


@@ 227,6 367,18 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
              CupertinoButton(
                padding: EdgeInsets.zero,
                child: Icon(
                  (_sortType != SubscriptionSortType.ByLatest ||
                          _search.length > 0)
                      ? CupertinoIcons
                          .line_horizontal_3_decrease_circle_fill
                      : CupertinoIcons.line_horizontal_3_decrease_circle,
                  semanticLabel: S.of(context).sortBy,
                ),
                onPressed: _openFilterModal,
              ),
              CupertinoButton(
                padding: EdgeInsets.zero,
                child: Icon(
                  CupertinoIcons.checkmark_circle,
                  semanticLabel: S.of(context).markAll,
                ),


@@ 253,17 405,32 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
          }
        } else {
          sources = [];
          for (var sid in sids) {
          for (var sid in sids!) {
            final source = Global.sourcesModel.getSource(sid);
            if (!unreadOnly || source.unreadCount > 0) {
            if (source != null && (!unreadOnly || source.unreadCount > 0)) {
              sources.add(source);
            }
          }
        }
        // Latest sources first
        sources.sort((a, b) {
          return b.latest.compareTo(a.latest);
        });
        if (_search.length > 0) {
          final keyword = _search.toUpperCase();
          sources =
              sources.where((s) => s.name.toUpperCase().contains(keyword)).toList();
        }
        switch (_sortType) {
          case SubscriptionSortType.ByLatest:
            sources.sort((a, b) => b.latest.compareTo(a.latest));
            break;
          case SubscriptionSortType.ByNameAsc:
            sources.sort((a, b) => a.name.compareTo(b.name));
            break;
          case SubscriptionSortType.ByNameDesc:
            sources.sort((a, b) => b.name.compareTo(a.name));
            break;
          case SubscriptionSortType.ByUnread:
            sources.sort((a, b) => b.unreadCount.compareTo(a.unreadCount));
            break;
        }
        return SliverList(
          delegate: SliverChildBuilderDelegate((content, index) {
            var source = sources[index];


@@ 309,19 476,19 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> {
      navigationBar,
      SyncControl(),
      if (Global.sourcesModel.showUnreadTip) _buildUnreadTip(),
      if (sids != null && sids.length > 0)
      if (sids != null && sids!.length > 0)
        Consumer<SourcesModel>(
          builder: (context, sourcesModel, child) {
            var count = sids
            var count = sids!
                .map((sid) => sourcesModel.getSource(sid))
                .fold(0, (c, s) => c + s.unreadCount);
                .fold(0, (c, s) => c + (s?.unreadCount ?? 0));
            return SliverToBoxAdapter(
                child: MyListTile(
              title: Text(S.of(context).allArticles),
              trailing: count > 0 ? Badge(count) : null,
              trailingChevron: false,
              onTap: () async {
                await Global.feedsModel.initSourcesFeed(sids.toList());
                await Global.feedsModel.initSourcesFeed(sids!.toList());
                Navigator.of(context).pushNamed("/feed", arguments: title);
              },
              background: CupertinoColors.systemBackground,

M lib/utils/db.dart => lib/utils/db.dart +4 -4
@@ 7,13 7,13 @@ abstract class DatabaseHelper {
  static final _dbName = "frlite.db";
  static final _dbVersion = 1;

  static Database _database;
  static Database? _database;

  static Future<Database> getDatabase() async {
    if (_database != null) return _database;
    if (_database != null) return _database!;
    String path = join(await getDatabasesPath(), _dbName);
    _database = await openDatabase(path, version:_dbVersion, onCreate: _onCreate);
    return _database;
    return _database!;
  }

  static Future<void> _onCreate(Database db, int version) async {


@@ 45,4 45,4 @@ abstract class DatabaseHelper {
    ''');
    await db.execute("CREATE INDEX itemsDate ON items (date DESC);");
  }
}
\ No newline at end of file
}

M lib/utils/global.dart => lib/utils/global.dart +27 -14
@@ 5,6 5,7 @@ import 'package:fluent_reader_lite/models/global_model.dart';
import 'package:fluent_reader_lite/models/groups_model.dart';
import 'package:fluent_reader_lite/models/items_model.dart';
import 'package:fluent_reader_lite/models/service.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:fluent_reader_lite/models/services/feedbin.dart';
import 'package:fluent_reader_lite/models/services/fever.dart';
import 'package:fluent_reader_lite/models/services/greader.dart';


@@ 20,15 21,15 @@ import 'package:sqflite/sqflite.dart';

abstract class Global {
  static bool _initialized = false;
  static GlobalModel globalModel;
  static SourcesModel sourcesModel;
  static ItemsModel itemsModel;
  static FeedsModel feedsModel;
  static GroupsModel groupsModel;
  static SyncModel syncModel;
  static ServiceHandler service;
  static Database db;
  static Jaguar server;
  static late GlobalModel globalModel;
  static late SourcesModel sourcesModel;
  static late ItemsModel itemsModel;
  static late FeedsModel feedsModel;
  static late GroupsModel groupsModel;
  static late SyncModel syncModel;
  static ServiceHandler? service;
  static late Database db;
  static Jaguar? server;
  static final GlobalKey<NavigatorState> tabletPanel = GlobalKey();

  static void init() {


@@ 70,11 71,11 @@ abstract class Global {
      ],
    );
    server = Jaguar(address: "127.0.0.1",port: 9000);
    server.addRoute(serveFlutterAssets());
    server!.addRoute(serveFlutterAssets());

    // Serve custom font files from app documents directory
    final fontsDir = await FontManager.getFontsDirectory();
    server.get('/custom-fonts/:filename*', (ctx) async {
    server!.get('/custom-fonts/:filename*', (ctx) async {
      final filename = ctx.pathParams['filename'];
      if (filename == null || filename.isEmpty) {
        return Response(statusCode: 404);


@@ 97,7 98,7 @@ abstract class Global {
      return ByteResponse(body: bytes, mimeType: mimeType);
    });

    await server.serve();
    await server!.serve();
    await sourcesModel.init();
    await feedsModel.all.init();
    if (globalModel.syncOnStart) await syncModel.syncWithService();


@@ 107,11 108,23 @@ abstract class Global {
    return globalModel.getBrightness() ?? MediaQuery.of(context).platformBrightness;
  }

  static SourceOpenTarget resolveOpenTarget(RSSSource source) {
    var groupName = groupsModel.findGroupForSource(source.id);
    var groupTarget = groupName != null
        ? groupsModel.getGroupOpenTarget(groupName)
        : SourceOpenTarget.Inherit;
    return resolveOpenTargetCascade(
      source.openTarget,
      groupTarget,
      globalModel.globalOpenTarget,
    );
  }

  static bool get isTablet => tabletPanel.currentWidget != null;

  static NavigatorState responsiveNavigator(BuildContext context) {
    return tabletPanel.currentWidget != null
      ? Global.tabletPanel.currentState
      ? Global.tabletPanel.currentState!
      : Navigator.of(context, rootNavigator: true);
  }
}
\ No newline at end of file
}

M lib/utils/store.dart => lib/utils/store.dart +46 -12
@@ 1,6 1,7 @@
import 'dart:convert';

import 'package:fluent_reader_lite/models/global_model.dart';
import 'package:fluent_reader_lite/models/source.dart';
import 'package:flutter/cupertino.dart';
import 'package:shared_preferences/shared_preferences.dart';



@@ 9,7 10,8 @@ abstract class StoreKeys {
  static const ERROR_LOG = "errorLog";
  static const UNCATEGORIZED = "uncategorized";
  static const UNREAD_SUBS_ONLY = "unreadSubsOnly";
 
  static const SUBSCRIPTION_SORT = "subSort";

  // General
  static const THEME = "theme";
  static const LOCALE = "locale";


@@ 49,15 51,20 @@ abstract class StoreKeys {
  static const AUTH = "auth";
  static const USE_INT_64 = "useInt64";
  static const INOREADER_REMOVE_AD = "inoRemoveAd";

  // Default open target
  static const GLOBAL_OPEN_TARGET = "globalOpenTarget";
  static const GROUP_OPEN_TARGETS = "groupOpenTargets";
}

class Store {
  // Initialized in main.dart
  static SharedPreferences sp;
  static late SharedPreferences sp;

  static Locale getLocale() {
  static Locale? getLocale() {
    if (!sp.containsKey(StoreKeys.LOCALE)) return null;
    var localeString = sp.getString(StoreKeys.LOCALE);
    if (localeString == null) return null;
    var splitted = localeString.split('_');
    if (splitted.length > 1) {
      return Locale(splitted[0], splitted[1]);


@@ 66,14 73,14 @@ class Store {
    }
  }

  static void setLocale(Locale locale) {
  static void setLocale(Locale? locale) {
    if (locale == null) sp.remove(StoreKeys.LOCALE);
    else sp.setString(StoreKeys.LOCALE, locale.toString());
  }

  static ThemeSetting getTheme() {
    return sp.containsKey(StoreKeys.THEME) 
    ? ThemeSetting.values[sp.getInt(StoreKeys.THEME)]
    return sp.containsKey(StoreKeys.THEME)
    ? ThemeSetting.values[sp.getInt(StoreKeys.THEME)!]
    : ThemeSetting.Default;
  }



@@ 96,14 103,14 @@ class Store {
    sp.setString(StoreKeys.GROUPS, jsonEncode(groups));
  }

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

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


@@ 114,7 121,7 @@ class Store {
  static int getArticleFontSize() {
    return sp.getInt(StoreKeys.ARTICLE_FONT_SIZE) ?? 16;
  }
  

  static void setArticleFontSize(int value) {
    sp.setInt(StoreKeys.ARTICLE_FONT_SIZE, value);
  }


@@ 135,15 142,42 @@ class Store {
    sp.setString(StoreKeys.FONT_FAMILY, value);
  }

  static String getCustomFontPath() {
  static String? getCustomFontPath() {
    return sp.getString(StoreKeys.CUSTOM_FONT_PATH);
  }

  static void setCustomFontPath(String value) {
  static void setCustomFontPath(String? value) {
    if (value == null) {
      sp.remove(StoreKeys.CUSTOM_FONT_PATH);
    } else {
      sp.setString(StoreKeys.CUSTOM_FONT_PATH, value);
    }
  }
}
\ No newline at end of file

  static SourceOpenTarget getGlobalOpenTarget() {
    var idx = sp.getInt(StoreKeys.GLOBAL_OPEN_TARGET);
    if (idx == null || idx >= SourceOpenTarget.values.length) {
      return SourceOpenTarget.Local;
    }
    return SourceOpenTarget.values[idx];
  }

  static void setGlobalOpenTarget(SourceOpenTarget target) {
    sp.setInt(StoreKeys.GLOBAL_OPEN_TARGET, target.index);
  }

  static Map<String, int> getGroupOpenTargets() {
    var stored = sp.getString(StoreKeys.GROUP_OPEN_TARGETS);
    if (stored == null) return {};
    Map<String, int> result = {};
    var parsed = jsonDecode(stored);
    for (var key in parsed.keys) {
      result[key] = parsed[key] as int;
    }
    return result;
  }

  static void setGroupOpenTargets(Map<String, int> targets) {
    sp.setString(StoreKeys.GROUP_OPEN_TARGETS, jsonEncode(targets));
  }
}

M lib/utils/utils.dart => lib/utils/utils.dart +3 -3
@@ 8,7 8,7 @@ abstract class Utils {
  static const syncMaxId = 9007199254740991;

  static void openExternal(String url) {
    launch(url, forceSafariVC: false, forceWebView: false);
    launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
  }

  static int binarySearch<T>(


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

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

  static void showServiceFailureDialog(BuildContext context) {
    showCupertinoDialog(

M pubspec.lock => pubspec.lock +284 -172
@@ 1,14 1,46 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
  app_links:
    dependency: "direct main"
    description:
      name: app_links
      sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
      url: "https://pub.dev"
    source: hosted
    version: "6.4.1"
  app_links_linux:
    dependency: transitive
    description:
      name: app_links_linux
      sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
      url: "https://pub.dev"
    source: hosted
    version: "1.0.3"
  app_links_platform_interface:
    dependency: transitive
    description:
      name: app_links_platform_interface
      sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
      url: "https://pub.dev"
    source: hosted
    version: "2.0.2"
  app_links_web:
    dependency: transitive
    description:
      name: app_links_web
      sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
      url: "https://pub.dev"
    source: hosted
    version: "1.0.4"
  async:
    dependency: transitive
    description:
      name: async
      sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0
      sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
      url: "https://pub.dev"
    source: hosted
    version: "2.10.0"
    version: "2.13.0"
  auth_header:
    dependency: transitive
    description:


@@ 21,42 53,42 @@ packages:
    dependency: transitive
    description:
      name: boolean_selector
      sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
      sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.1"
    version: "2.1.2"
  cached_network_image:
    dependency: "direct main"
    description:
      name: cached_network_image
      sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15
      sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f"
      url: "https://pub.dev"
    source: hosted
    version: "3.2.3"
    version: "3.3.1"
  cached_network_image_platform_interface:
    dependency: transitive
    description:
      name: cached_network_image_platform_interface
      sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7
      sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f"
      url: "https://pub.dev"
    source: hosted
    version: "2.0.0"
    version: "4.0.0"
  cached_network_image_web:
    dependency: transitive
    description:
      name: cached_network_image_web
      sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0
      sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.2"
    version: "1.2.0"
  characters:
    dependency: transitive
    description:
      name: characters
      sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
      sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
      url: "https://pub.dev"
    source: hosted
    version: "1.4.0"
    version: "1.4.1"
  clock:
    dependency: transitive
    description:


@@ 65,6 97,14 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "1.1.2"
  code_assets:
    dependency: transitive
    description:
      name: code_assets
      sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.0"
  collection:
    dependency: transitive
    description:


@@ 73,30 113,38 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "1.19.1"
  cross_file:
    dependency: transitive
    description:
      name: cross_file
      sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
      url: "https://pub.dev"
    source: hosted
    version: "0.3.5+2"
  crypto:
    dependency: "direct main"
    description:
      name: crypto
      sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
      sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
      url: "https://pub.dev"
    source: hosted
    version: "3.0.3"
    version: "3.0.7"
  csslib:
    dependency: transitive
    description:
      name: csslib
      sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
      sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.0"
    version: "1.0.2"
  cupertino_icons:
    dependency: "direct main"
    description:
      name: cupertino_icons
      sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d
      sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
      url: "https://pub.dev"
    source: hosted
    version: "1.0.6"
    version: "1.0.8"
  fake_async:
    dependency: transitive
    description:


@@ 109,39 157,39 @@ packages:
    dependency: transitive
    description:
      name: ffi
      sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99
      sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
      url: "https://pub.dev"
    source: hosted
    version: "2.0.2"
    version: "2.2.0"
  file:
    dependency: transitive
    description:
      name: file
      sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
      sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
      url: "https://pub.dev"
    source: hosted
    version: "6.1.4"
    version: "7.0.1"
  file_picker:
    dependency: "direct main"
    description:
      name: file_picker
      sha256: "9d6e95ec73abbd31ec54d0e0df8a961017e165aba1395e462e5b31ea0c165daf"
      sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
      url: "https://pub.dev"
    source: hosted
    version: "5.3.1"
    version: "8.3.7"
  fixnum:
    dependency: transitive
    description:
      name: fixnum
      sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
      url: "https://pub.dev"
    source: hosted
    version: "1.1.1"
  flutter:
    dependency: "direct main"
    description: flutter
    source: sdk
    version: "0.0.0"
  flutter_blurhash:
    dependency: transitive
    description:
      name: flutter_blurhash
      sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6"
      url: "https://pub.dev"
    source: hosted
    version: "0.7.0"
  flutter_cache_manager:
    dependency: "direct main"
    description:


@@ 159,10 207,10 @@ packages:
    dependency: transitive
    description:
      name: flutter_plugin_android_lifecycle
      sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31
      sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
      url: "https://pub.dev"
    source: hosted
    version: "2.0.30"
    version: "2.0.33"
  flutter_test:
    dependency: "direct dev"
    description: flutter


@@ 173,14 221,38 @@ packages:
    description: flutter
    source: sdk
    version: "0.0.0"
  glob:
    dependency: transitive
    description:
      name: glob
      sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
      url: "https://pub.dev"
    source: hosted
    version: "2.1.3"
  gtk:
    dependency: transitive
    description:
      name: gtk
      sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
      url: "https://pub.dev"
    source: hosted
    version: "2.1.0"
  hooks:
    dependency: transitive
    description:
      name: hooks
      sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.1"
  html:
    dependency: "direct main"
    description:
      name: html
      sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
      sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
      url: "https://pub.dev"
    source: hosted
    version: "0.15.4"
    version: "0.15.6"
  http:
    dependency: "direct main"
    description:


@@ 193,10 265,10 @@ packages:
    dependency: transitive
    description:
      name: http_parser
      sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
      url: "https://pub.dev"
    source: hosted
    version: "4.0.2"
    version: "4.1.2"
  http_server:
    dependency: transitive
    description:


@@ 217,10 289,10 @@ packages:
    dependency: "direct main"
    description:
      name: jaguar
      sha256: "1614ea947a81f2160fd6f962c5bff0b3f11eb6e5417d47140d447f4758a7f164"
      sha256: "07d8203fb1432c4228e434e64a2b6b7e03eb4c585ef024de05e3ddf517f29471"
      url: "https://pub.dev"
    source: hosted
    version: "3.1.3"
    version: "3.1.4"
  jaguar_common:
    dependency: transitive
    description:


@@ 265,10 337,10 @@ packages:
    dependency: transitive
    description:
      name: logging
      sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
      sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
      url: "https://pub.dev"
    source: hosted
    version: "1.2.0"
    version: "1.3.0"
  lpinyin:
    dependency: "direct main"
    description:


@@ 281,42 353,50 @@ packages:
    dependency: transitive
    description:
      name: matcher
      sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
      sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
      url: "https://pub.dev"
    source: hosted
    version: "0.12.17"
    version: "0.12.18"
  material_color_utilities:
    dependency: transitive
    description:
      name: material_color_utilities
      sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
      sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
      url: "https://pub.dev"
    source: hosted
    version: "0.11.1"
    version: "0.13.0"
  meta:
    dependency: transitive
    description:
      name: meta
      sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
      sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
      url: "https://pub.dev"
    source: hosted
    version: "1.16.0"
    version: "1.17.0"
  mime:
    dependency: transitive
    description:
      name: mime
      sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e
      sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.4"
    version: "1.0.6"
  modal_bottom_sheet:
    dependency: "direct main"
    description:
      name: modal_bottom_sheet
      sha256: "3bba63c62d35c931bce7f8ae23a47f9a05836d8cb3c11122ada64e0b2f3d718f"
      sha256: eac66ef8cb0461bf069a38c5eb0fa728cee525a531a8304bd3f7b2185407c67e
      url: "https://pub.dev"
    source: hosted
    version: "3.0.0"
  native_toolchain_c:
    dependency: transitive
    description:
      name: native_toolchain_c
      sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
      url: "https://pub.dev"
    source: hosted
    version: "3.0.0-pre"
    version: "0.17.4"
  nested:
    dependency: transitive
    description:


@@ 325,14 405,22 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "1.0.0"
  objective_c:
    dependency: transitive
    description:
      name: objective_c
      sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
      url: "https://pub.dev"
    source: hosted
    version: "9.3.0"
  octo_image:
    dependency: transitive
    description:
      name: octo_image
      sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143"
      sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.2"
    version: "2.1.0"
  overlay_dialog:
    dependency: "direct main"
    description:


@@ 345,10 433,10 @@ packages:
    dependency: "direct main"
    description:
      name: package_info_plus
      sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a"
      sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017"
      url: "https://pub.dev"
    source: hosted
    version: "4.1.0"
    version: "4.2.0"
  package_info_plus_platform_interface:
    dependency: transitive
    description:


@@ 369,26 457,26 @@ packages:
    dependency: "direct main"
    description:
      name: path_provider
      sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa
      sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.1"
    version: "2.1.5"
  path_provider_android:
    dependency: transitive
    description:
      name: path_provider_android
      sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1"
      sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
      url: "https://pub.dev"
    source: hosted
    version: "2.2.0"
    version: "2.2.22"
  path_provider_foundation:
    dependency: transitive
    description:
      name: path_provider_foundation
      sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d"
      sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
      url: "https://pub.dev"
    source: hosted
    version: "2.3.1"
    version: "2.6.0"
  path_provider_linux:
    dependency: transitive
    description:


@@ 401,18 489,18 @@ packages:
    dependency: transitive
    description:
      name: path_provider_platform_interface
      sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c"
      sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.1"
    version: "2.1.2"
  path_provider_windows:
    dependency: transitive
    description:
      name: path_provider_windows
      sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
      sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
      url: "https://pub.dev"
    source: hosted
    version: "2.2.1"
    version: "2.3.0"
  path_tree:
    dependency: transitive
    description:


@@ 425,26 513,34 @@ packages:
    dependency: transitive
    description:
      name: platform
      sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
      sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
      url: "https://pub.dev"
    source: hosted
    version: "3.1.2"
    version: "3.1.6"
  plugin_platform_interface:
    dependency: transitive
    description:
      name: plugin_platform_interface
      sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
      sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.6"
    version: "2.1.8"
  provider:
    dependency: "direct main"
    description:
      name: provider
      sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f
      sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
      url: "https://pub.dev"
    source: hosted
    version: "6.1.5+1"
  pub_semver:
    dependency: transitive
    description:
      name: pub_semver
      sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
      url: "https://pub.dev"
    source: hosted
    version: "6.0.5"
    version: "2.2.0"
  responsive_builder:
    dependency: "direct main"
    description:


@@ 461,70 557,78 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "0.27.7"
  share:
  share_plus:
    dependency: "direct main"
    description:
      name: share
      sha256: "97e6403f564ed1051a01534c2fc919cb6e40ea55e60a18ec23cee6e0ce19f4be"
      name: share_plus
      sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900"
      url: "https://pub.dev"
    source: hosted
    version: "2.0.4"
    version: "7.2.2"
  share_plus_platform_interface:
    dependency: transitive
    description:
      name: share_plus_platform_interface
      sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
      url: "https://pub.dev"
    source: hosted
    version: "3.4.0"
  shared_preferences:
    dependency: "direct main"
    description:
      name: shared_preferences
      sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac
      sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
      url: "https://pub.dev"
    source: hosted
    version: "2.2.1"
    version: "2.5.4"
  shared_preferences_android:
    dependency: transitive
    description:
      name: shared_preferences_android
      sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06"
      sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
      url: "https://pub.dev"
    source: hosted
    version: "2.2.1"
    version: "2.4.20"
  shared_preferences_foundation:
    dependency: transitive
    description:
      name: shared_preferences_foundation
      sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7"
      sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
      url: "https://pub.dev"
    source: hosted
    version: "2.3.4"
    version: "2.5.6"
  shared_preferences_linux:
    dependency: transitive
    description:
      name: shared_preferences_linux
      sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a
      sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
      url: "https://pub.dev"
    source: hosted
    version: "2.3.1"
    version: "2.4.1"
  shared_preferences_platform_interface:
    dependency: transitive
    description:
      name: shared_preferences_platform_interface
      sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a
      sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
      url: "https://pub.dev"
    source: hosted
    version: "2.3.1"
    version: "2.4.1"
  shared_preferences_web:
    dependency: transitive
    description:
      name: shared_preferences_web
      sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf
      sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
      url: "https://pub.dev"
    source: hosted
    version: "2.2.1"
    version: "2.4.3"
  shared_preferences_windows:
    dependency: transitive
    description:
      name: shared_preferences_windows
      sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f
      sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
      url: "https://pub.dev"
    source: hosted
    version: "2.3.1"
    version: "2.4.1"
  sky_engine:
    dependency: transitive
    description: flutter


@@ 534,34 638,50 @@ packages:
    dependency: transitive
    description:
      name: source_span
      sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250
      url: "https://pub.dev"
    source: hosted
    version: "1.9.1"
  sprintf:
    dependency: transitive
    description:
      name: sprintf
      sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
      sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
      url: "https://pub.dev"
    source: hosted
    version: "7.0.0"
    version: "1.10.2"
  sqflite:
    dependency: "direct main"
    description:
      name: sqflite
      sha256: b4d6710e1200e96845747e37338ea8a819a12b51689a3bcf31eff0003b37a0b9
      sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
      url: "https://pub.dev"
    source: hosted
    version: "2.2.8+4"
    version: "2.4.2"
  sqflite_android:
    dependency: transitive
    description:
      name: sqflite_android
      sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
      url: "https://pub.dev"
    source: hosted
    version: "2.4.2+2"
  sqflite_common:
    dependency: transitive
    description:
      name: sqflite_common
      sha256: "8f7603f3f8f126740bc55c4ca2d1027aab4b74a1267a3e31ce51fe40e3b65b8f"
      sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
      url: "https://pub.dev"
    source: hosted
    version: "2.5.6"
  sqflite_darwin:
    dependency: transitive
    description:
      name: sqflite_darwin
      sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
      url: "https://pub.dev"
    source: hosted
    version: "2.4.5+1"
    version: "2.4.2"
  sqflite_platform_interface:
    dependency: transitive
    description:
      name: sqflite_platform_interface
      sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
      url: "https://pub.dev"
    source: hosted
    version: "2.4.0"
  stack_trace:
    dependency: transitive
    description:


@@ 582,34 702,34 @@ packages:
    dependency: transitive
    description:
      name: string_scanner
      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
      sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
      url: "https://pub.dev"
    source: hosted
    version: "1.2.0"
    version: "1.4.1"
  synchronized:
    dependency: transitive
    description:
      name: synchronized
      sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60"
      sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
      url: "https://pub.dev"
    source: hosted
    version: "3.1.0"
    version: "3.4.0"
  term_glyph:
    dependency: transitive
    description:
      name: term_glyph
      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
      sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
      url: "https://pub.dev"
    source: hosted
    version: "1.2.1"
    version: "1.2.2"
  test_api:
    dependency: transitive
    description:
      name: test_api
      sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
      sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
      url: "https://pub.dev"
    source: hosted
    version: "0.7.6"
    version: "0.7.9"
  tuple:
    dependency: "direct main"
    description:


@@ 622,114 742,90 @@ packages:
    dependency: transitive
    description:
      name: typed_data
      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
      url: "https://pub.dev"
    source: hosted
    version: "1.3.2"
  uni_links:
    dependency: "direct main"
    description:
      name: uni_links
      sha256: "051098acfc9e26a9fde03b487bef5d3d228ca8f67693480c6f33fd4fbb8e2b6e"
      url: "https://pub.dev"
    source: hosted
    version: "0.5.1"
  uni_links_platform_interface:
    dependency: transitive
    description:
      name: uni_links_platform_interface
      sha256: "929cf1a71b59e3b7c2d8a2605a9cf7e0b125b13bc858e55083d88c62722d4507"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.0"
  uni_links_web:
    dependency: transitive
    description:
      name: uni_links_web
      sha256: "7539db908e25f67de2438e33cc1020b30ab94e66720b5677ba6763b25f6394df"
      sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
      url: "https://pub.dev"
    source: hosted
    version: "0.1.0"
    version: "1.4.0"
  universal_io:
    dependency: transitive
    description:
      name: universal_io
      sha256: "06866290206d196064fd61df4c7aea1ffe9a4e7c4ccaa8fcded42dd41948005d"
      sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
      url: "https://pub.dev"
    source: hosted
    version: "2.2.0"
    version: "2.3.1"
  url_launcher:
    dependency: "direct main"
    description:
      name: url_launcher
      sha256: eb1e00ab44303d50dd487aab67ebc575456c146c6af44422f9c13889984c00f3
      sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
      url: "https://pub.dev"
    source: hosted
    version: "6.1.11"
    version: "6.3.2"
  url_launcher_android:
    dependency: transitive
    description:
      name: url_launcher_android
      sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330
      sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
      url: "https://pub.dev"
    source: hosted
    version: "6.1.0"
    version: "6.3.28"
  url_launcher_ios:
    dependency: transitive
    description:
      name: url_launcher_ios
      sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f"
      sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
      url: "https://pub.dev"
    source: hosted
    version: "6.1.5"
    version: "6.4.1"
  url_launcher_linux:
    dependency: transitive
    description:
      name: url_launcher_linux
      sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e
      sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
      url: "https://pub.dev"
    source: hosted
    version: "3.0.6"
    version: "3.2.2"
  url_launcher_macos:
    dependency: transitive
    description:
      name: url_launcher_macos
      sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88
      sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
      url: "https://pub.dev"
    source: hosted
    version: "3.0.7"
    version: "3.2.5"
  url_launcher_platform_interface:
    dependency: transitive
    description:
      name: url_launcher_platform_interface
      sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618"
      sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.5"
    version: "2.3.2"
  url_launcher_web:
    dependency: transitive
    description:
      name: url_launcher_web
      sha256: ba140138558fcc3eead51a1c42e92a9fb074a1b1149ed3c73e66035b2ccd94f2
      sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
      url: "https://pub.dev"
    source: hosted
    version: "2.0.19"
    version: "2.4.2"
  url_launcher_windows:
    dependency: transitive
    description:
      name: url_launcher_windows
      sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069"
      sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
      url: "https://pub.dev"
    source: hosted
    version: "3.0.8"
    version: "3.1.5"
  uuid:
    dependency: transitive
    description:
      name: uuid
      sha256: e03928880bdbcbf496fb415573f5ab7b1ea99b9b04f669c01104d085893c3134
      sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
      url: "https://pub.dev"
    source: hosted
    version: "4.0.0"
    version: "4.5.2"
  vector_math:
    dependency: transitive
    description:


@@ 746,54 842,70 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "15.0.2"
  web:
    dependency: transitive
    description:
      name: web
      sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
      url: "https://pub.dev"
    source: hosted
    version: "1.1.1"
  webview_flutter:
    dependency: "direct main"
    description:
      name: webview_flutter
      sha256: "392c1d83b70fe2495de3ea2c84531268d5b8de2de3f01086a53334d8b6030a88"
      sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
      url: "https://pub.dev"
    source: hosted
    version: "3.0.4"
    version: "4.13.1"
  webview_flutter_android:
    dependency: transitive
    description:
      name: webview_flutter_android
      sha256: "8b3b2450e98876c70bfcead876d9390573b34b9418c19e28168b74f6cb252dbd"
      sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
      url: "https://pub.dev"
    source: hosted
    version: "2.10.4"
    version: "4.10.11"
  webview_flutter_platform_interface:
    dependency: transitive
    description:
      name: webview_flutter_platform_interface
      sha256: "812165e4e34ca677bdfbfa58c01e33b27fd03ab5fa75b70832d4b7d4ca1fa8cf"
      sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
      url: "https://pub.dev"
    source: hosted
    version: "1.9.5"
    version: "2.14.0"
  webview_flutter_wkwebview:
    dependency: transitive
    description:
      name: webview_flutter_wkwebview
      sha256: a5364369c758892aa487cbf59ea41d9edd10f9d9baf06a94e80f1bd1b4c7bbc0
      sha256: "0412b657a2828fb301e73509909e6ec02b77cd2b441ae9f77125d482b3ddf0e7"
      url: "https://pub.dev"
    source: hosted
    version: "2.9.5"
    version: "3.23.6"
  win32:
    dependency: transitive
    description:
      name: win32
      sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
      sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
      url: "https://pub.dev"
    source: hosted
    version: "4.1.4"
    version: "5.15.0"
  xdg_directories:
    dependency: transitive
    description:
      name: xdg_directories
      sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2"
      sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
      url: "https://pub.dev"
    source: hosted
    version: "1.0.3"
    version: "1.1.0"
  yaml:
    dependency: transitive
    description:
      name: yaml
      sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
      url: "https://pub.dev"
    source: hosted
    version: "3.1.3"
sdks:
  dart: ">=3.8.0-0 <4.0.0"
  flutter: ">=3.29.0"
  dart: ">=3.10.3 <4.0.0"
  flutter: ">=3.38.4"

M pubspec.yaml => pubspec.yaml +5 -5
@@ 31,23 31,23 @@ dependencies:
  intl: 0.20.2
  http: ^0.13.4
  html: ^0.15.0
  webview_flutter: ^3.0.4
  webview_flutter: ^4.0.0
  jaguar: ^3.0.11
  jaguar_flutter_asset: ^3.0.0
  url_launcher: ^6.0.9
  sqflite: ^2.0.0+3
  path: ^1.8.0
  share: ^2.0.4
  share_plus: ^7.0.0
  package_info_plus: ^4.1.0
  crypto: ^3.0.1
  responsive_builder: ^0.4.1
  cached_network_image: ^3.2.1
  cached_network_image: ^3.3.1
  flutter_cache_manager: ^3.3.0
  lpinyin: ^2.0.3
  uni_links: ^0.5.1
  app_links: ^6.0.0
  modal_bottom_sheet: ^3.0.0-pre
  overlay_dialog: ^0.2.0
  file_picker: ^5.0.0
  file_picker: ^8.0.4
  path_provider: ^2.0.0