~cytrogen/fluent-reader-mobile

34469ca0231cd9ea216f42aab16cb9e3714bb4e7 — HallowDem 4 days ago 7fa7c7f
feat: enhance custom font upload with WebView rendering, WOFF/WOFF2 support, preview & deletion

- Fix core bug: custom fonts now actually render in article WebView via
  @font-face injection and Jaguar local server font serving route
- Also fix built-in fonts (OpenSans/Roboto/SourceSerif) not rendering in WebView
- Add WOFF/WOFF2 format support for font upload
- Add font preview text in font picker and selected font indicator
- Add custom font deletion UI with confirmation dialog
- Add font file size validation (1KB-50MB)
- Update CSP to allow font-src from local server
- Add missing i18n translations for all 9 locales (zh, de, es, fr, hr, pt, tr, uk)
- Update Android build configuration and dependencies
75 files changed, 1053 insertions(+), 101 deletions(-)

M .metadata
A analysis_options.yaml
A android-old/.gitignore
A android-old/app/build.gradle
A android-old/app/proguard-rules.pro
A android-old/app/src/debug/AndroidManifest.xml
A android-old/app/src/main/AndroidManifest.xml
A android-old/app/src/main/ic_launcher-playstore.png
A android-old/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt
A android-old/app/src/main/res/drawable-night/launch_background.xml
A android-old/app/src/main/res/drawable/launch_background.xml
A android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
A android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
A android-old/app/src/main/res/mipmap-hdpi/ic_launcher.png
A android-old/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
A android-old/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher.png
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher.png
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
A android-old/app/src/main/res/mipmap-xxxhdpi/logo.png
A android-old/app/src/main/res/values/ic_launcher_background.xml
A android-old/app/src/main/res/values/styles.xml
A android-old/app/src/profile/AndroidManifest.xml
A android-old/build.gradle
A android-old/gradle.properties
A android-old/gradle/wrapper/gradle-wrapper.properties
A android-old/settings.gradle
M android/.gitignore
M android/app/build.gradle
M android/app/src/debug/AndroidManifest.xml
M android/app/src/main/AndroidManifest.xml
M android/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt
A android/app/src/main/res/drawable-v21/launch_background.xml
M android/app/src/main/res/drawable/launch_background.xml
M android/app/src/main/res/mipmap-hdpi/ic_launcher.png
M android/app/src/main/res/mipmap-mdpi/ic_launcher.png
M android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
M android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
M android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
A android/app/src/main/res/values-night/styles.xml
M android/app/src/profile/AndroidManifest.xml
M android/build.gradle
M android/gradle.properties
M android/gradle/wrapper/gradle-wrapper.properties
M android/settings.gradle
M assets/article/article.html
M assets/article/article.js
A ios/Flutter/ephemeral/flutter_lldb_helper.py
A ios/Flutter/ephemeral/flutter_lldbinit
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/models/global_model.dart
M lib/pages/article_page.dart
M lib/pages/settings/reading_page.dart
A lib/utils/font_manager.dart
M lib/utils/global.dart
M lib/utils/store.dart
M pubspec.lock
M pubspec.yaml
M .metadata => .metadata +22 -2
@@ 4,7 4,27 @@
# This file should be version controlled and should not be manually edited.

version:
  revision: 78910062997c3a836feee883712c241a5fd22983
  channel: stable
  revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
  channel: "stable"

project_type: app

# Tracks metadata for the flutter migrate command
migration:
  platforms:
    - platform: root
      create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
      base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
    - platform: android
      create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
      base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2

  # User provided section

  # List of Local paths (relative to this file) that should be
  # ignored by the migrate tool.
  #
  # Files that are not part of the templates will be ignored by default.
  unmanaged_files:
    - 'lib/main.dart'
    - 'ios/Runner.xcodeproj/project.pbxproj'

A analysis_options.yaml => analysis_options.yaml +28 -0
@@ 0,0 1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml

linter:
  # The lint rules applied to this project can be customized in the
  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
  # included above or to enable additional rules. A list of all available lints
  # and their documentation is published at https://dart.dev/lints.
  #
  # Instead of disabling a lint rule for the entire project in the
  # section below, it can also be suppressed for a single line of code
  # or a specific dart file by using the `// ignore: name_of_lint` and
  # `// ignore_for_file: name_of_lint` syntax on the line or in the file
  # producing the lint.
  rules:
    # avoid_print: false  # Uncomment to disable the `avoid_print` rule
    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

A android-old/.gitignore => android-old/.gitignore +11 -0
@@ 0,0 1,11 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java

# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties

A android-old/app/build.gradle => android-old/app/build.gradle +82 -0
@@ 0,0 1,82 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

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'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

plugins {
    id "com.android.application"
    id "dev.flutter.flutter-plugin-loader"
}

android {
    compileSdkVersion 33

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

    lintOptions {
        disable 'InvalidPackage'
    }

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

   signingConfigs {
       release {
           keyAlias keystoreProperties['keyAlias']
           keyPassword keystoreProperties['keyPassword']
           storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
           storePassword keystoreProperties['storePassword']
       }
   }

    buildTypes {
        release {
            signingConfig signingConfigs.release
            shrinkResources false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

flutter {
    source '../..'
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

A android-old/app/proguard-rules.pro => android-old/app/proguard-rules.pro +1 -0
@@ 0,0 1,1 @@
-keep class io.flutter.plugin.editing.** { *; }
\ No newline at end of file

A android-old/app/src/debug/AndroidManifest.xml => android-old/app/src/debug/AndroidManifest.xml +7 -0
@@ 0,0 1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- Flutter needs it to communicate with the running application
         to allow setting breakpoints, to provide hot reload, etc.
    -->
    <uses-permission android:name="android.permission.INTERNET"/>
</manifest>

A android-old/app/src/main/AndroidManifest.xml => android-old/app/src/main/AndroidManifest.xml +62 -0
@@ 0,0 1,62 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- io.flutter.app.FlutterApplication is an android.app.Application that
         calls FlutterMain.startInitialization(this); in its onCreate method.
         In most cases you can leave this as-is, but you if you want to provide
         additional functionality it is fine to subclass or reimplement
         FlutterApplication and put your custom class here. -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <application
        android:name="${applicationName}"
        android:label="Fluent Reader"
        android:icon="@mipmap/ic_launcher"
        android:usesCleartextTraffic="true">
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize"
            android:exported="true">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <!-- Displays an Android View that continues showing the launch screen
                 Drawable until Flutter paints its first frame, then this splash
                 screen fades out. A splash screen is useful to avoid any visual
                 gap between the end of Android's launch screen and the painting of
                 Flutter's first frame. -->
            <meta-data
              android:name="io.flutter.embedding.android.SplashScreenDrawable"
              android:resource="@drawable/launch_background"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>

            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
                <data
                    android:scheme="fluent-reader"
                    android:host="import" />
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>

A android-old/app/src/main/ic_launcher-playstore.png => android-old/app/src/main/ic_launcher-playstore.png +0 -0
A android-old/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt => android-old/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt +6 -0
@@ 0,0 1,6 @@
package me.hyliu.fluent_reader_lite

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {
}

A android-old/app/src/main/res/drawable-night/launch_background.xml => android-old/app/src/main/res/drawable-night/launch_background.xml +11 -0
@@ 0,0 1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@android:color/black" />

    <item>
        <bitmap
            android:gravity="center"
            android:src="@mipmap/logo" />
    </item>
</layer-list>

A android-old/app/src/main/res/drawable/launch_background.xml => android-old/app/src/main/res/drawable/launch_background.xml +11 -0
@@ 0,0 1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@android:color/white" />

    <item>
        <bitmap
            android:gravity="center"
            android:src="@mipmap/logo" />
    </item>
</layer-list>

A android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml => android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
@@ 0,0 1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background"/>
    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
\ No newline at end of file

A android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml => android-old/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
@@ 0,0 1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background"/>
    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
\ No newline at end of file

A android-old/app/src/main/res/mipmap-hdpi/ic_launcher.png => android-old/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
A android-old/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png => android-old/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
A android-old/app/src/main/res/mipmap-hdpi/ic_launcher_round.png => android-old/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher.png => android-old/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png => android-old/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
A android-old/app/src/main/res/mipmap-mdpi/ic_launcher_round.png => android-old/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher.png => android-old/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png => android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
A android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png => android-old/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher.png => android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png => android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
A android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png => android-old/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png => android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png => android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
A android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png => android-old/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
A android-old/app/src/main/res/mipmap-xxxhdpi/logo.png => android-old/app/src/main/res/mipmap-xxxhdpi/logo.png +0 -0
A android-old/app/src/main/res/values/ic_launcher_background.xml => android-old/app/src/main/res/values/ic_launcher_background.xml +4 -0
@@ 0,0 1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="ic_launcher_background">#FFFFFF</color>
</resources>
\ No newline at end of file

A android-old/app/src/main/res/values/styles.xml => android-old/app/src/main/res/values/styles.xml +18 -0
@@ 0,0 1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- Theme applied to the Android Window while the process is starting -->
    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
        <!-- Show a splash screen on the activity. Automatically removed when
             Flutter draws its first frame -->
        <item name="android:windowBackground">@drawable/launch_background</item>
    </style>
    <!-- Theme applied to the Android Window as soon as the process has started.
         This theme determines the color of the Android Window while your
         Flutter UI initializes, as well as behind your Flutter UI while its
         running.
         
         This Theme is only used starting with V2 of Flutter's Android embedding. -->
    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
        <item name="android:windowBackground">@android:color/white</item>
    </style>
</resources>

A android-old/app/src/profile/AndroidManifest.xml => android-old/app/src/profile/AndroidManifest.xml +7 -0
@@ 0,0 1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- Flutter needs it to communicate with the running application
         to allow setting breakpoints, to provide hot reload, etc.
    -->
    <uses-permission android:name="android.permission.INTERNET"/>
</manifest>

A android-old/build.gradle => android-old/build.gradle +35 -0
@@ 0,0 1,35 @@
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()
    }
}

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

tasks.register("clean", Delete) {
    delete rootProject.layout.buildDirectory
}

A android-old/gradle.properties => android-old/gradle.properties +4 -0
@@ 0,0 1,4 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
android.enableR8=true

A android-old/gradle/wrapper/gradle-wrapper.properties => android-old/gradle/wrapper/gradle-wrapper.properties +5 -0
@@ 0,0 1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

A android-old/settings.gradle => android-old/settings.gradle +11 -0
@@ 0,0 1,11 @@
include ':app'

def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()

assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }

def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

M android/.gitignore => android/.gitignore +4 -1
@@ 5,7 5,10 @@ gradle-wrapper.jar
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/

# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

M android/app/build.gradle => android/app/build.gradle +9 -9
@@ 1,3 1,7 @@
plugins {
    id "com.android.application"
}

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {


@@ 21,10 25,6 @@ if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {


@@ 32,21 32,21 @@ if (keystorePropertiesFile.exists()) {
}

android {
    compileSdkVersion 33
    compileSdk 33

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

    lintOptions {
    lint {
        disable 'InvalidPackage'
    }

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


@@ 75,4 75,4 @@ flutter {

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

M android/app/src/debug/AndroidManifest.xml => android/app/src/debug/AndroidManifest.xml +3 -3
@@ 1,6 1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- Flutter needs it to communicate with the running application
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- The INTERNET permission is required for development. Specifically,
         the Flutter tool needs it to communicate with the running application
         to allow setting breakpoints, to provide hot reload, etc.
    -->
    <uses-permission android:name="android.permission.INTERNET"/>

M android/app/src/main/AndroidManifest.xml => android/app/src/main/AndroidManifest.xml +11 -5
@@ 1,10 1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- io.flutter.app.FlutterApplication is an android.app.Application that
         calls FlutterMain.startInitialization(this); in its onCreate method.
         In most cases you can leave this as-is, but you if you want to provide
         additional functionality it is fine to subclass or reimplement
         FlutterApplication and put your custom class here. -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />


@@ 59,4 54,15 @@
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

M android/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt => android/app/src/main/kotlin/com/example/fluent_reader_lite/MainActivity.kt +2 -3
@@ 1,6 1,5 @@
package me.hyliu.fluent_reader_lite
package com.example.fluent_reader_lite

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {
}
class MainActivity : FlutterActivity()

A android/app/src/main/res/drawable-v21/launch_background.xml => android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
@@ 0,0 1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="?android:colorBackground" />

    <!-- You can insert your own image assets here -->
    <!-- <item>
        <bitmap
            android:gravity="center"
            android:src="@mipmap/launch_image" />
    </item> -->
</layer-list>

M android/app/src/main/res/drawable/launch_background.xml => android/app/src/main/res/drawable/launch_background.xml +4 -3
@@ 3,9 3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@android:color/white" />

    <item>
    <!-- You can insert your own image assets here -->
    <!-- <item>
        <bitmap
            android:gravity="center"
            android:src="@mipmap/logo" />
    </item>
            android:src="@mipmap/launch_image" />
    </item> -->
</layer-list>

M android/app/src/main/res/mipmap-hdpi/ic_launcher.png => android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
M android/app/src/main/res/mipmap-mdpi/ic_launcher.png => android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
M android/app/src/main/res/mipmap-xhdpi/ic_launcher.png => android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
M android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png => android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
M android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png => android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
A android/app/src/main/res/values-night/styles.xml => android/app/src/main/res/values-night/styles.xml +18 -0
@@ 0,0 1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
        <!-- Show a splash screen on the activity. Automatically removed when
             the Flutter engine draws its first frame -->
        <item name="android:windowBackground">@drawable/launch_background</item>
    </style>
    <!-- Theme applied to the Android Window as soon as the process has started.
         This theme determines the color of the Android Window while your
         Flutter UI initializes, as well as behind your Flutter UI while its
         running.

         This Theme is only used starting with V2 of Flutter's Android embedding. -->
    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
        <item name="android:windowBackground">?android:colorBackground</item>
    </style>
</resources>

M android/app/src/profile/AndroidManifest.xml => android/app/src/profile/AndroidManifest.xml +3 -3
@@ 1,6 1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.hyliu.fluent_reader_lite">
    <!-- Flutter needs it to communicate with the running application
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- The INTERNET permission is required for development. Specifically,
         the Flutter tool needs it to communicate with the running application
         to allow setting breakpoints, to provide hot reload, etc.
    -->
    <uses-permission android:name="android.permission.INTERNET"/>

M android/build.gradle => android/build.gradle +7 -3
@@ 1,3 1,7 @@
plugins {
    id "dev.flutter.flutter-gradle-plugin"
}

buildscript {
    ext.kotlin_version = '1.7.0'
    repositories {


@@ 26,6 30,6 @@ subprojects {
    project.evaluationDependsOn(':app')
}

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

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

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

M android/settings.gradle => android/settings.gradle +22 -8
@@ 1,11 1,25 @@
include ':app'
pluginManagement {
    def flutterSdkPath = {
        def properties = new Properties()
        file("local.properties").withInputStream { properties.load(it) }
        def flutterSdkPath = properties.getProperty("flutter.sdk")
        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
        return flutterSdkPath
    }()

def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")

assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
plugins {
    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
    id "com.android.application" version "8.9.1" apply false
    id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}

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

M assets/article/article.html => assets/article/article.html +1 -1
@@ 5,7 5,7 @@
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="referrer" content="no-referrer">
    <meta http-equiv="Content-Security-Policy"
        content="default-src 'none'; script-src 'self'; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
        content="default-src 'none'; script-src 'self'; img-src http: https: data:; style-src 'self' 'unsafe-inline'; font-src 'self' http://127.0.0.1:9000; frame-src http: https:; media-src http: https:; connect-src https: http:">
    <title>Article</title>
    <link rel="stylesheet" href="article.css" />
    <script src="mercury.web.js"></script>

M assets/article/article.js => assets/article/article.js +21 -0
@@ 12,6 12,27 @@ async function getArticle(url) {
    }
}
document.documentElement.style.fontSize = get("s") + "px"
let fontFamily = get("f")
if (fontFamily && fontFamily !== "System") {
    let fontPath = get("fp")
    if (!fontPath) {
        // Built-in fonts served from bundled assets
        var builtInFonts = {
            'OpenSans': 'fonts/OpenSans-Regular.ttf',
            'Roboto': 'fonts/Roboto-Regular.ttf',
            'SourceSerif': 'fonts/SourceSerif-Regular.ttf'
        }
        if (builtInFonts[fontFamily]) {
            fontPath = builtInFonts[fontFamily]
        }
    }
    if (fontPath) {
        var style = document.createElement('style')
        style.textContent = '@font-face { font-family: "' + fontFamily + '"; src: url("' + fontPath + '"); }'
        document.head.appendChild(style)
    }
    document.documentElement.style.fontFamily = '"' + fontFamily + '", sans-serif'
}
let theme = get("t")
if (theme !== null) document.documentElement.classList.add(theme === "1" ? "light" : "dark")
let url = get("u")

A ios/Flutter/ephemeral/flutter_lldb_helper.py => ios/Flutter/ephemeral/flutter_lldb_helper.py +32 -0
@@ 0,0 1,32 @@
#
# Generated file, do not edit.
#

import lldb

def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
    """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
    base = frame.register["x0"].GetValueAsAddress()
    page_len = frame.register["x1"].GetValueAsUnsigned()

    # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
    # first page to see if handled it correctly. This makes diagnosing
    # misconfiguration (e.g. missing breakpoint) easier.
    data = bytearray(page_len)
    data[0:8] = b'IHELPED!'

    error = lldb.SBError()
    frame.GetThread().GetProcess().WriteMemory(base, data, error)
    if not error.Success():
        print(f'Failed to write into {base}[+{page_len}]', error)
        return

def __lldb_init_module(debugger: lldb.SBDebugger, _):
    target = debugger.GetDummyTarget()
    # Caveat: must use BreakpointCreateByRegEx here and not
    # BreakpointCreateByName. For some reasons callback function does not
    # get carried over from dummy target for the later.
    bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
    bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
    bp.SetAutoContinue(True)
    print("-- LLDB integration loaded --")

A ios/Flutter/ephemeral/flutter_lldbinit => ios/Flutter/ephemeral/flutter_lldbinit +5 -0
@@ 0,0 1,5 @@
#
# Generated file, do not edit.
#

command script import --relative-to-command-file flutter_lldb_helper.py

M lib/l10n/intl_de.arb => lib/l10n/intl_de.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Menü offenen",
    "openExternal": "Extern öffnen",
    "fontSize": "Schriftgröße",
    "fontFamily": "Schriftart",
    "systemFont": "Systemschrift",
    "uploadCustomFont": "Eigene Schrift hochladen",
    "selectFontFamily": "Schriftart auswählen",
    "fontUploaded": "Schrift hochgeladen",
    "customFontUploaded": "Eigene Schrift wurde erfolgreich hochgeladen.",
    "fontUploadError": "Fehler beim Hochladen der Schrift",
    "fontSettings": "Schrift-Einstellungen",
    "deleteFont": "Schrift löschen",
    "deleteFontConfirm": "Sind Sie sicher, dass Sie diese Schrift löschen möchten?",
    "edit": "Editieren",
    "name": "Name",
    "icon": "Icon",

M lib/l10n/intl_en.arb => lib/l10n/intl_en.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Open menu",
    "openExternal": "Open externally",
    "fontSize": "Font size",
    "fontFamily": "Font Family",
    "systemFont": "System Font",
    "uploadCustomFont": "Upload Custom Font",
    "selectFontFamily": "Select Font Family",
    "fontUploaded": "Font Uploaded",
    "customFontUploaded": "Custom font has been uploaded successfully.",
    "fontUploadError": "Failed to upload font",
    "fontSettings": "Font Settings",
    "deleteFont": "Delete Font",
    "deleteFontConfirm": "Are you sure you want to delete this custom font?",
    "edit": "Edit",
    "name": "Name",
    "icon": "Icon",

M lib/l10n/intl_es.arb => lib/l10n/intl_es.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Abrir menú",
    "openExternal": "Abrir externamente",
    "fontSize": "Tamaño de fuente",
    "fontFamily": "Familia de fuentes",
    "systemFont": "Fuente del sistema",
    "uploadCustomFont": "Subir fuente personalizada",
    "selectFontFamily": "Seleccionar familia de fuentes",
    "fontUploaded": "Fuente subida",
    "customFontUploaded": "La fuente personalizada se ha subido correctamente.",
    "fontUploadError": "Error al subir la fuente",
    "fontSettings": "Configuración de fuentes",
    "deleteFont": "Eliminar fuente",
    "deleteFontConfirm": "¿Está seguro de que desea eliminar esta fuente personalizada?",
    "edit": "Editar",
    "name": "Nombre",
    "icon": "Icono",

M lib/l10n/intl_fr.arb => lib/l10n/intl_fr.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Ouvrir le menu",
    "openExternal": "Ouvrir dans un nouvel onglet",
    "fontSize": "Taille de police",
    "fontFamily": "Police de caractères",
    "systemFont": "Police système",
    "uploadCustomFont": "Téléverser une police personnalisée",
    "selectFontFamily": "Sélectionner la police",
    "fontUploaded": "Police téléversée",
    "customFontUploaded": "La police personnalisée a été téléversée avec succès.",
    "fontUploadError": "Échec du téléversement de la police",
    "fontSettings": "Paramètres de police",
    "deleteFont": "Supprimer la police",
    "deleteFontConfirm": "Êtes-vous sûr de vouloir supprimer cette police personnalisée ?",
    "edit": "Editer",
    "name": "Nom",
    "icon": "Icon",

M lib/l10n/intl_hr.arb => lib/l10n/intl_hr.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Otvori izbornik",
    "openExternal": "Otvori u vanjskoj aplikaciji",
    "fontSize": "Veličina fonta",
    "fontFamily": "Obitelj fontova",
    "systemFont": "Sistemski font",
    "uploadCustomFont": "Učitaj prilagođeni font",
    "selectFontFamily": "Odaberi obitelj fontova",
    "fontUploaded": "Font učitan",
    "customFontUploaded": "Prilagođeni font je uspješno učitan.",
    "fontUploadError": "Pogreška pri učitavanju fonta",
    "fontSettings": "Postavke fontova",
    "deleteFont": "Izbriši font",
    "deleteFontConfirm": "Jeste li sigurni da želite izbrisati ovaj prilagođeni font?",
    "edit": "Uredi",
    "name": "Naziv",
    "icon": "Ikona",

M lib/l10n/intl_pt.arb => lib/l10n/intl_pt.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Abrir menu",
    "openExternal": "Abrir externamente",
    "fontSize": "Tamanho da fonte",
    "fontFamily": "Família da fonte",
    "systemFont": "Fonte do sistema",
    "uploadCustomFont": "Enviar fonte personalizada",
    "selectFontFamily": "Selecionar família da fonte",
    "fontUploaded": "Fonte enviada",
    "customFontUploaded": "A fonte personalizada foi enviada com sucesso.",
    "fontUploadError": "Falha ao enviar a fonte",
    "fontSettings": "Configurações de fonte",
    "deleteFont": "Excluir fonte",
    "deleteFontConfirm": "Tem certeza de que deseja excluir esta fonte personalizada?",
    "edit": "Editar",
    "name": "Nome",
    "icon": "ícone",

M lib/l10n/intl_tr.arb => lib/l10n/intl_tr.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Menüyü aç",
    "openExternal": "Harici olarak aç",
    "fontSize": "Yazı boyutu",
    "fontFamily": "Yazı tipi ailesi",
    "systemFont": "Sistem yazı tipi",
    "uploadCustomFont": "Özel yazı tipi yükle",
    "selectFontFamily": "Yazı tipi ailesi seç",
    "fontUploaded": "Yazı tipi yüklendi",
    "customFontUploaded": "Özel yazı tipi başarıyla yüklendi.",
    "fontUploadError": "Yazı tipi yüklenemedi",
    "fontSettings": "Yazı tipi ayarları",
    "deleteFont": "Yazı tipini sil",
    "deleteFontConfirm": "Bu özel yazı tipini silmek istediğinizden emin misiniz?",
    "edit": "Düzenle",
    "name": "İsim",
    "icon": "Simge",

M lib/l10n/intl_uk.arb => lib/l10n/intl_uk.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "Відкрити меню",
    "openExternal": "Відкрити зовні",
    "fontSize": "Розмір шрифту",
    "fontFamily": "Сімейство шрифтів",
    "systemFont": "Системний шрифт",
    "uploadCustomFont": "Завантажити власний шрифт",
    "selectFontFamily": "Обрати сімейство шрифтів",
    "fontUploaded": "Шрифт завантажено",
    "customFontUploaded": "Власний шрифт успішно завантажено.",
    "fontUploadError": "Не вдалося завантажити шрифт",
    "fontSettings": "Налаштування шрифтів",
    "deleteFont": "Видалити шрифт",
    "deleteFontConfirm": "Ви впевнені, що хочете видалити цей власний шрифт?",
    "edit": "Редагувати",
    "name": "Назва",
    "icon": "Значок",

M lib/l10n/intl_zh.arb => lib/l10n/intl_zh.arb +10 -0
@@ 47,6 47,16 @@
    "openMenu": "打开菜单",
    "openExternal": "在外部打开",
    "fontSize": "字体大小",
    "fontFamily": "字体",
    "systemFont": "系统字体",
    "uploadCustomFont": "上传自定义字体",
    "selectFontFamily": "选择字体",
    "fontUploaded": "字体已上传",
    "customFontUploaded": "自定义字体上传成功。",
    "fontUploadError": "字体上传失败",
    "fontSettings": "字体设置",
    "deleteFont": "删除字体",
    "deleteFontConfirm": "确定要删除此自定义字体吗?",
    "edit": "编辑",
    "name": "名称",
    "icon": "图标",

M lib/models/global_model.dart => lib/models/global_model.dart +10 -0
@@ 14,6 14,7 @@ class GlobalModel with ChangeNotifier {
  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);
  String _fontFamily = Store.getFontFamily();

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


@@ 67,4 68,13 @@ class GlobalModel with ChangeNotifier {
      }
    }
  }

  String get fontFamily => _fontFamily;
  set fontFamily(String value) {
    if (value != _fontFamily) {
      _fontFamily = value;
      notifyListeners();
      Store.setFontFamily(value);
    }
  }
}
\ No newline at end of file

M lib/pages/article_page.dart => lib/pages/article_page.dart +10 -1
@@ 7,6 7,7 @@ import 'package:fluent_reader_lite/models/items_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/font_manager.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:flutter/cupertino.dart';


@@ 94,7 95,15 @@ class ArticlePageState extends State<ArticlePage> {
    h += '<article></article>';
    h = Uri.encodeComponent(h);
    var s = Store.getArticleFontSize();
    localUrl += "?a=$a&h=$h&s=$s&u=${item.link}&m=${loadFull ? 1 : 0}";
    var f = Uri.encodeComponent(Global.globalModel.fontFamily);
    localUrl += "?a=$a&h=$h&s=$s&f=$f&u=${item.link}&m=${loadFull ? 1 : 0}";
    // Pass custom font URL for non-built-in fonts
    if (!FontManager.builtInFonts.contains(Global.globalModel.fontFamily)) {
      final fontUrl = await FontManager.getCustomFontUrl(Global.globalModel.fontFamily);
      if (fontUrl != null) {
        localUrl += "&fp=${Uri.encodeComponent(fontUrl)}";
      }
    }
    if (Platform.isAndroid || Global.globalModel.getBrightness() != null) {
      var brightness = Global.currentBrightness(context);
      localUrl += "&t=${brightness.index}";

M lib/pages/settings/reading_page.dart => lib/pages/settings/reading_page.dart +198 -0
@@ 3,7 3,11 @@ import 'package:fluent_reader_lite/components/my_list_tile.dart';
import 'package:fluent_reader_lite/generated/l10n.dart';
import 'package:fluent_reader_lite/utils/colors.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:fluent_reader_lite/utils/global.dart';
import 'package:fluent_reader_lite/utils/font_manager.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';

class ReadingPage extends StatefulWidget {
  @override


@@ 12,6 16,178 @@ class ReadingPage extends StatefulWidget {

class _ReadingPageState extends State<ReadingPage> {
  int _fontSize = Store.getArticleFontSize();
  List<String> _customFontNames = [];
  List<String> _availableFonts = [];

  @override
  void initState() {
    super.initState();
    _loadCustomFonts();
  }

  void _loadCustomFonts() async {
    final customFonts = await FontManager.getInstalledCustomFonts();
    setState(() {
      _customFontNames = customFonts;
      _availableFonts = [...FontManager.builtInFonts, ...customFonts];
    });
  }

  void _showFontPicker(BuildContext context) {
    showCupertinoModalPopup(
      context: context,
      builder: (context) => CupertinoActionSheet(
        title: Text(S.of(context).selectFontFamily),
        actions: _availableFonts.map((font) {
          final isCustom = !FontManager.builtInFonts.contains(font);
          final isSelected = Global.globalModel.fontFamily == font;
          final isSystem = font == 'System';
          return CupertinoActionSheetAction(
            child: Column(
              children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text(
                      FontManager.getFontDisplayName(font),
                      style: TextStyle(
                        fontFamily: isCustom || isSystem ? null : font,
                        fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
                      ),
                    ),
                    if (isSelected) Padding(
                      padding: EdgeInsets.only(left: 8),
                      child: Icon(Icons.done, size: 18, color: CupertinoColors.activeBlue),
                    ),
                  ],
                ),
                if (!isSystem && !isCustom)
                  Text(
                    'The quick brown fox jumps over the lazy dog',
                    style: TextStyle(
                      fontFamily: font,
                      fontSize: 12,
                      color: CupertinoColors.systemGrey,
                    ),
                  ),
                if (isCustom)
                  Text(
                    '(${S.of(context).uploadCustomFont})',
                    style: TextStyle(
                      fontSize: 12,
                      color: CupertinoColors.systemGrey,
                    ),
                  ),
              ],
            ),
            onPressed: () {
              Global.globalModel.fontFamily = font;
              Navigator.pop(context);
              setState(() {});
            },
          );
        }).toList(),
        cancelButton: CupertinoActionSheetAction(
          child: Text(S.of(context).cancel),
          onPressed: () => Navigator.pop(context),
        ),
      ),
    );
  }

  void _uploadCustomFont() async {
    FilePickerResult? result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['ttf', 'otf', 'woff', 'woff2'],
    );

    if (result != null && result.files.single.path != null) {
      try {
        final sourcePath = result.files.single.path!;
        final fileName = result.files.single.name;

        await FontManager.installCustomFont(sourcePath, fileName);

        final fontName = fileName.split('.').first;
        Global.globalModel.fontFamily = fontName;

        _loadCustomFonts();

        showCupertinoDialog(
          context: context,
          builder: (context) => CupertinoAlertDialog(
            title: Text(S.of(context).fontUploaded),
            content: Text(S.of(context).customFontUploaded),
            actions: [
              CupertinoDialogAction(
                child: Text('OK'),
                onPressed: () => Navigator.pop(context),
              ),
            ],
          ),
        );
      } catch (e) {
        showCupertinoDialog(
          context: context,
          builder: (context) => CupertinoAlertDialog(
            title: Text(S.of(context).fontUploadError),
            content: Text('$e'),
            actions: [
              CupertinoDialogAction(
                child: Text('OK'),
                onPressed: () => Navigator.pop(context),
              ),
            ],
          ),
        );
      }
    }
  }

  void _confirmDeleteFont(BuildContext context, String fontName) {
    showCupertinoDialog(
      context: context,
      builder: (context) => CupertinoAlertDialog(
        title: Text(S.of(context).deleteFont),
        content: Text(S.of(context).deleteFontConfirm),
        actions: [
          CupertinoDialogAction(
            child: Text(S.of(context).cancel),
            onPressed: () => Navigator.pop(context),
          ),
          CupertinoDialogAction(
            isDestructiveAction: true,
            child: Text(S.of(context).confirm),
            onPressed: () async {
              Navigator.pop(context);
              try {
                await FontManager.removeCustomFont(fontName);
                if (Global.globalModel.fontFamily == fontName) {
                  Global.globalModel.fontFamily = 'System';
                  Store.sp.remove(StoreKeys.CUSTOM_FONT_PATH);
                }
                _loadCustomFonts();
              } catch (e) {
                showCupertinoDialog(
                  context: context,
                  builder: (ctx) => CupertinoAlertDialog(
                    title: Text(S.of(ctx).fontUploadError),
                    content: Text('$e'),
                    actions: [
                      CupertinoDialogAction(
                        child: Text('OK'),
                        onPressed: () => Navigator.pop(ctx),
                      ),
                    ],
                  ),
                );
              }
            },
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {


@@ 41,6 217,28 @@ class _ReadingPageState extends State<ReadingPage> {
            withDivider: false,
          ),
        ], title: S.of(context).preferences),
        ListTileGroup([
          MyListTile(
            title: Text(S.of(context).fontFamily),
            trailing: Text(FontManager.getFontDisplayName(Global.globalModel.fontFamily)),
            onTap: () => _showFontPicker(context),
          ),
          MyListTile(
            title: Text(S.of(context).uploadCustomFont),
            trailing: Icon(CupertinoIcons.add),
            onTap: _uploadCustomFont,
            withDivider: _customFontNames.isNotEmpty,
          ),
          ..._customFontNames.map((fontName) => MyListTile(
            title: Text(fontName),
            trailing: GestureDetector(
              onTap: () => _confirmDeleteFont(context, fontName),
              child: Icon(CupertinoIcons.delete, color: CupertinoColors.destructiveRed, size: 20),
            ),
            trailingChevron: false,
            withDivider: fontName != _customFontNames.last,
          )),
        ], title: S.of(context).fontSettings),
      ]),
    );
  }

A lib/utils/font_manager.dart => lib/utils/font_manager.dart +107 -0
@@ 0,0 1,107 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';

class FontManager {
  static const supportedExtensions = ['.ttf', '.otf', '.woff', '.woff2'];
  static const builtInFonts = ['System', 'OpenSans', 'Roboto', 'SourceSerif'];

  static Future<Directory> getFontsDirectory() async {
    final directory = await getApplicationDocumentsDirectory();
    final fontsDir = Directory('${directory.path}/fonts');
    if (!await fontsDir.exists()) {
      await fontsDir.create(recursive: true);
    }
    return fontsDir;
  }

  static Future<String> installCustomFont(String sourcePath, String fileName) async {
    try {
      final fontsDir = await getFontsDirectory();
      final sourceFile = File(sourcePath);

      // Basic validation: fonts should be > 1KB and < 50MB
      final fileSize = await sourceFile.length();
      if (fileSize < 1024 || fileSize > 50 * 1024 * 1024) {
        throw Exception('Invalid font file size');
      }

      final targetPath = '${fontsDir.path}/$fileName';
      final targetFile = await sourceFile.copy(targetPath);
      return targetFile.path;
    } catch (e) {
      throw Exception('Failed to install custom font: $e');
    }
  }

  static Future<List<String>> getInstalledCustomFonts() async {
    try {
      final fontsDir = await getFontsDirectory();
      final files = await fontsDir.list().toList();
      return files
          .where((file) => file is File &&
                 supportedExtensions.any((ext) => file.path.toLowerCase().endsWith(ext)))
          .map((file) {
            final fileName = file.path.split(Platform.pathSeparator).last;
            return fileName.split('.').first;
          })
          .toList();
    } catch (e) {
      return [];
    }
  }

  /// Get the full file name (with extension) for a given font name
  static Future<String?> getFontFileName(String fontName) async {
    final fontsDir = await getFontsDirectory();
    final files = await fontsDir.list().toList();
    for (final file in files) {
      if (file is File) {
        final fileName = file.path.split(Platform.pathSeparator).last;
        final nameWithoutExt = fileName.split('.').first;
        if (nameWithoutExt == fontName) {
          return fileName;
        }
      }
    }
    return null;
  }

  /// Get the local server URL for a custom font
  static Future<String?> getCustomFontUrl(String fontName) async {
    final fileName = await getFontFileName(fontName);
    if (fileName == null) return null;
    return 'http://127.0.0.1:9000/custom-fonts/${Uri.encodeComponent(fileName)}';
  }

  static Future<void> removeCustomFont(String fontName) async {
    try {
      final fontsDir = await getFontsDirectory();
      final files = await fontsDir.list().toList();
      for (final file in files) {
        if (file is File) {
          final fileName = file.path.split(Platform.pathSeparator).last;
          final nameWithoutExt = fileName.split('.').first;
          if (nameWithoutExt == fontName) {
            await file.delete();
            break;
          }
        }
      }
    } catch (e) {
      throw Exception('Failed to remove custom font: $e');
    }
  }

  static String getFontDisplayName(String fontFamily) {
    switch (fontFamily) {
      case 'System':
        return 'System Font';
      case 'OpenSans':
        return 'Open Sans';
      case 'SourceSerif':
        return 'Source Serif';
      default:
        return fontFamily;
    }
  }
}

M lib/utils/global.dart => lib/utils/global.dart +30 -1
@@ 1,3 1,5 @@
import 'dart:io';

import 'package:fluent_reader_lite/models/feeds_model.dart';
import 'package:fluent_reader_lite/models/global_model.dart';
import 'package:fluent_reader_lite/models/groups_model.dart';


@@ 9,9 11,10 @@ import 'package:fluent_reader_lite/models/services/greader.dart';
import 'package:fluent_reader_lite/models/sources_model.dart';
import 'package:fluent_reader_lite/models/sync_model.dart';
import 'package:fluent_reader_lite/utils/db.dart';
import 'package:fluent_reader_lite/utils/font_manager.dart';
import 'package:fluent_reader_lite/utils/store.dart';
import 'package:flutter/cupertino.dart';
import 'package:jaguar/serve/server.dart';
import 'package:jaguar/jaguar.dart';
import 'package:jaguar_flutter_asset/jaguar_flutter_asset.dart';
import 'package:sqflite/sqflite.dart';



@@ 68,6 71,32 @@ abstract class Global {
    );
    server = Jaguar(address: "127.0.0.1",port: 9000);
    server.addRoute(serveFlutterAssets());

    // Serve custom font files from app documents directory
    final fontsDir = await FontManager.getFontsDirectory();
    server.get('/custom-fonts/:filename*', (ctx) async {
      final filename = ctx.pathParams['filename'];
      if (filename == null || filename.isEmpty) {
        return Response(statusCode: 404);
      }
      // Security: only allow font file extensions
      final lowerFilename = filename.toLowerCase();
      if (!FontManager.supportedExtensions.any((ext) => lowerFilename.endsWith(ext))) {
        return Response(statusCode: 403);
      }
      final file = File('${fontsDir.path}/$filename');
      if (!await file.exists()) {
        return Response(statusCode: 404);
      }
      String mimeType = 'application/octet-stream';
      if (lowerFilename.endsWith('.ttf')) mimeType = 'font/ttf';
      else if (lowerFilename.endsWith('.otf')) mimeType = 'font/otf';
      else if (lowerFilename.endsWith('.woff2')) mimeType = 'font/woff2';
      else if (lowerFilename.endsWith('.woff')) mimeType = 'font/woff';
      final bytes = await file.readAsBytes();
      return ByteResponse(body: bytes, mimeType: mimeType);
    });

    await server.serve();
    await sourcesModel.init();
    await feedsModel.all.init();

M lib/utils/store.dart => lib/utils/store.dart +22 -0
@@ 30,6 30,8 @@ abstract class StoreKeys {

  // Reading preferences
  static const ARTICLE_FONT_SIZE = "articleFontSize";
  static const FONT_FAMILY = "fontFamily";
  static const CUSTOM_FONT_PATH = "customFontPath";

  // Syncing
  static const SYNC_SERVICE = "syncService";


@@ 124,4 126,24 @@ class Store {
  static void setErrorLog(String value) {
    sp.setString(StoreKeys.ERROR_LOG, value);
  }

  static String getFontFamily() {
    return sp.getString(StoreKeys.FONT_FAMILY) ?? "System";
  }

  static void setFontFamily(String value) {
    sp.setString(StoreKeys.FONT_FAMILY, value);
  }

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

  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

M pubspec.lock => pubspec.lock +74 -34
@@ 53,26 53,26 @@ packages:
    dependency: transitive
    description:
      name: characters
      sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c
      sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
      url: "https://pub.dev"
    source: hosted
    version: "1.2.1"
    version: "1.4.0"
  clock:
    dependency: transitive
    description:
      name: clock
      sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
      sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
      url: "https://pub.dev"
    source: hosted
    version: "1.1.1"
    version: "1.1.2"
  collection:
    dependency: transitive
    description:
      name: collection
      sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0
      sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
      url: "https://pub.dev"
    source: hosted
    version: "1.17.0"
    version: "1.19.1"
  crypto:
    dependency: "direct main"
    description:


@@ 101,10 101,10 @@ packages:
    dependency: transitive
    description:
      name: fake_async
      sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
      sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
      url: "https://pub.dev"
    source: hosted
    version: "1.3.1"
    version: "1.3.3"
  ffi:
    dependency: transitive
    description:


@@ 121,6 121,14 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "6.1.4"
  file_picker:
    dependency: "direct main"
    description:
      name: file_picker
      sha256: "9d6e95ec73abbd31ec54d0e0df8a961017e165aba1395e462e5b31ea0c165daf"
      url: "https://pub.dev"
    source: hosted
    version: "5.3.1"
  flutter:
    dependency: "direct main"
    description: flutter


@@ 147,6 155,14 @@ packages:
    description: flutter
    source: sdk
    version: "0.0.0"
  flutter_plugin_android_lifecycle:
    dependency: transitive
    description:
      name: flutter_plugin_android_lifecycle
      sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31
      url: "https://pub.dev"
    source: hosted
    version: "2.0.30"
  flutter_test:
    dependency: "direct dev"
    description: flutter


@@ 193,10 209,10 @@ packages:
    dependency: "direct main"
    description:
      name: intl
      sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
      sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
      url: "https://pub.dev"
    source: hosted
    version: "0.17.0"
    version: "0.20.2"
  jaguar:
    dependency: "direct main"
    description:


@@ 221,14 237,30 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "3.0.0"
  js:
  leak_tracker:
    dependency: transitive
    description:
      name: js
      sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7"
      name: leak_tracker
      sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
      url: "https://pub.dev"
    source: hosted
    version: "0.6.5"
    version: "11.0.2"
  leak_tracker_flutter_testing:
    dependency: transitive
    description:
      name: leak_tracker_flutter_testing
      sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
      url: "https://pub.dev"
    source: hosted
    version: "3.0.10"
  leak_tracker_testing:
    dependency: transitive
    description:
      name: leak_tracker_testing
      sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
      url: "https://pub.dev"
    source: hosted
    version: "3.0.2"
  logging:
    dependency: transitive
    description:


@@ 249,26 281,26 @@ packages:
    dependency: transitive
    description:
      name: matcher
      sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72"
      sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
      url: "https://pub.dev"
    source: hosted
    version: "0.12.13"
    version: "0.12.17"
  material_color_utilities:
    dependency: transitive
    description:
      name: material_color_utilities
      sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724
      sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
      url: "https://pub.dev"
    source: hosted
    version: "0.2.0"
    version: "0.11.1"
  meta:
    dependency: transitive
    description:
      name: meta
      sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42"
      sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
      url: "https://pub.dev"
    source: hosted
    version: "1.8.0"
    version: "1.16.0"
  mime:
    dependency: transitive
    description:


@@ 329,12 361,12 @@ packages:
    dependency: "direct main"
    description:
      name: path
      sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b
      sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
      url: "https://pub.dev"
    source: hosted
    version: "1.8.2"
    version: "1.9.1"
  path_provider:
    dependency: transitive
    dependency: "direct main"
    description:
      name: path_provider
      sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa


@@ 497,7 529,7 @@ packages:
    dependency: transitive
    description: flutter
    source: sdk
    version: "0.0.99"
    version: "0.0.0"
  source_span:
    dependency: transitive
    description:


@@ 534,18 566,18 @@ packages:
    dependency: transitive
    description:
      name: stack_trace
      sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
      sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
      url: "https://pub.dev"
    source: hosted
    version: "1.11.0"
    version: "1.12.1"
  stream_channel:
    dependency: transitive
    description:
      name: stream_channel
      sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
      sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
      url: "https://pub.dev"
    source: hosted
    version: "2.1.1"
    version: "2.1.4"
  string_scanner:
    dependency: transitive
    description:


@@ 574,10 606,10 @@ packages:
    dependency: transitive
    description:
      name: test_api
      sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206
      sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
      url: "https://pub.dev"
    source: hosted
    version: "0.4.16"
    version: "0.7.6"
  tuple:
    dependency: "direct main"
    description:


@@ 702,10 734,18 @@ packages:
    dependency: transitive
    description:
      name: vector_math
      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
      sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
      url: "https://pub.dev"
    source: hosted
    version: "2.1.4"
    version: "2.2.0"
  vm_service:
    dependency: transitive
    description:
      name: vm_service
      sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
      url: "https://pub.dev"
    source: hosted
    version: "15.0.2"
  webview_flutter:
    dependency: "direct main"
    description:


@@ 755,5 795,5 @@ packages:
    source: hosted
    version: "1.0.3"
sdks:
  dart: ">=2.19.0 <3.0.0"
  flutter: ">=3.7.0"
  dart: ">=3.8.0-0 <4.0.0"
  flutter: ">=3.29.0"

M pubspec.yaml => pubspec.yaml +21 -21
@@ 18,7 18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.4+11

environment:
  sdk: ">=2.7.0 <3.0.0"
  sdk: ">=3.0.0 <4.0.0"

dependencies:
  flutter:


@@ 28,7 28,7 @@ dependencies:
  provider: ^6.0.3
  tuple: ^2.0.0
  shared_preferences: ^2.0.15
  intl: ^0.17.0
  intl: 0.20.2
  http: ^0.13.4
  html: ^0.15.0
  webview_flutter: ^3.0.4


@@ 47,6 47,8 @@ dependencies:
  uni_links: ^0.5.1
  modal_bottom_sheet: ^3.0.0-pre
  overlay_dialog: ^0.2.0
  file_picker: ^5.0.0
  path_provider: ^2.0.0


  # The following adds the Cupertino Icons font to your application.


@@ 72,6 74,7 @@ flutter:
  assets:
    - assets/article/
    - assets/icons/
    - assets/fonts/
  
  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware.


@@ 79,25 82,22 @@ flutter:
  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages
  fonts:
    - family: OpenSans
      fonts:
        - asset: assets/fonts/OpenSans-Regular.ttf
        - asset: assets/fonts/OpenSans-Bold.ttf
          weight: 700
    - family: Roboto
      fonts:
        - asset: assets/fonts/Roboto-Regular.ttf
        - asset: assets/fonts/Roboto-Bold.ttf
          weight: 700
    - family: SourceSerif
      fonts:
        - asset: assets/fonts/SourceSerif-Regular.ttf
        - asset: assets/fonts/SourceSerif-Bold.ttf
          weight: 700

flutter_intl:
  enabled: true