diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6be2f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.devbox/ diff --git a/SPEC.md b/SPEC.md index 782202d..221c840 100644 --- a/SPEC.md +++ b/SPEC.md @@ -18,7 +18,7 @@ A Flutter browser/client for Mews Pages (see mews-spec). ## Approach -Local-first: all data is stored locally in a Drift database. Network operations (Nostr publishing, image upload) are secondary and never block local functionality. +Local-first: all data is stored locally in a Drift database. Network operations are secondary and never block local functionality. # Architecture directives @@ -55,4 +55,13 @@ Use the `actors` package only where Dart Isolate isolation is genuinely useful: ## Persistence — Drift - Use `drift` for all tables, DAOs, and queries. -- Keep database definitions in `database/` separate from UI code. \ No newline at end of file +- Keep database definitions in `database/` separate from UI code. + +## v1 reader UI + +The v1 client presents one reader screen: + +- An address field pinned at the top; it hides when the reader scrolls down and returns when they scroll up or reach the top. +- A swipe right goes back and a swipe left goes forward through the history stack. +- Scrolling a page fades in a FOB in the bottom-right corner; on pages that fit without scrolling it is present from the start. It opens a bottom sheet with the reader's presentation choices: typeface, text size, theme (light, dark, follow system), color variant (site, warm, cool, green, mono), and a bookmark and subscription toggle for the current page. +- Fonts are bundled. v1 ships Atkinson Hyperlegible; Verdana and Georgia follow once their font files are added to `app/assets/fonts/`. A font is one TTF plus one `fonts` entry in `app/pubspec.yaml` and one entry in `app/lib/presentation/reader/reader_fonts.dart`. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..1c9477b --- /dev/null +++ b/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "nixpkgs000000000000000000000000000000000" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: nixpkgs000000000000000000000000000000000 + base_revision: nixpkgs000000000000000000000000000000000 + - platform: android + create_revision: nixpkgs000000000000000000000000000000000 + base_revision: nixpkgs000000000000000000000000000000000 + + # 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' diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..466f1c6 --- /dev/null +++ b/app/README.md @@ -0,0 +1,8 @@ +# Mews + +A Flutter reader for Mews pages (see `../mews-spec/`). Local-first: pages, +bookmarks, subscriptions, and reading settings live in a local Drift database; +network access only revalidates the cache. + +Run `devbox shell` from the repository root, then `devbox run run`, +`devbox run test`, or `devbox run analyze`. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/app/analysis_options.yaml @@ -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 diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..224284f --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "page.mews.mews" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "page.mews.mews" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..687fd20 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/page/mews/mews/MainActivity.kt b/app/android/app/src/main/kotlin/page/mews/mews/MainActivity.kt new file mode 100644 index 0000000..fc2da0f --- /dev/null +++ b/app/android/app/src/main/kotlin/page/mews/mews/MainActivity.kt @@ -0,0 +1,5 @@ +package page.mews.mews + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/app/assets/fonts/AtkinsonHyperlegible-Bold.ttf b/app/assets/fonts/AtkinsonHyperlegible-Bold.ttf new file mode 100644 index 0000000..ca3f613 Binary files /dev/null and b/app/assets/fonts/AtkinsonHyperlegible-Bold.ttf differ diff --git a/app/assets/fonts/AtkinsonHyperlegible-BoldItalic.ttf b/app/assets/fonts/AtkinsonHyperlegible-BoldItalic.ttf new file mode 100644 index 0000000..2804fdb Binary files /dev/null and b/app/assets/fonts/AtkinsonHyperlegible-BoldItalic.ttf differ diff --git a/app/assets/fonts/AtkinsonHyperlegible-Italic.ttf b/app/assets/fonts/AtkinsonHyperlegible-Italic.ttf new file mode 100644 index 0000000..9ee6818 Binary files /dev/null and b/app/assets/fonts/AtkinsonHyperlegible-Italic.ttf differ diff --git a/app/assets/fonts/AtkinsonHyperlegible-Regular.ttf b/app/assets/fonts/AtkinsonHyperlegible-Regular.ttf new file mode 100644 index 0000000..b3cc420 Binary files /dev/null and b/app/assets/fonts/AtkinsonHyperlegible-Regular.ttf differ diff --git a/app/assets/fonts/OFL.txt b/app/assets/fonts/OFL.txt new file mode 100644 index 0000000..2befc89 --- /dev/null +++ b/app/assets/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright 2020 Braille Institute of America, Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/assets/logo.svg b/app/assets/logo.svg new file mode 100644 index 0000000..53f3b80 --- /dev/null +++ b/app/assets/logo.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/app/lib/application/actors/fetch_actor.dart b/app/lib/application/actors/fetch_actor.dart new file mode 100644 index 0000000..c1895de --- /dev/null +++ b/app/lib/application/actors/fetch_actor.dart @@ -0,0 +1,160 @@ +import 'package:actors/actors.dart'; +import 'package:http/http.dart' as http; + +import '../../mews/document.dart'; +import '../../mews/parser.dart'; + +/// Commands accepted by the fetch actor. Network I/O and XML parsing both +/// happen in the actor's isolate; results return by message only. +sealed class FetchCommand { + const FetchCommand(); +} + +/// Fetch [url], sending validators when the page is cached. A 304 parses +/// [cachedSource] in place of a body, and a network failure falls back to it, +/// so the reader stays online-transparent and works offline. +class FetchPage extends FetchCommand { + const FetchPage({required this.url, this.etag, this.lastModified, this.cachedSource}); + + final String url; + final String? etag; + final String? lastModified; + final String? cachedSource; +} + +/// Submit a form's fields to its action URL (spec 4.2). +class SubmitForm extends FetchCommand { + const SubmitForm({required this.url, required this.method, required this.fields}); + + final String url; + final String method; + final Map fields; +} + +sealed class FetchResult { + const FetchResult(); +} + +class FetchPageOk extends FetchResult { + const FetchPageOk({ + required this.page, + required this.source, + this.etag, + this.lastModified, + this.notModified = false, + }); + + final MewsPage page; + + /// The markup the page was parsed from, for the cache. + final String source; + final String? etag; + final String? lastModified; + + /// True when the page came from the local cache (304 or offline). + final bool notModified; +} + +class FetchFormOk extends FetchResult { + const FetchFormOk(this.page); + + final MewsPage page; +} + +class FetchNetworkError extends FetchResult { + const FetchNetworkError(); +} + +class FetchHttpStatusError extends FetchResult { + const FetchHttpStatusError(this.status); + + final int status; +} + +class FetchParseError extends FetchResult { + const FetchParseError(this.message, this.source); + + final String message; + + /// The markup that failed to parse, for the "show page text" option. + final String source; +} + +class FetchHandler with Handler { + // Identifies the client to servers that vary behavior by user agent, like + // the http-to-https redirects some Mews sites run (spec 7.2). + static const _userAgent = 'Mews/0.1 (Mews client)'; + + final http.Client _client = http.Client(); + + @override + Future handle(FetchCommand message) async { + return switch (message) { + FetchPage(:final url, :final etag, :final lastModified, :final cachedSource) => + await _fetchPage(url, etag, lastModified, cachedSource), + SubmitForm(:final url, :final method, :final fields) => + await _submitForm(url, method, fields), + }; + } + + Future _fetchPage( + String url, String? etag, String? lastModified, String? cachedSource) async { + final headers = {'User-Agent': _userAgent}; + if (etag != null) headers['If-None-Match'] = etag; + if (lastModified != null) headers['If-Modified-Since'] = lastModified; + + http.Response response; + try { + response = await _client + .get(Uri.parse(url), headers: headers) + .timeout(const Duration(seconds: 20)); + } catch (_) { + return _fromCacheOrError(url, cachedSource, etag, lastModified); + } + + if (response.statusCode == 304) { + return _fromCacheOrError(url, cachedSource, etag, lastModified); + } + if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode); + + final newEtag = response.headers['etag']; + final newLastModified = response.headers['last-modified']; + return _parse(response.body, url, etag: newEtag, lastModified: newLastModified); + } + + /// The page could not be revalidated; use the cached copy when there is one. + FetchResult _fromCacheOrError(String url, String? cachedSource, String? etag, String? lastModified) { + if (cachedSource == null) return const FetchNetworkError(); + return _parse(cachedSource, url, etag: etag, lastModified: lastModified, notModified: true); + } + + FetchResult _parse(String source, String url, + {String? etag, String? lastModified, bool notModified = false}) { + try { + final page = parseMewsPage(source: source, url: url); + return FetchPageOk( + page: page, source: source, etag: etag, lastModified: lastModified, notModified: notModified); + } on MewsParseException catch (e) { + return FetchParseError(e.message, source); + } + } + + Future _submitForm(String url, String method, Map fields) async { + final headers = {'User-Agent': _userAgent}; + http.Response response; + if (method == 'post') { + response = await _client + .post(Uri.parse(url), headers: headers, body: fields) + .timeout(const Duration(seconds: 20)); + } else { + response = await _client + .get(Uri.parse(url).replace(queryParameters: fields), headers: headers) + .timeout(const Duration(seconds: 20)); + } + if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode); + return _parse(response.body, url); + } + + @override + Future close() async => _client.close(); +} diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart new file mode 100644 index 0000000..8cd83e8 --- /dev/null +++ b/app/lib/application/controllers/browser_controller.dart @@ -0,0 +1,151 @@ +import 'package:actors/actors.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:signals/signals.dart'; + +import '../../database/database.dart'; +import '../../mews/document.dart'; +import '../actors/fetch_actor.dart'; + +/// What went wrong while browsing. The presentation layer maps these to +/// user-facing messages; no raw codes cross into the UI. +sealed class BrowseError { + const BrowseError(); +} + +class LoadFailed extends BrowseError { + const LoadFailed(); +} + +class BrokenPage extends BrowseError { + const BrokenPage(this.source); + + /// The markup that failed to parse; "show page text" recovers its content. + final String source; +} + +/// Coordinates navigation: owns the signals the reader UI observes and +/// dispatches fetch commands to the fetch actor. Local-first: a cached page is +/// revalidated with the server but survives any network failure. +class BrowserController { + BrowserController({required MewsDatabase db, required Actor fetch}) + : _db = db, + _fetch = fetch; + + final MewsDatabase _db; + final Actor _fetch; + + /// The stack of visited pages; the reader is at [_stackIndex]. + final List _stack = []; + int _stackIndex = -1; + + final currentPage = signal(null); + final loading = signal(false); + final error = signal(null); + + /// Pages opened this session, so links can show the visited color. + final visitedUrls = signal({}); + + bool get canGoBack => _stackIndex > 0; + bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; + + /// Normalizes what the reader typed into an address: bare domains become + /// https URLs. + static String normalizeAddress(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return ''; + if (trimmed.contains('://')) return trimmed; + return 'https://$trimmed'; + } + + Future open(String input) async { + final url = normalizeAddress(input); + if (url.isEmpty) return; + _stack.removeRange(_stackIndex + 1, _stack.length); + _stack.add(url); + _stackIndex++; + await _loadCurrent(); + } + + Future back() async { + if (!canGoBack) return; + _stackIndex--; + await _loadCurrent(); + } + + Future forward() async { + if (!canGoForward) return; + _stackIndex++; + await _loadCurrent(); + } + + Future reload() => _loadCurrent(); + + Future submitForm(String action, String method, Map fields) async { + loading.value = true; + error.value = null; + final result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields)); + loading.value = false; + switch (result) { + case FetchFormOk(:final page): + _stack.removeRange(_stackIndex + 1, _stack.length); + _stack.add(page.url); + _stackIndex++; + _show(page); + case _: + error.value = const LoadFailed(); + } + } + + /// Reopens the most recent page from the previous session, if any. + Future restoreLastSession() async { + final url = await _db.lastVisitedUrl(); + if (url == null) return; + _stack.add(url); + _stackIndex = 0; + await _loadCurrent(); + } + + Future _loadCurrent() async { + if (_stackIndex < 0) return; + final url = _stack[_stackIndex]; + loading.value = true; + error.value = null; + + final cached = await _db.cachedPage(url); + final result = await _fetch.send(FetchPage( + url: url, + etag: cached?.etag, + lastModified: cached?.lastModified, + cachedSource: cached?.source, + )); + loading.value = false; + + switch (result) { + case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified): + currentPage.value = page; + error.value = null; + visitedUrls.value = {...visitedUrls.value, url}; + if (!notModified) { + await _db.saveCachedPage(CachedPagesCompanion.insert( + url: url, + source: source, + title: Value(page.title), + etag: Value(etag), + lastModified: Value(lastModified), + )); + await _db.recordVisit(url, page.title); + } + case FetchParseError(:final source): + currentPage.value = null; + error.value = BrokenPage(source); + case _: + currentPage.value = null; + error.value = const LoadFailed(); + } + } + + void _show(MewsPage page) { + currentPage.value = page; + _db.recordVisit(page.url, page.title); + } +} diff --git a/app/lib/application/controllers/reader_settings_controller.dart b/app/lib/application/controllers/reader_settings_controller.dart new file mode 100644 index 0000000..cc62fa9 --- /dev/null +++ b/app/lib/application/controllers/reader_settings_controller.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:signals/signals.dart'; + +import '../../database/database.dart'; +import '../../mews/document.dart'; + +/// The reader's presentation choices (spec 8.2) plus the per-app limits they +/// move within. Values persist through ReaderSettings in the database. +class ReaderSettingsController { + ReaderSettingsController(this._db); + + final MewsDatabase _db; + + static const minScale = 0.75; + static const maxScale = 2.0; + static const scaleStep = 0.125; + + final font = signal('Atkinson Hyperlegible'); + final fontScale = signal(1.0); + final themeMode = signal('system'); // system | light | dark + final variant = signal('site'); // site | warm | cool | green | mono + + Future load() async { + final settings = await _db.readSettings(); + if (settings == null) return; + font.value = settings.font; + fontScale.value = settings.fontScale; + themeMode.value = settings.themeMode; + variant.value = settings.variant; + } + + Future setFont(String family) async { + font.value = family; + await _persist(); + } + + Future decreaseSize() => _setScale(fontScale.value - scaleStep); + Future increaseSize() => _setScale(fontScale.value + scaleStep); + + Future _setScale(double value) async { + if (value < minScale || value > maxScale) return; + fontScale.value = value; + await _persist(); + } + + Future setThemeMode(String mode) async { + themeMode.value = mode; + await _persist(); + } + + Future setVariant(String variant) async { + this.variant.value = variant; + await _persist(); + } + + Future _persist() => _db.saveSettings( + font: font.value, + fontScale: fontScale.value, + themeMode: themeMode.value, + variant: variant.value, + ); +} + +/// Bookmarks and subscriptions. Signals expose derived state (which URLs are +/// bookmarked or subscribed); subscription refreshes piggyback on page loads, +/// comparing the page's dated links against the known entry URLs. +class LibraryController { + LibraryController(this._db); + + final MewsDatabase _db; + + final bookmarkedUrls = signal({}); + final subscribedUrls = signal({}); + + /// New posts found on the most recent refresh of a subscribed site, keyed by + /// site URL. Empty when there is nothing to report. + final newEntries = signal(>{}); + + Future load() async { + final bookmarks = await _db.allBookmarks(); + bookmarkedUrls.value = bookmarks.map((b) => b.url).toSet(); + final subs = await _db.allSubscriptions(); + subscribedUrls.value = subs.map((s) => s.url).toSet(); + } + + bool isBookmarked(String url) => bookmarkedUrls.value.contains(url); + bool isSubscribed(String url) => subscribedUrls.value.contains(url); + + Future toggleBookmark(String url, String? title) async { + if (isBookmarked(url)) { + await _db.removeBookmark(url); + bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url); + } else { + await _db.addBookmark(url, title); + bookmarkedUrls.value = {...bookmarkedUrls.value, url}; + } + } + + Future toggleSubscription(String url, String? title, List entries) async { + if (isSubscribed(url)) { + await _db.removeSubscription(url); + subscribedUrls.value = {...subscribedUrls.value}..remove(url); + newEntries.value = {...newEntries.value}..remove(url); + } else { + await _db.addSubscription(url, title, entries.map((e) => e.url).toList()); + subscribedUrls.value = {...subscribedUrls.value, url}; + newEntries.value = {...newEntries.value}..remove(url); + } + } + + /// Revalidates every subscribed page and reports entries the reader has not + /// seen yet (spec 6.1). Called when a subscribed page is opened. + Future refreshSubscription(MewsPage page) async { + final subs = await _db.allSubscriptions(); + final sub = subs.where((s) => s.url == page.url).firstOrNull; + if (sub == null) return; + + final known = (jsonDecode(sub.entryUrls) as List).cast().toSet(); + final fresh = page.entries.where((e) => !known.contains(e.url)).toList(); + if (fresh.isEmpty) { + // Nothing new this time, so the site stops being flagged. + newEntries.value = {...newEntries.value}..remove(sub.url); + await _db.updateSubscription(sub.url, lastCheckedAt: DateTime.now()); + return; + } + + newEntries.value = {...newEntries.value, page.url: fresh}; + await _db.updateSubscription( + sub.url, + entryUrls: [...known, ...fresh.map((e) => e.url)], + lastCheckedAt: DateTime.now(), + ); + } +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart new file mode 100644 index 0000000..ff595d6 --- /dev/null +++ b/app/lib/database/database.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +part 'database.g.dart'; + +/// Visited pages, newest first; the most recent row is the app's start page. +class HistoryItems extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get url => text()(); + TextColumn get title => text().nullable()(); + DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)(); +} + +class Bookmarks extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get url => text()(); + TextColumn get title => text().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} + +/// A subscription to one page (spec 6). [entryUrls] is a JSON list of the +/// entry URLs already seen, so a refresh can report only new posts. +class Subscriptions extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get url => text()(); + TextColumn get title => text().nullable()(); + TextColumn get entryUrls => text().withDefault(const Constant('[]'))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get lastCheckedAt => dateTime().nullable()(); +} + +/// Single-row table (id 0) holding the reader's presentation choices. +class ReaderSettings extends Table { + IntColumn get id => integer().withDefault(const Constant(0))(); + TextColumn get font => text().withDefault(const Constant('Atkinson Hyperlegible'))(); + RealColumn get fontScale => real().withDefault(const Constant(1.0))(); + TextColumn get themeMode => text().withDefault(const Constant('system'))(); + TextColumn get variant => text().withDefault(const Constant('site'))(); + + @override + Set get primaryKey => {id}; +} + +/// The fetched source of a page, kept so the reader can reopen it offline. +class CachedPages extends Table { + TextColumn get url => text()(); + TextColumn get title => text().nullable()(); + TextColumn get source => text()(); + TextColumn get etag => text().nullable()(); + TextColumn get lastModified => text().nullable()(); + DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {url}; +} + +@DriftDatabase(tables: [HistoryItems, Bookmarks, Subscriptions, ReaderSettings, CachedPages]) +class MewsDatabase extends _$MewsDatabase { + MewsDatabase() : super(_open()); + + /// For tests. + MewsDatabase.test(super.executor); + + @override + int get schemaVersion => 1; + + static QueryExecutor _open() => LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + return NativeDatabase.createInBackground(File(p.join(dir.path, 'mews.sqlite'))); + }); + + // History ------------------------------------------------------------- + + Future recordVisit(String url, String? title) => + (into(historyItems).insert(HistoryItemsCompanion.insert(url: url, title: Value(title)))); + + Future lastVisitedUrl() async { + final query = (select(historyItems) + ..orderBy([ + (r) => OrderingTerm.desc(r.visitedAt), + // Tiebreak pages recorded within the same clock tick. + (r) => OrderingTerm.desc(r.id), + ]) + ..limit(1)); + final row = await query.getSingleOrNull(); + return row?.url; + } + + // Bookmarks ----------------------------------------------------------- + + Future> allBookmarks() => select(bookmarks).get(); + + Future addBookmark(String url, String? title) => into(bookmarks).insert( + BookmarksCompanion.insert(url: url, title: Value(title)), + mode: InsertMode.insertOrIgnore); + + Future removeBookmark(String url) => + (delete(bookmarks)..where((r) => r.url.equals(url))).go(); + + // Subscriptions --------------------------------------------------------- + + Future addSubscription(String url, String? title, List entryUrls) => into( + subscriptions) + .insert( + SubscriptionsCompanion.insert( + url: url, title: Value(title), entryUrls: Value(jsonEncode(entryUrls))), + mode: InsertMode.insertOrIgnore); + + Future removeSubscription(String url) => + (delete(subscriptions)..where((r) => r.url.equals(url))).go(); + + Future> allSubscriptions() => select(subscriptions).get(); + + Future updateSubscription(String url, + {String? title, List? entryUrls, DateTime? lastCheckedAt}) => + (update(subscriptions)..where((r) => r.url.equals(url))).write(SubscriptionsCompanion( + title: Value(title), + entryUrls: entryUrls == null ? const Value.absent() : Value(jsonEncode(entryUrls)), + lastCheckedAt: Value(lastCheckedAt), + )); + + // Settings --------------------------------------------------------------- + + Future readSettings() async { + final query = select(readerSettings)..where((r) => r.id.equals(0)); + return query.getSingleOrNull(); + } + + Future saveSettings({ + required String font, + required double fontScale, + required String themeMode, + required String variant, + }) => + into(readerSettings).insert( + ReaderSettingsCompanion.insert( + id: const Value(0), + font: Value(font), + fontScale: Value(fontScale), + themeMode: Value(themeMode), + variant: Value(variant), + ), + mode: InsertMode.insertOrReplace); + + // Cache -------------------------------------------------------------------- + + Future cachedPage(String url) async => + (select(cachedPages)..where((r) => r.url.equals(url))).getSingleOrNull(); + + Future saveCachedPage(CachedPagesCompanion entry) => + into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace); +} diff --git a/app/lib/database/database.g.dart b/app/lib/database/database.g.dart new file mode 100644 index 0000000..bc7601c --- /dev/null +++ b/app/lib/database/database.g.dart @@ -0,0 +1,2760 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $HistoryItemsTable extends HistoryItems + with TableInfo<$HistoryItemsTable, HistoryItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $HistoryItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _visitedAtMeta = const VerificationMeta( + 'visitedAt', + ); + @override + late final GeneratedColumn visitedAt = GeneratedColumn( + 'visited_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, url, title, visitedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('visited_at')) { + context.handle( + _visitedAtMeta, + visitedAt.isAcceptableOrUnknown(data['visited_at']!, _visitedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + HistoryItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryItem( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + visitedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}visited_at'], + )!, + ); + } + + @override + $HistoryItemsTable createAlias(String alias) { + return $HistoryItemsTable(attachedDatabase, alias); + } +} + +class HistoryItem extends DataClass implements Insertable { + final int id; + final String url; + final String? title; + final DateTime visitedAt; + const HistoryItem({ + required this.id, + required this.url, + this.title, + required this.visitedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['url'] = Variable(url); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['visited_at'] = Variable(visitedAt); + return map; + } + + HistoryItemsCompanion toCompanion(bool nullToAbsent) { + return HistoryItemsCompanion( + id: Value(id), + url: Value(url), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + visitedAt: Value(visitedAt), + ); + } + + factory HistoryItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryItem( + id: serializer.fromJson(json['id']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + visitedAt: serializer.fromJson(json['visitedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'visitedAt': serializer.toJson(visitedAt), + }; + } + + HistoryItem copyWith({ + int? id, + String? url, + Value title = const Value.absent(), + DateTime? visitedAt, + }) => HistoryItem( + id: id ?? this.id, + url: url ?? this.url, + title: title.present ? title.value : this.title, + visitedAt: visitedAt ?? this.visitedAt, + ); + HistoryItem copyWithCompanion(HistoryItemsCompanion data) { + return HistoryItem( + id: data.id.present ? data.id.value : this.id, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + visitedAt: data.visitedAt.present ? data.visitedAt.value : this.visitedAt, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryItem(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('visitedAt: $visitedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, url, title, visitedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryItem && + other.id == this.id && + other.url == this.url && + other.title == this.title && + other.visitedAt == this.visitedAt); +} + +class HistoryItemsCompanion extends UpdateCompanion { + final Value id; + final Value url; + final Value title; + final Value visitedAt; + const HistoryItemsCompanion({ + this.id = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.visitedAt = const Value.absent(), + }); + HistoryItemsCompanion.insert({ + this.id = const Value.absent(), + required String url, + this.title = const Value.absent(), + this.visitedAt = const Value.absent(), + }) : url = Value(url); + static Insertable custom({ + Expression? id, + Expression? url, + Expression? title, + Expression? visitedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (visitedAt != null) 'visited_at': visitedAt, + }); + } + + HistoryItemsCompanion copyWith({ + Value? id, + Value? url, + Value? title, + Value? visitedAt, + }) { + return HistoryItemsCompanion( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + visitedAt: visitedAt ?? this.visitedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (visitedAt.present) { + map['visited_at'] = Variable(visitedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryItemsCompanion(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('visitedAt: $visitedAt') + ..write(')')) + .toString(); + } +} + +class $BookmarksTable extends Bookmarks + with TableInfo<$BookmarksTable, Bookmark> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BookmarksTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, url, title, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bookmarks'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Bookmark map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Bookmark( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $BookmarksTable createAlias(String alias) { + return $BookmarksTable(attachedDatabase, alias); + } +} + +class Bookmark extends DataClass implements Insertable { + final int id; + final String url; + final String? title; + final DateTime createdAt; + const Bookmark({ + required this.id, + required this.url, + this.title, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['url'] = Variable(url); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['created_at'] = Variable(createdAt); + return map; + } + + BookmarksCompanion toCompanion(bool nullToAbsent) { + return BookmarksCompanion( + id: Value(id), + url: Value(url), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + createdAt: Value(createdAt), + ); + } + + factory Bookmark.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Bookmark( + id: serializer.fromJson(json['id']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Bookmark copyWith({ + int? id, + String? url, + Value title = const Value.absent(), + DateTime? createdAt, + }) => Bookmark( + id: id ?? this.id, + url: url ?? this.url, + title: title.present ? title.value : this.title, + createdAt: createdAt ?? this.createdAt, + ); + Bookmark copyWithCompanion(BookmarksCompanion data) { + return Bookmark( + id: data.id.present ? data.id.value : this.id, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Bookmark(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, url, title, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Bookmark && + other.id == this.id && + other.url == this.url && + other.title == this.title && + other.createdAt == this.createdAt); +} + +class BookmarksCompanion extends UpdateCompanion { + final Value id; + final Value url; + final Value title; + final Value createdAt; + const BookmarksCompanion({ + this.id = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.createdAt = const Value.absent(), + }); + BookmarksCompanion.insert({ + this.id = const Value.absent(), + required String url, + this.title = const Value.absent(), + this.createdAt = const Value.absent(), + }) : url = Value(url); + static Insertable custom({ + Expression? id, + Expression? url, + Expression? title, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (createdAt != null) 'created_at': createdAt, + }); + } + + BookmarksCompanion copyWith({ + Value? id, + Value? url, + Value? title, + Value? createdAt, + }) { + return BookmarksCompanion( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BookmarksCompanion(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $SubscriptionsTable extends Subscriptions + with TableInfo<$SubscriptionsTable, Subscription> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SubscriptionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _entryUrlsMeta = const VerificationMeta( + 'entryUrls', + ); + @override + late final GeneratedColumn entryUrls = GeneratedColumn( + 'entry_urls', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _lastCheckedAtMeta = const VerificationMeta( + 'lastCheckedAt', + ); + @override + late final GeneratedColumn lastCheckedAt = + GeneratedColumn( + 'last_checked_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + url, + title, + entryUrls, + createdAt, + lastCheckedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'subscriptions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('entry_urls')) { + context.handle( + _entryUrlsMeta, + entryUrls.isAcceptableOrUnknown(data['entry_urls']!, _entryUrlsMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('last_checked_at')) { + context.handle( + _lastCheckedAtMeta, + lastCheckedAt.isAcceptableOrUnknown( + data['last_checked_at']!, + _lastCheckedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Subscription map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Subscription( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + entryUrls: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}entry_urls'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + lastCheckedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_checked_at'], + ), + ); + } + + @override + $SubscriptionsTable createAlias(String alias) { + return $SubscriptionsTable(attachedDatabase, alias); + } +} + +class Subscription extends DataClass implements Insertable { + final int id; + final String url; + final String? title; + final String entryUrls; + final DateTime createdAt; + final DateTime? lastCheckedAt; + const Subscription({ + required this.id, + required this.url, + this.title, + required this.entryUrls, + required this.createdAt, + this.lastCheckedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['url'] = Variable(url); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['entry_urls'] = Variable(entryUrls); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || lastCheckedAt != null) { + map['last_checked_at'] = Variable(lastCheckedAt); + } + return map; + } + + SubscriptionsCompanion toCompanion(bool nullToAbsent) { + return SubscriptionsCompanion( + id: Value(id), + url: Value(url), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + entryUrls: Value(entryUrls), + createdAt: Value(createdAt), + lastCheckedAt: lastCheckedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastCheckedAt), + ); + } + + factory Subscription.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Subscription( + id: serializer.fromJson(json['id']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + entryUrls: serializer.fromJson(json['entryUrls']), + createdAt: serializer.fromJson(json['createdAt']), + lastCheckedAt: serializer.fromJson(json['lastCheckedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'entryUrls': serializer.toJson(entryUrls), + 'createdAt': serializer.toJson(createdAt), + 'lastCheckedAt': serializer.toJson(lastCheckedAt), + }; + } + + Subscription copyWith({ + int? id, + String? url, + Value title = const Value.absent(), + String? entryUrls, + DateTime? createdAt, + Value lastCheckedAt = const Value.absent(), + }) => Subscription( + id: id ?? this.id, + url: url ?? this.url, + title: title.present ? title.value : this.title, + entryUrls: entryUrls ?? this.entryUrls, + createdAt: createdAt ?? this.createdAt, + lastCheckedAt: lastCheckedAt.present + ? lastCheckedAt.value + : this.lastCheckedAt, + ); + Subscription copyWithCompanion(SubscriptionsCompanion data) { + return Subscription( + id: data.id.present ? data.id.value : this.id, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + entryUrls: data.entryUrls.present ? data.entryUrls.value : this.entryUrls, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastCheckedAt: data.lastCheckedAt.present + ? data.lastCheckedAt.value + : this.lastCheckedAt, + ); + } + + @override + String toString() { + return (StringBuffer('Subscription(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('entryUrls: $entryUrls, ') + ..write('createdAt: $createdAt, ') + ..write('lastCheckedAt: $lastCheckedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, url, title, entryUrls, createdAt, lastCheckedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Subscription && + other.id == this.id && + other.url == this.url && + other.title == this.title && + other.entryUrls == this.entryUrls && + other.createdAt == this.createdAt && + other.lastCheckedAt == this.lastCheckedAt); +} + +class SubscriptionsCompanion extends UpdateCompanion { + final Value id; + final Value url; + final Value title; + final Value entryUrls; + final Value createdAt; + final Value lastCheckedAt; + const SubscriptionsCompanion({ + this.id = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.entryUrls = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastCheckedAt = const Value.absent(), + }); + SubscriptionsCompanion.insert({ + this.id = const Value.absent(), + required String url, + this.title = const Value.absent(), + this.entryUrls = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastCheckedAt = const Value.absent(), + }) : url = Value(url); + static Insertable custom({ + Expression? id, + Expression? url, + Expression? title, + Expression? entryUrls, + Expression? createdAt, + Expression? lastCheckedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (entryUrls != null) 'entry_urls': entryUrls, + if (createdAt != null) 'created_at': createdAt, + if (lastCheckedAt != null) 'last_checked_at': lastCheckedAt, + }); + } + + SubscriptionsCompanion copyWith({ + Value? id, + Value? url, + Value? title, + Value? entryUrls, + Value? createdAt, + Value? lastCheckedAt, + }) { + return SubscriptionsCompanion( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + entryUrls: entryUrls ?? this.entryUrls, + createdAt: createdAt ?? this.createdAt, + lastCheckedAt: lastCheckedAt ?? this.lastCheckedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (entryUrls.present) { + map['entry_urls'] = Variable(entryUrls.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastCheckedAt.present) { + map['last_checked_at'] = Variable(lastCheckedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SubscriptionsCompanion(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('entryUrls: $entryUrls, ') + ..write('createdAt: $createdAt, ') + ..write('lastCheckedAt: $lastCheckedAt') + ..write(')')) + .toString(); + } +} + +class $ReaderSettingsTable extends ReaderSettings + with TableInfo<$ReaderSettingsTable, ReaderSetting> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ReaderSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _fontMeta = const VerificationMeta('font'); + @override + late final GeneratedColumn font = GeneratedColumn( + 'font', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('Atkinson Hyperlegible'), + ); + static const VerificationMeta _fontScaleMeta = const VerificationMeta( + 'fontScale', + ); + @override + late final GeneratedColumn fontScale = GeneratedColumn( + 'font_scale', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(1.0), + ); + static const VerificationMeta _themeModeMeta = const VerificationMeta( + 'themeMode', + ); + @override + late final GeneratedColumn themeMode = GeneratedColumn( + 'theme_mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('system'), + ); + static const VerificationMeta _variantMeta = const VerificationMeta( + 'variant', + ); + @override + late final GeneratedColumn variant = GeneratedColumn( + 'variant', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('site'), + ); + @override + List get $columns => [ + id, + font, + fontScale, + themeMode, + variant, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'reader_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('font')) { + context.handle( + _fontMeta, + font.isAcceptableOrUnknown(data['font']!, _fontMeta), + ); + } + if (data.containsKey('font_scale')) { + context.handle( + _fontScaleMeta, + fontScale.isAcceptableOrUnknown(data['font_scale']!, _fontScaleMeta), + ); + } + if (data.containsKey('theme_mode')) { + context.handle( + _themeModeMeta, + themeMode.isAcceptableOrUnknown(data['theme_mode']!, _themeModeMeta), + ); + } + if (data.containsKey('variant')) { + context.handle( + _variantMeta, + variant.isAcceptableOrUnknown(data['variant']!, _variantMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ReaderSetting map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ReaderSetting( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + font: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}font'], + )!, + fontScale: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}font_scale'], + )!, + themeMode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_mode'], + )!, + variant: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}variant'], + )!, + ); + } + + @override + $ReaderSettingsTable createAlias(String alias) { + return $ReaderSettingsTable(attachedDatabase, alias); + } +} + +class ReaderSetting extends DataClass implements Insertable { + final int id; + final String font; + final double fontScale; + final String themeMode; + final String variant; + const ReaderSetting({ + required this.id, + required this.font, + required this.fontScale, + required this.themeMode, + required this.variant, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['font'] = Variable(font); + map['font_scale'] = Variable(fontScale); + map['theme_mode'] = Variable(themeMode); + map['variant'] = Variable(variant); + return map; + } + + ReaderSettingsCompanion toCompanion(bool nullToAbsent) { + return ReaderSettingsCompanion( + id: Value(id), + font: Value(font), + fontScale: Value(fontScale), + themeMode: Value(themeMode), + variant: Value(variant), + ); + } + + factory ReaderSetting.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ReaderSetting( + id: serializer.fromJson(json['id']), + font: serializer.fromJson(json['font']), + fontScale: serializer.fromJson(json['fontScale']), + themeMode: serializer.fromJson(json['themeMode']), + variant: serializer.fromJson(json['variant']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'font': serializer.toJson(font), + 'fontScale': serializer.toJson(fontScale), + 'themeMode': serializer.toJson(themeMode), + 'variant': serializer.toJson(variant), + }; + } + + ReaderSetting copyWith({ + int? id, + String? font, + double? fontScale, + String? themeMode, + String? variant, + }) => ReaderSetting( + id: id ?? this.id, + font: font ?? this.font, + fontScale: fontScale ?? this.fontScale, + themeMode: themeMode ?? this.themeMode, + variant: variant ?? this.variant, + ); + ReaderSetting copyWithCompanion(ReaderSettingsCompanion data) { + return ReaderSetting( + id: data.id.present ? data.id.value : this.id, + font: data.font.present ? data.font.value : this.font, + fontScale: data.fontScale.present ? data.fontScale.value : this.fontScale, + themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, + variant: data.variant.present ? data.variant.value : this.variant, + ); + } + + @override + String toString() { + return (StringBuffer('ReaderSetting(') + ..write('id: $id, ') + ..write('font: $font, ') + ..write('fontScale: $fontScale, ') + ..write('themeMode: $themeMode, ') + ..write('variant: $variant') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, font, fontScale, themeMode, variant); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ReaderSetting && + other.id == this.id && + other.font == this.font && + other.fontScale == this.fontScale && + other.themeMode == this.themeMode && + other.variant == this.variant); +} + +class ReaderSettingsCompanion extends UpdateCompanion { + final Value id; + final Value font; + final Value fontScale; + final Value themeMode; + final Value variant; + const ReaderSettingsCompanion({ + this.id = const Value.absent(), + this.font = const Value.absent(), + this.fontScale = const Value.absent(), + this.themeMode = const Value.absent(), + this.variant = const Value.absent(), + }); + ReaderSettingsCompanion.insert({ + this.id = const Value.absent(), + this.font = const Value.absent(), + this.fontScale = const Value.absent(), + this.themeMode = const Value.absent(), + this.variant = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? font, + Expression? fontScale, + Expression? themeMode, + Expression? variant, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (font != null) 'font': font, + if (fontScale != null) 'font_scale': fontScale, + if (themeMode != null) 'theme_mode': themeMode, + if (variant != null) 'variant': variant, + }); + } + + ReaderSettingsCompanion copyWith({ + Value? id, + Value? font, + Value? fontScale, + Value? themeMode, + Value? variant, + }) { + return ReaderSettingsCompanion( + id: id ?? this.id, + font: font ?? this.font, + fontScale: fontScale ?? this.fontScale, + themeMode: themeMode ?? this.themeMode, + variant: variant ?? this.variant, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (font.present) { + map['font'] = Variable(font.value); + } + if (fontScale.present) { + map['font_scale'] = Variable(fontScale.value); + } + if (themeMode.present) { + map['theme_mode'] = Variable(themeMode.value); + } + if (variant.present) { + map['variant'] = Variable(variant.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ReaderSettingsCompanion(') + ..write('id: $id, ') + ..write('font: $font, ') + ..write('fontScale: $fontScale, ') + ..write('themeMode: $themeMode, ') + ..write('variant: $variant') + ..write(')')) + .toString(); + } +} + +class $CachedPagesTable extends CachedPages + with TableInfo<$CachedPagesTable, CachedPage> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedPagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastModifiedMeta = const VerificationMeta( + 'lastModified', + ); + @override + late final GeneratedColumn lastModified = GeneratedColumn( + 'last_modified', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + url, + title, + source, + etag, + lastModified, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_pages'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } else if (isInserting) { + context.missing(_sourceMeta); + } + if (data.containsKey('etag')) { + context.handle( + _etagMeta, + etag.isAcceptableOrUnknown(data['etag']!, _etagMeta), + ); + } + if (data.containsKey('last_modified')) { + context.handle( + _lastModifiedMeta, + lastModified.isAcceptableOrUnknown( + data['last_modified']!, + _lastModifiedMeta, + ), + ); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {url}; + @override + CachedPage map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedPage( + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + etag: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}etag'], + ), + lastModified: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_modified'], + ), + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedPagesTable createAlias(String alias) { + return $CachedPagesTable(attachedDatabase, alias); + } +} + +class CachedPage extends DataClass implements Insertable { + final String url; + final String? title; + final String source; + final String? etag; + final String? lastModified; + final DateTime cachedAt; + const CachedPage({ + required this.url, + this.title, + required this.source, + this.etag, + this.lastModified, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url'] = Variable(url); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['source'] = Variable(source); + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); + } + if (!nullToAbsent || lastModified != null) { + map['last_modified'] = Variable(lastModified); + } + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedPagesCompanion toCompanion(bool nullToAbsent) { + return CachedPagesCompanion( + url: Value(url), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + source: Value(source), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent + ? const Value.absent() + : Value(lastModified), + cachedAt: Value(cachedAt), + ); + } + + factory CachedPage.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedPage( + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + source: serializer.fromJson(json['source']), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'source': serializer.toJson(source), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedPage copyWith({ + String? url, + Value title = const Value.absent(), + String? source, + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + DateTime? cachedAt, + }) => CachedPage( + url: url ?? this.url, + title: title.present ? title.value : this.title, + source: source ?? this.source, + etag: etag.present ? etag.value : this.etag, + lastModified: lastModified.present ? lastModified.value : this.lastModified, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedPage copyWithCompanion(CachedPagesCompanion data) { + return CachedPage( + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + source: data.source.present ? data.source.value : this.source, + etag: data.etag.present ? data.etag.value : this.etag, + lastModified: data.lastModified.present + ? data.lastModified.value + : this.lastModified, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedPage(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('source: $source, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(url, title, source, etag, lastModified, cachedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedPage && + other.url == this.url && + other.title == this.title && + other.source == this.source && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.cachedAt == this.cachedAt); +} + +class CachedPagesCompanion extends UpdateCompanion { + final Value url; + final Value title; + final Value source; + final Value etag; + final Value lastModified; + final Value cachedAt; + final Value rowid; + const CachedPagesCompanion({ + this.url = const Value.absent(), + this.title = const Value.absent(), + this.source = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.cachedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CachedPagesCompanion.insert({ + required String url, + this.title = const Value.absent(), + required String source, + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.cachedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : url = Value(url), + source = Value(source); + static Insertable custom({ + Expression? url, + Expression? title, + Expression? source, + Expression? etag, + Expression? lastModified, + Expression? cachedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (url != null) 'url': url, + if (title != null) 'title': title, + if (source != null) 'source': source, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (cachedAt != null) 'cached_at': cachedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CachedPagesCompanion copyWith({ + Value? url, + Value? title, + Value? source, + Value? etag, + Value? lastModified, + Value? cachedAt, + Value? rowid, + }) { + return CachedPagesCompanion( + url: url ?? this.url, + title: title ?? this.title, + source: source ?? this.source, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + cachedAt: cachedAt ?? this.cachedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (etag.present) { + map['etag'] = Variable(etag.value); + } + if (lastModified.present) { + map['last_modified'] = Variable(lastModified.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedPagesCompanion(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('source: $source, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('cachedAt: $cachedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$MewsDatabase extends GeneratedDatabase { + _$MewsDatabase(QueryExecutor e) : super(e); + $MewsDatabaseManager get managers => $MewsDatabaseManager(this); + late final $HistoryItemsTable historyItems = $HistoryItemsTable(this); + late final $BookmarksTable bookmarks = $BookmarksTable(this); + late final $SubscriptionsTable subscriptions = $SubscriptionsTable(this); + late final $ReaderSettingsTable readerSettings = $ReaderSettingsTable(this); + late final $CachedPagesTable cachedPages = $CachedPagesTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + historyItems, + bookmarks, + subscriptions, + readerSettings, + cachedPages, + ]; +} + +typedef $$HistoryItemsTableCreateCompanionBuilder = + HistoryItemsCompanion Function({ + Value id, + required String url, + Value title, + Value visitedAt, + }); +typedef $$HistoryItemsTableUpdateCompanionBuilder = + HistoryItemsCompanion Function({ + Value id, + Value url, + Value title, + Value visitedAt, + }); + +class $$HistoryItemsTableFilterComposer + extends Composer<_$MewsDatabase, $HistoryItemsTable> { + $$HistoryItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get visitedAt => $composableBuilder( + column: $table.visitedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$HistoryItemsTableOrderingComposer + extends Composer<_$MewsDatabase, $HistoryItemsTable> { + $$HistoryItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get visitedAt => $composableBuilder( + column: $table.visitedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$HistoryItemsTableAnnotationComposer + extends Composer<_$MewsDatabase, $HistoryItemsTable> { + $$HistoryItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get visitedAt => + $composableBuilder(column: $table.visitedAt, builder: (column) => column); +} + +class $$HistoryItemsTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $HistoryItemsTable, + HistoryItem, + $$HistoryItemsTableFilterComposer, + $$HistoryItemsTableOrderingComposer, + $$HistoryItemsTableAnnotationComposer, + $$HistoryItemsTableCreateCompanionBuilder, + $$HistoryItemsTableUpdateCompanionBuilder, + ( + HistoryItem, + BaseReferences<_$MewsDatabase, $HistoryItemsTable, HistoryItem>, + ), + HistoryItem, + PrefetchHooks Function() + > { + $$HistoryItemsTableTableManager(_$MewsDatabase db, $HistoryItemsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$HistoryItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$HistoryItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$HistoryItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value visitedAt = const Value.absent(), + }) => HistoryItemsCompanion( + id: id, + url: url, + title: title, + visitedAt: visitedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String url, + Value title = const Value.absent(), + Value visitedAt = const Value.absent(), + }) => HistoryItemsCompanion.insert( + id: id, + url: url, + title: title, + visitedAt: visitedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$HistoryItemsTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $HistoryItemsTable, + HistoryItem, + $$HistoryItemsTableFilterComposer, + $$HistoryItemsTableOrderingComposer, + $$HistoryItemsTableAnnotationComposer, + $$HistoryItemsTableCreateCompanionBuilder, + $$HistoryItemsTableUpdateCompanionBuilder, + ( + HistoryItem, + BaseReferences<_$MewsDatabase, $HistoryItemsTable, HistoryItem>, + ), + HistoryItem, + PrefetchHooks Function() + >; +typedef $$BookmarksTableCreateCompanionBuilder = + BookmarksCompanion Function({ + Value id, + required String url, + Value title, + Value createdAt, + }); +typedef $$BookmarksTableUpdateCompanionBuilder = + BookmarksCompanion Function({ + Value id, + Value url, + Value title, + Value createdAt, + }); + +class $$BookmarksTableFilterComposer + extends Composer<_$MewsDatabase, $BookmarksTable> { + $$BookmarksTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$BookmarksTableOrderingComposer + extends Composer<_$MewsDatabase, $BookmarksTable> { + $$BookmarksTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$BookmarksTableAnnotationComposer + extends Composer<_$MewsDatabase, $BookmarksTable> { + $$BookmarksTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$BookmarksTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $BookmarksTable, + Bookmark, + $$BookmarksTableFilterComposer, + $$BookmarksTableOrderingComposer, + $$BookmarksTableAnnotationComposer, + $$BookmarksTableCreateCompanionBuilder, + $$BookmarksTableUpdateCompanionBuilder, + (Bookmark, BaseReferences<_$MewsDatabase, $BookmarksTable, Bookmark>), + Bookmark, + PrefetchHooks Function() + > { + $$BookmarksTableTableManager(_$MewsDatabase db, $BookmarksTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$BookmarksTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BookmarksTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BookmarksTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value createdAt = const Value.absent(), + }) => BookmarksCompanion( + id: id, + url: url, + title: title, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String url, + Value title = const Value.absent(), + Value createdAt = const Value.absent(), + }) => BookmarksCompanion.insert( + id: id, + url: url, + title: title, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$BookmarksTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $BookmarksTable, + Bookmark, + $$BookmarksTableFilterComposer, + $$BookmarksTableOrderingComposer, + $$BookmarksTableAnnotationComposer, + $$BookmarksTableCreateCompanionBuilder, + $$BookmarksTableUpdateCompanionBuilder, + (Bookmark, BaseReferences<_$MewsDatabase, $BookmarksTable, Bookmark>), + Bookmark, + PrefetchHooks Function() + >; +typedef $$SubscriptionsTableCreateCompanionBuilder = + SubscriptionsCompanion Function({ + Value id, + required String url, + Value title, + Value entryUrls, + Value createdAt, + Value lastCheckedAt, + }); +typedef $$SubscriptionsTableUpdateCompanionBuilder = + SubscriptionsCompanion Function({ + Value id, + Value url, + Value title, + Value entryUrls, + Value createdAt, + Value lastCheckedAt, + }); + +class $$SubscriptionsTableFilterComposer + extends Composer<_$MewsDatabase, $SubscriptionsTable> { + $$SubscriptionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get entryUrls => $composableBuilder( + column: $table.entryUrls, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastCheckedAt => $composableBuilder( + column: $table.lastCheckedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SubscriptionsTableOrderingComposer + extends Composer<_$MewsDatabase, $SubscriptionsTable> { + $$SubscriptionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get entryUrls => $composableBuilder( + column: $table.entryUrls, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastCheckedAt => $composableBuilder( + column: $table.lastCheckedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SubscriptionsTableAnnotationComposer + extends Composer<_$MewsDatabase, $SubscriptionsTable> { + $$SubscriptionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get entryUrls => + $composableBuilder(column: $table.entryUrls, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastCheckedAt => $composableBuilder( + column: $table.lastCheckedAt, + builder: (column) => column, + ); +} + +class $$SubscriptionsTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $SubscriptionsTable, + Subscription, + $$SubscriptionsTableFilterComposer, + $$SubscriptionsTableOrderingComposer, + $$SubscriptionsTableAnnotationComposer, + $$SubscriptionsTableCreateCompanionBuilder, + $$SubscriptionsTableUpdateCompanionBuilder, + ( + Subscription, + BaseReferences<_$MewsDatabase, $SubscriptionsTable, Subscription>, + ), + Subscription, + PrefetchHooks Function() + > { + $$SubscriptionsTableTableManager(_$MewsDatabase db, $SubscriptionsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SubscriptionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SubscriptionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SubscriptionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value entryUrls = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastCheckedAt = const Value.absent(), + }) => SubscriptionsCompanion( + id: id, + url: url, + title: title, + entryUrls: entryUrls, + createdAt: createdAt, + lastCheckedAt: lastCheckedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String url, + Value title = const Value.absent(), + Value entryUrls = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastCheckedAt = const Value.absent(), + }) => SubscriptionsCompanion.insert( + id: id, + url: url, + title: title, + entryUrls: entryUrls, + createdAt: createdAt, + lastCheckedAt: lastCheckedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SubscriptionsTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $SubscriptionsTable, + Subscription, + $$SubscriptionsTableFilterComposer, + $$SubscriptionsTableOrderingComposer, + $$SubscriptionsTableAnnotationComposer, + $$SubscriptionsTableCreateCompanionBuilder, + $$SubscriptionsTableUpdateCompanionBuilder, + ( + Subscription, + BaseReferences<_$MewsDatabase, $SubscriptionsTable, Subscription>, + ), + Subscription, + PrefetchHooks Function() + >; +typedef $$ReaderSettingsTableCreateCompanionBuilder = + ReaderSettingsCompanion Function({ + Value id, + Value font, + Value fontScale, + Value themeMode, + Value variant, + }); +typedef $$ReaderSettingsTableUpdateCompanionBuilder = + ReaderSettingsCompanion Function({ + Value id, + Value font, + Value fontScale, + Value themeMode, + Value variant, + }); + +class $$ReaderSettingsTableFilterComposer + extends Composer<_$MewsDatabase, $ReaderSettingsTable> { + $$ReaderSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get font => $composableBuilder( + column: $table.font, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fontScale => $composableBuilder( + column: $table.fontScale, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get variant => $composableBuilder( + column: $table.variant, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ReaderSettingsTableOrderingComposer + extends Composer<_$MewsDatabase, $ReaderSettingsTable> { + $$ReaderSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get font => $composableBuilder( + column: $table.font, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fontScale => $composableBuilder( + column: $table.fontScale, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get variant => $composableBuilder( + column: $table.variant, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ReaderSettingsTableAnnotationComposer + extends Composer<_$MewsDatabase, $ReaderSettingsTable> { + $$ReaderSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get font => + $composableBuilder(column: $table.font, builder: (column) => column); + + GeneratedColumn get fontScale => + $composableBuilder(column: $table.fontScale, builder: (column) => column); + + GeneratedColumn get themeMode => + $composableBuilder(column: $table.themeMode, builder: (column) => column); + + GeneratedColumn get variant => + $composableBuilder(column: $table.variant, builder: (column) => column); +} + +class $$ReaderSettingsTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $ReaderSettingsTable, + ReaderSetting, + $$ReaderSettingsTableFilterComposer, + $$ReaderSettingsTableOrderingComposer, + $$ReaderSettingsTableAnnotationComposer, + $$ReaderSettingsTableCreateCompanionBuilder, + $$ReaderSettingsTableUpdateCompanionBuilder, + ( + ReaderSetting, + BaseReferences<_$MewsDatabase, $ReaderSettingsTable, ReaderSetting>, + ), + ReaderSetting, + PrefetchHooks Function() + > { + $$ReaderSettingsTableTableManager( + _$MewsDatabase db, + $ReaderSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ReaderSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ReaderSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ReaderSettingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value font = const Value.absent(), + Value fontScale = const Value.absent(), + Value themeMode = const Value.absent(), + Value variant = const Value.absent(), + }) => ReaderSettingsCompanion( + id: id, + font: font, + fontScale: fontScale, + themeMode: themeMode, + variant: variant, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value font = const Value.absent(), + Value fontScale = const Value.absent(), + Value themeMode = const Value.absent(), + Value variant = const Value.absent(), + }) => ReaderSettingsCompanion.insert( + id: id, + font: font, + fontScale: fontScale, + themeMode: themeMode, + variant: variant, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ReaderSettingsTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $ReaderSettingsTable, + ReaderSetting, + $$ReaderSettingsTableFilterComposer, + $$ReaderSettingsTableOrderingComposer, + $$ReaderSettingsTableAnnotationComposer, + $$ReaderSettingsTableCreateCompanionBuilder, + $$ReaderSettingsTableUpdateCompanionBuilder, + ( + ReaderSetting, + BaseReferences<_$MewsDatabase, $ReaderSettingsTable, ReaderSetting>, + ), + ReaderSetting, + PrefetchHooks Function() + >; +typedef $$CachedPagesTableCreateCompanionBuilder = + CachedPagesCompanion Function({ + required String url, + Value title, + required String source, + Value etag, + Value lastModified, + Value cachedAt, + Value rowid, + }); +typedef $$CachedPagesTableUpdateCompanionBuilder = + CachedPagesCompanion Function({ + Value url, + Value title, + Value source, + Value etag, + Value lastModified, + Value cachedAt, + Value rowid, + }); + +class $$CachedPagesTableFilterComposer + extends Composer<_$MewsDatabase, $CachedPagesTable> { + $$CachedPagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedPagesTableOrderingComposer + extends Composer<_$MewsDatabase, $CachedPagesTable> { + $$CachedPagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedPagesTableAnnotationComposer + extends Composer<_$MewsDatabase, $CachedPagesTable> { + $$CachedPagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); + + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => column, + ); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedPagesTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $CachedPagesTable, + CachedPage, + $$CachedPagesTableFilterComposer, + $$CachedPagesTableOrderingComposer, + $$CachedPagesTableAnnotationComposer, + $$CachedPagesTableCreateCompanionBuilder, + $$CachedPagesTableUpdateCompanionBuilder, + ( + CachedPage, + BaseReferences<_$MewsDatabase, $CachedPagesTable, CachedPage>, + ), + CachedPage, + PrefetchHooks Function() + > { + $$CachedPagesTableTableManager(_$MewsDatabase db, $CachedPagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedPagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedPagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedPagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value url = const Value.absent(), + Value title = const Value.absent(), + Value source = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value cachedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedPagesCompanion( + url: url, + title: title, + source: source, + etag: etag, + lastModified: lastModified, + cachedAt: cachedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String url, + Value title = const Value.absent(), + required String source, + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value cachedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedPagesCompanion.insert( + url: url, + title: title, + source: source, + etag: etag, + lastModified: lastModified, + cachedAt: cachedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedPagesTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $CachedPagesTable, + CachedPage, + $$CachedPagesTableFilterComposer, + $$CachedPagesTableOrderingComposer, + $$CachedPagesTableAnnotationComposer, + $$CachedPagesTableCreateCompanionBuilder, + $$CachedPagesTableUpdateCompanionBuilder, + ( + CachedPage, + BaseReferences<_$MewsDatabase, $CachedPagesTable, CachedPage>, + ), + CachedPage, + PrefetchHooks Function() + >; + +class $MewsDatabaseManager { + final _$MewsDatabase _db; + $MewsDatabaseManager(this._db); + $$HistoryItemsTableTableManager get historyItems => + $$HistoryItemsTableTableManager(_db, _db.historyItems); + $$BookmarksTableTableManager get bookmarks => + $$BookmarksTableTableManager(_db, _db.bookmarks); + $$SubscriptionsTableTableManager get subscriptions => + $$SubscriptionsTableTableManager(_db, _db.subscriptions); + $$ReaderSettingsTableTableManager get readerSettings => + $$ReaderSettingsTableTableManager(_db, _db.readerSettings); + $$CachedPagesTableTableManager get cachedPages => + $$CachedPagesTableTableManager(_db, _db.cachedPages); +} diff --git a/app/lib/l10n/strings.dart b/app/lib/l10n/strings.dart new file mode 100644 index 0000000..2f416a0 --- /dev/null +++ b/app/lib/l10n/strings.dart @@ -0,0 +1,39 @@ +import 'package:fluent/fluent.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter/widgets.dart'; + +/// Loads and formats the app's Fluent messages. All user-facing strings come +/// from locales/en-US/main.ftl; lookups fall back to the message ID when a +/// message is missing so that missing translations are obvious in the UI. +class Strings { + final FluentBundle _bundle = FluentBundle('en-US'); + + /// Loads the messages from the app's asset bundle. + Future load() async { + final source = await rootBundle.loadString('locales/en-US/main.ftl'); + loadSource(source); + } + + /// Loads messages from an already-read source, so tests can read the file + /// from disk instead of the asset bundle. + void loadSource(String source) { + _bundle.addMessages(source); + } + + String t(String id, [Map args = const {}]) { + return _bundle.format(id, args: args) ?? id; + } +} + +/// Provides [Strings] to the widget tree. +class StringsScope extends InheritedWidget { + const StringsScope({required this.strings, required super.child, super.key}); + + final Strings strings; + + static Strings of(BuildContext context) => + context.getInheritedWidgetOfExactType()!.strings; + + @override + bool updateShouldNotify(StringsScope oldWidget) => false; +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..98b18f6 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'l10n/strings.dart'; +import 'presentation/app.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final strings = Strings(); + await strings.load(); + + final deps = AppDependencies(); + await deps.start(); + + runApp(MewsApp(deps: deps, strings: strings)); +} diff --git a/app/lib/mews/document.dart b/app/lib/mews/document.dart new file mode 100644 index 0000000..ec2293b --- /dev/null +++ b/app/lib/mews/document.dart @@ -0,0 +1,291 @@ +/// Document model for Mews pages (mews-spec/SPEC.md sections 3 to 6). +/// Pure data: no Flutter imports, so it can be built inside an isolate. +library; + +/// The `body` variant class a page carries (spec 5.3), or [plain] when absent. +enum MewsVariant { plain, warm, cool, green, mono } + +/// A parsed page. [specVersion] holds the conformance marker's value and is +/// null on a page without the marker, which must be shown plainly (spec 8.1). +class MewsPage { + const MewsPage({ + required this.url, + required this.body, + required this.lang, + required this.direction, + required this.variant, + required this.entries, + this.specVersion, + this.title, + this.description, + this.author, + this.atomFeedHref, + }); + + final String url; + final List body; + final String lang; + final TextDirection direction; + final MewsVariant variant; + final List entries; + final String? specVersion; + final String? title; + final String? description; + final String? author; + final String? atomFeedHref; + + bool get isMews => specVersion != null; +} + +/// TextDirection without importing Flutter: pages with dir="rtl" render +/// right-to-left (spec 3). +enum TextDirection { ltr, rtl } + +/// A dated link that starts a list item (spec 6.1). +class MewsEntry { + const MewsEntry({required this.url, required this.date, required this.title}); + + final String url; + final DateTime date; + final String title; +} + +sealed class MewsBlock { + const MewsBlock(); +} + +class HeadingBlock extends MewsBlock { + const HeadingBlock(this.level, this.inline); + + final int level; // 1 to 6 + final List inline; +} + +class ParagraphBlock extends MewsBlock { + const ParagraphBlock(this.inline); + + final List inline; +} + +class QuoteBlock extends MewsBlock { + const QuoteBlock(this.children); + + final List children; +} + +class PreBlock extends MewsBlock { + const PreBlock(this.text); + + final String text; +} + +class AddressBlock extends MewsBlock { + const AddressBlock(this.inline); + + final List inline; +} + +class RuleBlock extends MewsBlock { + const RuleBlock(); +} + +class ListBlock extends MewsBlock { + const ListBlock({required this.ordered, required this.start, required this.items}); + + final bool ordered; + final int start; + final List items; +} + +class MewsListItem { + const MewsListItem(this.blocks); + + final List blocks; +} + +class DefinitionItem { + const DefinitionItem(this.term, this.definition); + + final List term; + final List definition; +} + +class DefinitionListBlock extends MewsBlock { + const DefinitionListBlock(this.items); + + final List items; +} + +class TableBlock extends MewsBlock { + const TableBlock({required this.rows, this.caption, this.summary}); + + final List rows; + final String? caption; + final String? summary; +} + +class MewsTableRow { + const MewsTableRow(this.cells); + + final List cells; +} + +class MewsTableCell { + const MewsTableCell({ + required this.header, + required this.inline, + this.colspan = 1, + this.rowspan = 1, + }); + + final bool header; + final List inline; + final int colspan; + final int rowspan; +} + +/// Same-site images load normally; off-site or data: sources are refused by +/// the client without reader action (specs 4.2 and 8.1), and the alt text is +/// shown in their place. +class ImageBlock extends MewsBlock { + const ImageBlock({required this.src, required this.alt, required this.allowed}); + + final String src; + final String alt; + final bool allowed; +} + +class FormBlock extends MewsBlock { + const FormBlock({ + required this.action, + required this.method, + required this.fields, + required this.submitLabel, + }); + + final String action; + final String method; + final List fields; + final String? submitLabel; +} + +sealed class MewsFormField { + const MewsFormField({required this.name, this.label}); + + final String name; + final String? label; +} + +class MewsTextField extends MewsFormField { + const MewsTextField({ + required super.name, + required this.password, + this.value = '', + this.maxLength, + this.inputMode, + super.label, + }); + + final bool password; + final String value; + final int? maxLength; + final String? inputMode; +} + +class MewsCheckboxField extends MewsFormField { + const MewsCheckboxField({ + required super.name, + required this.checked, + this.value = 'on', + super.label, + }); + + final bool checked; + final String value; +} + +class MewsRadioField extends MewsFormField { + const MewsRadioField({ + required super.name, + required this.checked, + required this.value, + super.label, + }); + + final bool checked; + final String value; +} + +class MewsSelectField extends MewsFormField { + const MewsSelectField({required super.name, required this.options, super.label}); + + final List options; +} + +class MewsSelectOption { + const MewsSelectOption({required this.value, required this.selected}); + + final String value; + final bool selected; +} + +class MewsTextAreaField extends MewsFormField { + const MewsTextAreaField({ + required super.name, + required this.value, + this.rows = 3, + super.label, + }); + + final String value; + final int rows; +} + +/// A hidden input; kept in the model so it round-trips on submit. +class MewsHiddenField extends MewsFormField { + const MewsHiddenField({required super.name, required this.value}); + + final String value; +} + +/// One or more inputs of kind submit/reset collapse into this control. +class MewsSubmitField extends MewsFormField { + const MewsSubmitField({required this.reset, super.label, super.name = ''}); + + final bool reset; +} + +sealed class MewsInline { + const MewsInline(); +} + +class MewsText extends MewsInline { + const MewsText(this.text); + + final String text; +} + +enum EmphasisStyle { emphasis, strong, code, sample, variable, term, quote } + +/// em, i, cite and abbr map to [EmphasisStyle.emphasis]; strong and b to +/// [EmphasisStyle.strong]; kbd and samp to [EmphasisStyle.sample]; var to +/// [EmphasisStyle.variable]; dfn to [EmphasisStyle.term]; q to +/// [EmphasisStyle.quote]. The distinction keeps the renderer honest about what +/// authors meant, per the reader-controls-presentation principle. +class MewsEmphasis extends MewsInline { + const MewsEmphasis(this.style, this.children); + + final EmphasisStyle style; + final List children; +} + +class MewsLink extends MewsInline { + const MewsLink(this.href, this.children); + + final String href; + final List children; +} + +class MewsLineBreak extends MewsInline { + const MewsLineBreak(); +} diff --git a/app/lib/mews/parser.dart b/app/lib/mews/parser.dart new file mode 100644 index 0000000..d2692bd --- /dev/null +++ b/app/lib/mews/parser.dart @@ -0,0 +1,434 @@ +import 'package:xml/xml.dart'; + +import 'document.dart'; + +/// Thrown when a page is not well-formed XML; the client must show a clear +/// error, never guess (spec 8.1). +class MewsParseException implements Exception { + const MewsParseException(this.message); + + final String message; + + @override + String toString() => message; +} + +final _entryDatePattern = RegExp(r'^(\d{4}-\d{2}-\d{2}) (.+)$'); + +/// Parses a fetched document into a [MewsPage]. Pages are parsed as XML +/// whatever the MIME type (spec 7.1). Elements outside the Mews subset keep +/// their text content (so ordinary XHTML still renders) but scripts, styles +/// and objects are dropped entirely, and no author styling can enter the model. +MewsPage parseMewsPage({required String source, required String url}) { + XmlDocument doc; + try { + doc = XmlDocument.parse(source); + } on XmlException catch (e) { + throw MewsParseException(e.toString()); + } + + final html = doc.rootElement; + if (html.name.qualified != 'html') { + throw const MewsParseException('The root element is not html.'); + } + + final head = html.findElements('head').firstOrNull; + final bodyElement = html.findElements('body').firstOrNull; + if (head == null || bodyElement == null) { + throw const MewsParseException('The page has no head or no body.'); + } + + final headData = _parseHead(head); + final blocks = []; + for (final child in bodyElement.children.whereType()) { + _appendBlocks(child, url, blocks); + } + + final dir = _dirOf(html) ?? _dirOf(bodyElement); + return MewsPage( + url: url, + specVersion: headData.specVersion, + title: headData.title, + description: headData.description, + author: headData.author, + atomFeedHref: headData.atomFeedHref, + lang: html.getAttribute('xml:lang') ?? + html.getAttribute('lang') ?? + bodyElement.getAttribute('lang') ?? + 'en', + direction: dir == 'rtl' ? TextDirection.rtl : TextDirection.ltr, + variant: _variantOf(bodyElement.getAttribute('class')), + entries: _entriesOf(bodyElement, url), + body: blocks, + ); +} + +class _HeadData { + String? title; + String? description; + String? author; + String? specVersion; + String? atomFeedHref; +} + +_HeadData _parseHead(XmlElement head) { + final data = _HeadData(); + for (final element in head.children.whereType()) { + switch (element.name.qualified) { + case 'title': + data.title = element.innerText.trim(); + case 'meta': + final name = element.getAttribute('name'); + if (name == 'mews-profile') { + data.specVersion = element.getAttribute('content'); + } else if (name == 'description') { + data.description = element.getAttribute('content'); + } else if (name == 'author') { + data.author = element.getAttribute('content'); + } + case 'link': + final rel = element.getAttribute('rel'); + final type = element.getAttribute('type'); + if (rel == 'alternate' && type == 'application/atom+xml') { + data.atomFeedHref = element.getAttribute('href'); + } + } + } + return data; +} + +String? _dirOf(XmlElement element) => element.getAttribute('dir'); + +MewsVariant _variantOf(String? classes) { + switch (classes) { + case 'mews-warm': + return MewsVariant.warm; + case 'mews-cool': + return MewsVariant.cool; + case 'mews-green': + return MewsVariant.green; + case 'mews-mono': + return MewsVariant.mono; + default: + return MewsVariant.plain; + } +} + +List _entriesOf(XmlElement body, String pageUrl) { + final entries = []; + for (final ul in body.descendants.whereType().where((e) => + e.name.qualified == 'ul' || e.name.qualified == 'ol')) { + for (final li in ul.findElements('li')) { + final link = li.children.whereType().firstOrNull; + if (link == null || link.name.qualified != 'a') continue; + final match = _entryDatePattern.firstMatch(link.innerText.trim()); + if (match == null) continue; + final date = DateTime.tryParse(match.group(1)!); + if (date == null) continue; + entries.add(MewsEntry( + url: Uri.parse(pageUrl).resolve(link.getAttribute('href') ?? '').toString(), + date: date, + title: match.group(2)!.trim(), + )); + } + } + entries.sort((a, b) => b.date.compareTo(a.date)); + return entries; +} + +// Blocks -------------------------------------------------------------------- + +/// Elements whose whole subtree is not content and must never reach the model. +const _droppedElements = {'script', 'style', 'object', 'meta', 'link', 'head'}; + +void _appendBlocks(XmlElement element, String pageUrl, List out) { + switch (element.name.qualified) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + out.add(HeadingBlock(int.parse(element.name.qualified[1]), _inline(element, pageUrl))); + case 'p': + final embedded = []; + out.add(ParagraphBlock(_inline(element, pageUrl, embedded))); + out.addAll(embedded); + case 'blockquote': + final children = []; + for (final child in element.children.whereType()) { + _appendBlocks(child, pageUrl, children); + } + out.add(QuoteBlock(children)); + case 'pre': + out.add(PreBlock(element.innerText)); + case 'address': + final embedded = []; + out.add(AddressBlock(_inline(element, pageUrl, embedded))); + out.addAll(embedded); + case 'hr': + out.add(const RuleBlock()); + case 'ul': + case 'ol': + out.add(_listBlock(element, pageUrl)); + case 'dl': + out.add(_definitionBlock(element, pageUrl)); + case 'table': + out.add(_tableBlock(element, pageUrl)); + case 'img': + out.add(_imageBlock(element, pageUrl)); + case 'form': + out.add(_formBlock(element, pageUrl)); + case 'fieldset': + for (final child in element.children.whereType()) { + _appendBlocks(child, pageUrl, out); + } + default: + // Unknown or dropped wrappers: keep the readable content, drop the rest. + if (!_droppedElements.contains(element.name.qualified)) { + for (final child in element.children.whereType()) { + _appendBlocks(child, pageUrl, out); + } + } + } +} + +ListBlock _listBlock(XmlElement element, String pageUrl) { + final items = []; + for (final li in element.findElements('li')) { + final blocks = []; + for (final child in li.children.whereType()) { + _appendBlocks(child, pageUrl, blocks); + } + items.add(MewsListItem(blocks)); + } + return ListBlock( + ordered: element.name.qualified == 'ol', + start: int.tryParse(element.getAttribute('start') ?? '') ?? 1, + items: items, + ); +} + +DefinitionListBlock _definitionBlock(XmlElement element, String pageUrl) { + final items = []; + List? term; + for (final child in element.children.whereType()) { + switch (child.name.qualified) { + case 'dt': + term = _inline(child, pageUrl); + case 'dd': + items.add(DefinitionItem(term ?? const [], _inline(child, pageUrl))); + term = null; + } + } + return DefinitionListBlock(items); +} + +TableBlock _tableBlock(XmlElement element, String pageUrl) { + final rows = []; + for (final row in element.descendants + .whereType() + .where((e) => e.name.qualified == 'tr')) { + final cells = []; + for (final cell in row.children.whereType()) { + final isHeader = cell.name.qualified == 'th'; + if (!isHeader && cell.name.qualified != 'td') continue; + cells.add(MewsTableCell( + header: isHeader, + inline: _inline(cell, pageUrl), + colspan: int.tryParse(cell.getAttribute('colspan') ?? '') ?? 1, + rowspan: int.tryParse(cell.getAttribute('rowspan') ?? '') ?? 1, + )); + } + rows.add(MewsTableRow(cells)); + } + return TableBlock( + rows: rows, + caption: element.findElements('caption').firstOrNull?.innerText.trim(), + summary: element.getAttribute('summary'), + ); +} + +/// An image loads only from the page's own registered domain, never as a +/// data: URI (spec 4.2). Anything else shows its alt text instead. +ImageBlock _imageBlock(XmlElement element, String pageUrl) { + final src = element.getAttribute('src') ?? ''; + final alt = element.getAttribute('alt') ?? ''; + final resolved = Uri.tryParse(pageUrl)?.resolve(src); + final allowed = resolved != null && + !resolved.scheme.startsWith('data') && + _sameSite(resolved, Uri.parse(pageUrl)); + return ImageBlock(src: resolved?.toString() ?? src, alt: alt, allowed: allowed); +} + +/// Rough registrable-domain match: the last two labels. Fine for the small +/// web; a public-suffix list is out of scope for v1. +bool _sameSite(Uri image, Uri page) { + String? domain(Uri uri) { + final host = uri.host; + if (host.isEmpty) return null; + final labels = host.split('.'); + return labels.length <= 2 ? host : labels.sublist(labels.length - 2).join('.'); + } + + final imageDomain = domain(image); + return imageDomain != null && imageDomain == domain(page); +} + +FormBlock _formBlock(XmlElement element, String pageUrl) { + final labels = {}; + final fields = []; + + for (final child in element.descendants.whereType()) { + switch (child.name.qualified) { + case 'label': + final text = child.innerText.trim(); + final forId = child.getAttribute('for'); + final controlId = child.children + .whereType() + .map((e) => e.getAttribute('id')) + .firstOrNull; + if (forId != null) { + labels[forId] = text; + } else if (controlId != null) { + labels[controlId] = text; + } + case 'input': + fields.add(_inputField(child, labels[child.getAttribute('id')])); + case 'select': + final options = []; + for (final option in child.descendants.whereType().where( + (e) => e.name.qualified == 'option')) { + options.add(MewsSelectOption( + value: option.getAttribute('value') ?? option.innerText.trim(), + selected: option.getAttribute('selected') != null, + )); + } + fields.add(MewsSelectField( + name: child.getAttribute('name') ?? '', + options: options, + label: labels[child.getAttribute('id')], + )); + case 'textarea': + fields.add(MewsTextAreaField( + name: child.getAttribute('name') ?? '', + value: child.innerText, + rows: int.tryParse(child.getAttribute('rows') ?? '') ?? 3, + label: labels[child.getAttribute('id')], + )); + } + } + + String? submitLabel; + for (final field in fields.whereType()) { + submitLabel ??= field.label; + } + final action = element.getAttribute('action') ?? pageUrl; + return FormBlock( + action: Uri.parse(pageUrl).resolve(action).toString(), + method: (element.getAttribute('method') ?? 'get').toLowerCase(), + fields: fields, + submitLabel: submitLabel, + ); +} + +MewsFormField _inputField(XmlElement element, String? label) { + final name = element.getAttribute('name') ?? ''; + final value = element.getAttribute('value') ?? ''; + final checked = element.getAttribute('checked') != null; + switch (element.getAttribute('type') ?? 'text') { + case 'password': + return MewsTextField(name: name, password: true, value: value, label: label); + case 'hidden': + return MewsHiddenField(name: name, value: value); + case 'checkbox': + return MewsCheckboxField(name: name, checked: checked, value: value.isEmpty ? 'on' : value, label: label); + case 'radio': + return MewsRadioField(name: name, checked: checked, value: value, label: label); + case 'submit': + return MewsSubmitField(reset: false, label: value.isEmpty ? null : value, name: name); + case 'reset': + return MewsSubmitField(reset: true, label: value.isEmpty ? null : value, name: name); + default: + return MewsTextField( + name: name, + password: false, + value: value, + maxLength: int.tryParse(element.getAttribute('maxlength') ?? ''), + inputMode: element.getAttribute('inputmode'), + label: label, + ); + } +} + +// Inline --------------------------------------------------------------------- + +List _inline(XmlElement element, String pageUrl, [List? embedded]) { + final out = []; + for (final child in element.children) { + _appendInline(child, pageUrl, out, embedded); + } + return out; +} + +void _appendInline(XmlNode node, String pageUrl, List out, + [List? embedded]) { + if (node is XmlText) { + final text = node.value; + if (text.isNotEmpty) out.add(MewsText(text)); + return; + } + if (node is! XmlElement) return; + + switch (node.name.qualified) { + case 'a': + final href = node.getAttribute('href') ?? ''; + out.add(MewsLink(Uri.parse(pageUrl).resolve(href).toString(), _inline(node, pageUrl, embedded))); + case 'br': + out.add(const MewsLineBreak()); + case 'em': + case 'i': + case 'cite': + case 'abbr': + out.add(MewsEmphasis(EmphasisStyle.emphasis, _inline(node, pageUrl, embedded))); + case 'strong': + case 'b': + out.add(MewsEmphasis(EmphasisStyle.strong, _inline(node, pageUrl, embedded))); + case 'code': + out.add(MewsEmphasis(EmphasisStyle.code, _inline(node, pageUrl, embedded))); + case 'kbd': + out.add(MewsEmphasis(EmphasisStyle.sample, _inline(node, pageUrl, embedded))); + case 'samp': + out.add(MewsEmphasis(EmphasisStyle.sample, _inline(node, pageUrl, embedded))); + case 'var': + out.add(MewsEmphasis(EmphasisStyle.variable, _inline(node, pageUrl, embedded))); + case 'dfn': + out.add(MewsEmphasis(EmphasisStyle.term, _inline(node, pageUrl, embedded))); + case 'q': + out.add(MewsEmphasis(EmphasisStyle.quote, _inline(node, pageUrl, embedded))); + case 'img': + // Every image is shown in its own block (spec 4.2), so an inline image + // is lifted out of the paragraph that contains it. + embedded?.add(_imageBlock(node, pageUrl)); + default: + if (!_droppedElements.contains(node.name.qualified)) { + for (final child in node.children) { + _appendInline(child, pageUrl, out, embedded); + } + } + } +} + +/// Recovers readable text from a page that failed XML parsing, for the "show +/// its text" escape hatch (spec 8.1). Best effort by design. +String mewsPlainText(String source) { + final withoutBlocks = source.replaceAll(RegExp(r'<(script|style)[\s\S]*?', caseSensitive: false), ''); + final stripped = withoutBlocks.replaceAll(RegExp(r'<[^>]+>'), ' '); + return stripped + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart new file mode 100644 index 0000000..f9ff387 --- /dev/null +++ b/app/lib/presentation/app.dart @@ -0,0 +1,100 @@ +import 'package:actors/actors.dart'; +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart' show SignalBuilder; + +import '../application/actors/fetch_actor.dart'; +import '../application/controllers/browser_controller.dart'; +import '../application/controllers/reader_settings_controller.dart'; +import '../database/database.dart'; +import '../l10n/strings.dart'; +import 'reader/reader_screen.dart'; + +/// Wires the app's dependencies together and provides them to the tree. +/// Everything is local-first: the database is created up front and the fetch +/// actor is the only path to the network. +class AppDependencies { + AppDependencies({MewsDatabase? database}) + : db = database ?? MewsDatabase(), + fetch = Actor.create(FetchHandler.new) { + browser = BrowserController(db: db, fetch: fetch); + settings = ReaderSettingsController(db); + library = LibraryController(db); + } + + final MewsDatabase db; + final Actor fetch; + late final BrowserController browser; + late final ReaderSettingsController settings; + late final LibraryController library; + + Future start() async { + await settings.load(); + await library.load(); + await browser.restoreLastSession(); + } +} + +class AppScope extends InheritedWidget { + const AppScope({ + required this.deps, + required this.strings, + required super.child, + super.key, + }); + + final AppDependencies deps; + final Strings strings; + + static AppScope of(BuildContext context) => + context.getInheritedWidgetOfExactType()!; + + @override + bool updateShouldNotify(AppScope oldWidget) => false; +} + +class MewsApp extends StatelessWidget { + const MewsApp({required this.deps, required this.strings, super.key}); + + final AppDependencies deps; + final Strings strings; + + @override + Widget build(BuildContext context) { + return AppScope( + deps: deps, + strings: strings, + child: const _ThemedApp(), + ); + } +} + +class _ThemedApp extends StatelessWidget { + const _ThemedApp(); + + @override + Widget build(BuildContext context) { + final deps = AppScope.of(context).deps; + return SignalBuilder(builder: (context) { + final mode = switch (deps.settings.themeMode.value) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + return MaterialApp( + title: 'Mews', + themeMode: mode, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff1a4f9c)), + ), + darkTheme: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xff1a4f9c), brightness: Brightness.dark), + ), + home: const ReaderScreen(), + ); + }); + } +} diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart new file mode 100644 index 0000000..04c27cc --- /dev/null +++ b/app/lib/presentation/reader/address_bar.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart' show SignalBuilder; + +import '../app.dart'; + +/// The single-field address bar at the top of the reader. Submitting navigates; +/// the field shows the current page's URL whenever the reader is not typing. +class AddressBar extends StatefulWidget { + const AddressBar({super.key}); + + @override + State createState() => _AddressBarState(); +} + +class _AddressBarState extends State { + final _controller = TextEditingController(); + final _focus = FocusNode(); + bool _focused = false; + + @override + void initState() { + super.initState(); + _focus.addListener(() { + if (_focus.hasFocus != _focused) { + setState(() => _focused = _focus.hasFocus); + if (_focused) { + _controller.selection = + TextSelection(baseOffset: 0, extentOffset: _controller.text.length); + } + } + }); + } + + @override + void dispose() { + _focus.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final deps = AppScope.of(context).deps; + final strings = AppScope.of(context).strings; + + return SignalBuilder(builder: (context) { + final url = deps.browser.currentPage.value?.url; + if (url != null && !_focused) { + _controller.text = url; + } + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: TextField( + controller: _controller, + focusNode: _focus, + autofocus: false, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (value) { + _focus.unfocus(); + deps.browser.open(value); + }, + decoration: InputDecoration( + hintText: strings.t('reader-address-placeholder'), + labelText: strings.t('reader-address-label'), + prefixIcon: IconButton( + icon: const Icon(Icons.refresh), + tooltip: strings.t('reader-reload'), + onPressed: () => deps.browser.reload(), + ), + isDense: true, + border: const OutlineInputBorder(), + ), + ), + ); + }); + } +} diff --git a/app/lib/presentation/reader/fob.dart b/app/lib/presentation/reader/fob.dart new file mode 100644 index 0000000..4f18f9c --- /dev/null +++ b/app/lib/presentation/reader/fob.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// The floating control that appears when the reader scrolls, opening the +/// reader settings bottom sheet. +class Fob extends StatelessWidget { + const Fob({required this.visible, required this.label, required this.onTap, super.key}); + + final bool visible; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: IgnorePointer( + ignoring: !visible, + child: Padding( + padding: const EdgeInsets.all(16), + child: Semantics( + label: label, + button: true, + child: FloatingActionButton( + onPressed: onTap, + child: const Icon(Icons.tune), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart new file mode 100644 index 0000000..6bb2f3d --- /dev/null +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -0,0 +1,482 @@ +import 'dart:ui' as ui show TextDirection; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../l10n/strings.dart'; +import '../../mews/document.dart'; +import 'palettes.dart'; + +/// Renders a parsed [MewsPage] with the client's own presentation, following +/// the measurements of the default stylesheet mews-0.1.css: a 40em text +/// column, 1.5 line height, and the block margins authors expect. +class MewsPageView extends StatefulWidget { + const MewsPageView({ + required this.page, + required this.palette, + required this.fontFamily, + required this.fontScale, + required this.visitedUrls, + required this.onLinkTap, + required this.onFormSubmit, + this.scrollController, + super.key, + }); + + final MewsPage page; + final MewsPalette palette; + final String fontFamily; + final double fontScale; + final Set visitedUrls; + final void Function(String url) onLinkTap; + final void Function(FormBlock form, Map values) onFormSubmit; + final ScrollController? scrollController; + + @override + State createState() => _MewsPageViewState(); +} + +class _MewsPageViewState extends State { + final _recognizers = {}; + + @override + void didUpdateWidget(MewsPageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.page != widget.page) { + _disposeRecognizers(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers.values) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + @override + Widget build(BuildContext context) { + final strings = StringsScope.of(context); + final base = 17.0 * widget.fontScale; // body font-size: 106% + final palette = widget.palette; + + final blocks = [ + if (!widget.page.isMews) + _notice(strings.t('error-non-mews'), base, palette), + for (final block in widget.page.body) _block(context, block, base), + ]; + + return Directionality( + textDirection: widget.page.direction == TextDirection.rtl + ? ui.TextDirection.rtl + : ui.TextDirection.ltr, + child: ColoredBox( + color: palette.background, + child: ListView( + controller: widget.scrollController, + padding: EdgeInsets.all(base), + children: [ + Center( + child: ConstrainedBox( + // max-width: 40em + constraints: BoxConstraints(maxWidth: 40 * base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: blocks, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _notice(String text, double base, MewsPalette palette) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Text( + text, + style: TextStyle(color: palette.foreground.withValues(alpha: 0.7), fontSize: base * 0.85), + ), + ); + + // Blocks ------------------------------------------------------------------- + + Widget _block(BuildContext context, MewsBlock block, double base) { + final palette = widget.palette; + final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5); + + return switch (block) { + HeadingBlock(:final level, :final inline) => Padding( + padding: EdgeInsets.only( + top: level == 1 ? base * 0.5 : base * 1.6, + bottom: base * 0.5, + ), + child: Text.rich( + TextSpan(children: _inline(inline)), + style: foreground.copyWith( + fontWeight: FontWeight.bold, + height: 1.25, + fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1], + ), + ), + ), + ParagraphBlock(:final inline) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Text.rich(TextSpan(children: _inline(inline)), style: foreground), + ), + AddressBlock(:final inline) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Text.rich( + TextSpan(children: _inline(inline)), + style: foreground.copyWith(fontStyle: FontStyle.italic), + ), + ), + QuoteBlock(:final children) => Container( + margin: EdgeInsets.only(bottom: base), + decoration: BoxDecoration( + border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)), + ), + padding: EdgeInsetsDirectional.only(start: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [for (final child in children) _block(context, child, base)], + ), + ), + PreBlock(:final text) => Container( + margin: EdgeInsets.only(bottom: base), + color: palette.preBackground, + padding: EdgeInsets.all(base * 0.75), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + text, + style: foreground.copyWith( + fontFamily: 'monospace', + fontSize: base * 0.9, + ), + ), + ), + ), + RuleBlock() => Padding( + padding: EdgeInsets.symmetric(vertical: base * 2), + child: Divider(color: palette.border, height: 1), + ), + ListBlock(:final ordered, :final start, :final items) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < items.length; i++) + Padding( + padding: EdgeInsets.only(bottom: base * 0.25), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: base * 1.5, + child: Text( + ordered ? '${start + i}.' : '\u2022', + style: foreground, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in items[i].blocks) _block(context, child, base), + ], + ), + ), + ], + ), + ), + ], + ), + ), + DefinitionListBlock(:final items) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in items) ...[ + Text.rich( + TextSpan(children: _inline(item.term)), + style: foreground.copyWith(fontWeight: FontWeight.bold), + ), + Padding( + padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5), + child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground), + ), + ], + ], + ), + ), + TableBlock(:final rows, :final caption) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (caption != null) + Text(caption, + style: foreground.copyWith(fontWeight: FontWeight.bold)), + Table( + border: TableBorder.all(color: palette.border), + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: [ + for (final row in rows) + TableRow( + children: [ + for (final cell in row.cells) + TableCell( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: base * 0.6, + vertical: base * 0.3, + ), + child: Text.rich( + TextSpan(children: _inline(cell.inline)), + style: foreground.copyWith( + fontWeight: cell.header ? FontWeight.bold : null, + ), + ), + ), + ), + ], + ), + ], + ), + ], + ), + ), + ImageBlock(:final src, :final alt, :final allowed) => Padding( + padding: EdgeInsets.only(bottom: base), + child: allowed + ? Image.network( + src, + fit: BoxFit.scaleDown, + errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), + ) + : _imageFallback(alt, base), + ), + FormBlock() => Padding( + padding: EdgeInsets.only(bottom: base), + child: _MewsForm( + form: block, + foreground: foreground, + onFormSubmit: widget.onFormSubmit, + ), + ), + }; + } + + Widget _imageFallback(String alt, double base, {bool unavailable = false}) { + final palette = widget.palette; + final strings = StringsScope.of(context); + final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked'; + final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt'; + return Container( + width: double.infinity, + padding: EdgeInsets.all(base * 0.75), + decoration: BoxDecoration( + border: Border.all(color: palette.border), + color: palette.preBackground, + ), + child: Text( + message, + style: TextStyle(color: palette.foreground, fontSize: base * 0.9), + ), + ); + } + + /// Builds the span tree. [linkRecognizer] carries the enclosing link's + /// recognizer down to every glyph-owning span: hit-testing resolves to leaf + /// spans only, so a recognizer on a parent span alone never fires. + List _inline(List inline, [TapGestureRecognizer? linkRecognizer]) { + final palette = widget.palette; + return [ + for (final node in inline) + switch (node) { + MewsText(:final text) => TextSpan(text: text, recognizer: linkRecognizer), + MewsLineBreak() => const TextSpan(text: '\n'), + MewsEmphasis(:final style, :final children) => TextSpan( + style: switch (style) { + EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold), + EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic), + EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic), + EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic), + _ => const TextStyle(fontFamily: 'monospace'), + }, + children: _inline(children, linkRecognizer)), + MewsLink(:final href, :final children) => TextSpan( + style: TextStyle( + color: widget.visitedUrls.contains(href) ? palette.visited : palette.link, + decoration: TextDecoration.underline, + ), + recognizer: _recognizer(node), + children: _inline(children, _recognizer(node))), + }, + ]; + } + + TapGestureRecognizer _recognizer(MewsLink link) { + return _recognizers.putIfAbsent(link, () { + return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href); + }); + } +} + +/// A form rendered from the page model. Submitting sends the field values to +/// the browser controller; reset restores the author's initial values. +class _MewsForm extends StatefulWidget { + const _MewsForm({ + required this.form, + required this.foreground, + required this.onFormSubmit, + }); + + final FormBlock form; + final TextStyle foreground; + final void Function(FormBlock form, Map values) onFormSubmit; + + @override + State<_MewsForm> createState() => _MewsFormState(); +} + +class _MewsFormState extends State<_MewsForm> { + final _controllers = {}; + final _values = {}; + + @override + void dispose() { + for (final controller in _controllers.values) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final strings = StringsScope.of(context); + final form = widget.form; + final style = widget.foreground; + + return Form( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final field in form.fields) _field(field, style), + for (final field in form.fields.whereType()) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton( + onPressed: () => field.reset ? _reset(form) : _submit(form), + child: Text( + field.label ?? + (field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')), + ), + ), + ), + ], + ), + ); + } + + Widget _field(MewsFormField field, TextStyle style) { + Widget control = switch (field) { + MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField( + controller: _controllers.putIfAbsent( + name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))), + obscureText: password, + maxLength: maxLength, + style: style, + decoration: InputDecoration(labelText: field.label, isDense: true), + ), + MewsTextAreaField(:final name, :final value, :final rows) => TextFormField( + controller: _controllers.putIfAbsent( + name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))), + minLines: rows, + maxLines: rows, + style: style, + decoration: InputDecoration(labelText: field.label, isDense: true), + ), + MewsCheckboxField(:final name, :final checked) => CheckboxListTile( + title: field.label == null ? null : Text(field.label!, style: style), + value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on', + onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''), + ), + MewsRadioField(:final name, :final value, :final checked) => RadioGroup( + groupValue: _values.putIfAbsent(name, () => checked ? value : ''), + onChanged: (value) => setState(() => _values[name] = value ?? ''), + child: RadioListTile( + value: value, + title: field.label == null ? null : Text(field.label!, style: style), + ), + ), + MewsSelectField(:final name, :final options) => options.isEmpty + ? const SizedBox.shrink() + : DropdownButtonFormField( + initialValue: _values.putIfAbsent( + name, + () => options.firstWhere((o) => o.selected, orElse: () => options.first).value), + items: [ + for (final option in options) DropdownMenuItem(value: option.value, child: Text(option.value)), + ], + onChanged: (value) => _values[name] = value ?? '', + decoration: InputDecoration(labelText: field.label, isDense: true), + ), + MewsSubmitField() => const SizedBox.shrink(), + MewsHiddenField() => const SizedBox.shrink(), + }; + return control; + } + + void _submit(FormBlock form) { + final values = {}; + for (final field in form.fields) { + switch (field) { + case MewsTextField(:final name): + values[name] = _controllers[name]?.text ?? ''; + case MewsTextAreaField(:final name): + values[name] = _controllers[name]?.text ?? ''; + case MewsCheckboxField(:final name, :final value): + if (_values[name] == 'on') values[name] = value; + case MewsRadioField(:final name, :final value): + if (_values[name] == value) values[name] = value; + case MewsSelectField(:final name): + values[name] = _values[name] ?? ''; + case MewsHiddenField(:final name, :final value): + values[name] = value; + case MewsSubmitField(): + break; + } + } + widget.onFormSubmit(form, values); + } + + void _reset(FormBlock form) { + setState(() { + _values.clear(); + for (final controller in _controllers.values) { + controller.clear(); + } + // Restore the author's initial text field values. + for (final field in form.fields) { + switch (field) { + case MewsTextField(:final name, :final value): + _controllers[name]?.text = value; + case MewsTextAreaField(:final name, :final value): + _controllers[name]?.text = value; + default: + break; + } + } + }); + } +} diff --git a/app/lib/presentation/reader/palettes.dart b/app/lib/presentation/reader/palettes.dart new file mode 100644 index 0000000..a9cc91c --- /dev/null +++ b/app/lib/presentation/reader/palettes.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import '../../mews/document.dart'; + +/// The client's own presentation, mirroring the palettes of the default +/// stylesheet mews-0.1.css (spec 5.1). Mews clients ignore author CSS and use +/// their own, so these colors are the single visual source for page content. +class MewsPalette { + const MewsPalette({ + required this.background, + required this.foreground, + required this.link, + required this.visited, + required this.border, + required this.preBackground, + }); + + final Color background; + final Color foreground; + final Color link; + final Color visited; + final Color border; + final Color preBackground; +} + +const _plainLight = MewsPalette( + background: Color(0xfffaf8f3), + foreground: Color(0xff1f1f1f), + link: Color(0xff1a4f9c), + visited: Color(0xff5b2a86), + border: Color(0xffc9c4b8), + preBackground: Color(0xffefece4), +); + +const _plainDark = MewsPalette( + background: Color(0xff18191b), + foreground: Color(0xffe3e1dc), + link: Color(0xff8fb8f5), + visited: Color(0xffc9a3f2), + border: Color(0xff45464a), + preBackground: Color(0xff222326), +); + +const _warmLight = MewsPalette( + background: Color(0xfffbf4e8), + foreground: Color(0xff2a2420), + link: Color(0xff8a3d14), + visited: Color(0xff5b2a86), + border: Color(0xffc9c4b8), + preBackground: Color(0xffefece4), +); + +const _warmDark = MewsPalette( + background: Color(0xff1d1916), + foreground: Color(0xffe8dfd3), + link: Color(0xfff0b48a), + visited: Color(0xffc9a3f2), + border: Color(0xff45464a), + preBackground: Color(0xff222326), +); + +const _coolLight = MewsPalette( + background: Color(0xfff3f6fa), + foreground: Color(0xff1c2430), + link: Color(0xff1a4f9c), + visited: Color(0xff5b2a86), + border: Color(0xffc9c4b8), + preBackground: Color(0xffefece4), +); + +const _coolDark = MewsPalette( + background: Color(0xff161a20), + foreground: Color(0xffdde4ee), + link: Color(0xff8fb8f5), + visited: Color(0xffc9a3f2), + border: Color(0xff45464a), + preBackground: Color(0xff222326), +); + +const _greenLight = MewsPalette( + background: Color(0xfff2f6ef), + foreground: Color(0xff1f2a1f), + link: Color(0xff24502a), + visited: Color(0xff5b2a86), + border: Color(0xffc9c4b8), + preBackground: Color(0xffefece4), +); + +const _greenDark = MewsPalette( + background: Color(0xff161b16), + foreground: Color(0xffdbe6d8), + link: Color(0xff9fd39a), + visited: Color(0xffc9a3f2), + border: Color(0xff45464a), + preBackground: Color(0xff222326), +); + +const _monoLight = MewsPalette( + background: Color(0xffe8ebe1), + foreground: Color(0xff1e2219), + link: Color(0xff1e2219), + visited: Color(0xff5b2a86), + border: Color(0xffc9c4b8), + preBackground: Color(0xffefece4), +); + +const _monoDark = MewsPalette( + background: Color(0xff1b1d18), + foreground: Color(0xffc8d0b8), + link: Color(0xffc8d0b8), + visited: Color(0xffc9a3f2), + border: Color(0xff45464a), + preBackground: Color(0xff222326), +); + +/// Resolves the palette: the reader's variant choice overrides the page's own +/// variant unless the choice is "site" (spec 5.3, 8.2). +MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) { + final variant = readerVariant == 'site' ? pageVariant.name : pageVariantFromName(readerVariant); + return switch ((variant, dark)) { + (MewsVariant.warm, false) => _warmLight, + (MewsVariant.warm, true) => _warmDark, + (MewsVariant.cool, false) => _coolLight, + (MewsVariant.cool, true) => _coolDark, + (MewsVariant.green, false) => _greenLight, + (MewsVariant.green, true) => _greenDark, + (MewsVariant.mono, false) => _monoLight, + (MewsVariant.mono, true) => _monoDark, + (_, false) => _plainLight, + (_, true) => _plainDark, + }; +} + +MewsVariant pageVariantFromName(String name) => switch (name) { + 'warm' => MewsVariant.warm, + 'cool' => MewsVariant.cool, + 'green' => MewsVariant.green, + 'mono' => MewsVariant.mono, + _ => MewsVariant.plain, + }; diff --git a/app/lib/presentation/reader/reader_fonts.dart b/app/lib/presentation/reader/reader_fonts.dart new file mode 100644 index 0000000..98bd407 --- /dev/null +++ b/app/lib/presentation/reader/reader_fonts.dart @@ -0,0 +1,15 @@ +/// The bundled typefaces the reader can choose from (spec 8.2). Adding a font +/// is one TTF in assets/fonts, one entry in pubspec.yaml and one entry here. +class ReaderFont { + const ReaderFont(this.family, this.labelId); + + /// The pubspec font family name. + final String family; + + /// Message ID of its display name in locales/en-US/main.ftl. + final String labelId; +} + +const readerFonts = [ + ReaderFont('Atkinson Hyperlegible', 'settings-font-atkinson'), +]; diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart new file mode 100644 index 0000000..6599438 --- /dev/null +++ b/app/lib/presentation/reader/reader_screen.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:signals/signals_flutter.dart' show SignalBuilder; + +import '../../application/controllers/browser_controller.dart' + show BrokenPage, LoadFailed; +import '../../mews/document.dart'; +import '../../mews/parser.dart' show mewsPlainText; +import '../app.dart'; +import 'address_bar.dart'; +import 'fob.dart'; +import 'mews_page_view.dart'; +import 'palettes.dart'; +import 'settings_sheet.dart'; + +/// The whole v1 UI: an address bar that hides on scroll, the page reader with +/// swipe back/forward, a settings FOB that appears on scroll, and error +/// states that never guess. +class ReaderScreen extends StatefulWidget { + const ReaderScreen({super.key}); + + @override + State createState() => _ReaderScreenState(); +} + +class _ReaderScreenState extends State { + final _scroll = ScrollController(); + bool _barVisible = true; + bool _fobVisible = false; + bool _showBrokenText = false; + final _reportedNewPosts = {}; + final _checkedSubscriptions = {}; + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scroll.hasClients) return; + final position = _scroll.position; + + // The bar hides when the reader scrolls down and comes back on any + // upward scroll; it is always visible at the top of the page. + final wantBar = position.pixels <= 0 || + position.userScrollDirection == ScrollDirection.forward; + // The FOB appears once the page is scrolled, and stays reachable on pages + // that fit without scrolling. + final contentScrolls = position.maxScrollExtent > 0; + final wantFob = contentScrolls ? position.pixels > 16 : true; + + if (wantBar != _barVisible || wantFob != _fobVisible) { + setState(() { + _barVisible = wantBar; + _fobVisible = wantFob; + }); + } + } + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + final deps = scope.deps; + final strings = scope.strings; + + return Scaffold( + body: Column( + children: [ + AnimatedSlide( + offset: _barVisible ? Offset.zero : const Offset(0, -1), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _barVisible + ? const AddressBar() + : const SizedBox(width: double.infinity), + ), + ), + Expanded( + child: SignalBuilder(builder: (context) { + final browser = deps.browser; + final page = browser.currentPage.value; + + return GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + // Swipe right goes back, swipe left goes forward. + if (velocity > 300) { + browser.back(); + } else if (velocity < -300) { + browser.forward(); + } + }, + child: Stack( + children: [ + Positioned.fill(child: _body(context, page)), + Positioned( + right: 0, + bottom: 0, + child: Fob( + visible: _fobVisible, + label: strings.t('reader-fob-label'), + onTap: () => showReaderSettingsSheet(context), + ), + ), + if (browser.loading.value) + const Positioned( + top: 8, + left: 0, + right: 0, + child: LinearProgressIndicator(minHeight: 2), + ), + ], + ), + ); + }), + ), + ], + ), + ); + } + + Widget _body(BuildContext context, MewsPage? page) { + final deps = AppScope.of(context).deps; + + if (page == null) { + return SignalBuilder(builder: (context) { + final error = deps.browser.error.value; + return switch (error) { + BrokenPage(:final source) => _errorPage(context, source), + LoadFailed() => _errorPage(context, null), + null => _emptyPage(context), + }; + }); + } + + _checkSubscription(context, page); + _showBrokenText = false; + + return SignalBuilder(builder: (context) { + final settings = deps.settings; + final font = settings.font.value; + final fontScale = settings.fontScale.value; + final variant = settings.variant.value; + final visited = deps.browser.visitedUrls.value; + _maybeReportNewPosts(context, page); + + final palette = paletteFor( + pageVariant: page.variant, + readerVariant: variant, + dark: Theme.of(context).brightness == Brightness.dark, + ); + return MewsPageView( + key: ValueKey(page.url), + page: page, + palette: palette, + fontFamily: font, + fontScale: fontScale, + visitedUrls: visited, + onLinkTap: (url) => deps.browser.open(url), + onFormSubmit: (form, values) => + deps.browser.submitForm(form.action, form.method, values), + scrollController: _scroll, + ); + }); + } + + Widget _emptyPage(BuildContext context) { + // No page yet: the address bar is the whole UI. + return const SizedBox.expand(); + } + + Widget _errorPage(BuildContext context, String? brokenSource) { + final strings = AppScope.of(context).strings; + final deps = AppScope.of(context).deps; + + Widget content; + if (brokenSource == null) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(strings.t('error-load-failed'), textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => deps.browser.reload(), + child: Text(strings.t('error-try-again')), + ), + ], + ); + } else if (_showBrokenText) { + content = Column( + children: [ + Text(strings.t('error-broken-page-text-title'), + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Expanded(child: SingleChildScrollView(child: Text(mewsPlainText(brokenSource)))), + ], + ); + } else { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(strings.t('error-broken-page'), textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => setState(() => _showBrokenText = true), + child: Text(strings.t('error-broken-page-show-text')), + ), + ], + ); + } + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: content, + ), + ); + } + + /// Revalidates a subscribed page once per page load so new entries surface + /// (spec 6.1). The library controller no-ops for unsubscribed pages. + void _checkSubscription(BuildContext context, MewsPage page) { + if (_checkedSubscriptions.contains(page.url)) return; + _checkedSubscriptions.add(page.url); + final deps = AppScope.of(context).deps; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) deps.library.refreshSubscription(page); + }); + } + + /// Shows the new-posts snackbar once per site after the refresh reports + /// entries the reader has not seen. + void _maybeReportNewPosts(BuildContext context, MewsPage page) { + final scope = AppScope.of(context); + final fresh = scope.deps.library.newEntries.value[page.url]; + if (fresh == null || fresh.isEmpty || _reportedNewPosts.contains(page.url)) return; + _reportedNewPosts.add(page.url); + + final strings = scope.strings; + final message = fresh.length == 1 + ? strings.t('new-posts-one') + : strings.t('new-posts-many', {'count': fresh.length}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + }); + } +} diff --git a/app/lib/presentation/reader/settings_sheet.dart b/app/lib/presentation/reader/settings_sheet.dart new file mode 100644 index 0000000..6e93380 --- /dev/null +++ b/app/lib/presentation/reader/settings_sheet.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart' show SignalBuilder; + +import '../../application/controllers/reader_settings_controller.dart'; +import '../app.dart'; +import 'reader_fonts.dart'; + +/// Opens the reader settings bottom sheet: fonts, text size, theme, colors, +/// bookmark and subscription for the current page. +void showReaderSettingsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const SettingsSheet(), + ); +} + +class SettingsSheet extends StatelessWidget { + const SettingsSheet({super.key}); + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + final deps = scope.deps; + final strings = scope.strings; + + return SignalBuilder(builder: (context) { + final settings = deps.settings; + final library = deps.library; + final page = deps.browser.currentPage.value; + final url = page?.url; + + final percent = (settings.fontScale.value * 100).round(); + + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(0, 12, 0, 16), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + strings.t('settings-sheet-title'), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + _heading(context, strings.t('settings-font-heading')), + RadioGroup( + groupValue: settings.font.value, + onChanged: (value) => settings.setFont(value!), + child: Column( + children: [ + for (final font in readerFonts) + RadioListTile( + value: font.family, + title: Text(strings.t(font.labelId)), + ), + ], + ), + ), + _heading(context, strings.t('settings-size-heading')), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: strings.t('settings-size-decrease'), + onPressed: settings.fontScale.value > ReaderSettingsController.minScale + ? settings.decreaseSize + : null, + icon: const Icon(Icons.remove), + ), + SizedBox( + width: 56, + child: Text( + '$percent%', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + tooltip: strings.t('settings-size-increase'), + onPressed: settings.fontScale.value < ReaderSettingsController.maxScale + ? settings.increaseSize + : null, + icon: const Icon(Icons.add), + ), + ], + ), + _heading(context, strings.t('settings-theme-heading')), + RadioGroup( + groupValue: settings.themeMode.value, + onChanged: (value) => settings.setThemeMode(value!), + child: Column( + children: [ + for (final (mode, id) in [ + ('light', 'settings-theme-light'), + ('dark', 'settings-theme-dark'), + ('system', 'settings-theme-system'), + ]) + RadioListTile( + value: mode, + title: Text(strings.t(id)), + ), + ], + ), + ), + _heading(context, strings.t('settings-variant-heading')), + RadioGroup( + groupValue: settings.variant.value, + onChanged: (value) => settings.setVariant(value!), + child: Column( + children: [ + for (final (variant, id) in [ + ('site', 'settings-variant-site'), + ('warm', 'settings-variant-warm'), + ('cool', 'settings-variant-cool'), + ('green', 'settings-variant-green'), + ('mono', 'settings-variant-mono'), + ]) + RadioListTile( + value: variant, + title: Text(strings.t(id)), + ), + ], + ), + ), + const Divider(), + if (url != null) ...[ + ListTile( + leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border), + title: Text( + library.isBookmarked(url) + ? strings.t('settings-bookmark-remove') + : strings.t('settings-bookmark-add'), + ), + onTap: () => library.toggleBookmark(url, page?.title), + ), + ListTile( + leading: Icon(library.isSubscribed(url) + ? Icons.notifications_active + : Icons.notifications_outlined), + title: Text( + library.isSubscribed(url) + ? strings.t('settings-unsubscribe') + : strings.t('settings-subscribe'), + ), + subtitle: library.isSubscribed(url) + ? Text(strings.t('settings-subscribed-notice')) + : null, + onTap: () => library.toggleSubscription(url, page?.title, page?.entries ?? const []), + ), + ], + ], + ), + ); + }); + } + + Widget _heading(BuildContext context, String text) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + text, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ); +} diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl new file mode 100644 index 0000000..9327790 --- /dev/null +++ b/app/locales/en-US/main.ftl @@ -0,0 +1,54 @@ +## Reader chrome + +reader-address-placeholder = Enter a web address +# Accessibility label for the address field. +reader-address-label = Web address +reader-reload = Reload this page +reader-fob-label = Reading settings + +## Reader settings sheet + +settings-sheet-title = Reading settings +settings-font-heading = Typeface +settings-font-atkinson = Atkinson Hyperlegible +settings-size-heading = Text size +settings-size-decrease = Smaller text +settings-size-increase = Larger text +settings-theme-heading = Theme +settings-theme-light = Light +settings-theme-dark = Dark +settings-theme-system = Follow system +settings-variant-heading = Colors +settings-variant-site = Site +settings-variant-warm = Warm +settings-variant-cool = Cool +settings-variant-green = Green +settings-variant-mono = Mono +settings-bookmark-add = Add bookmark +settings-bookmark-remove = Remove bookmark +settings-subscribe = Subscribe to this site +settings-unsubscribe = Stop subscribing +settings-subscribed-notice = New posts from this site appear here. + +## New posts + +# Shown when a subscribed site has new posts. Format the number in code. +new-posts-one = 1 new post is available on this site. +new-posts-many = { $count } new posts are available on this site. + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Submit +form-reset-default = Reset + +## Errors + +error-load-failed = The page could not be loaded. Check the address and your connection, then try again. +error-try-again = Try again +error-broken-page = This page has a markup error, so it cannot be shown. +error-broken-page-show-text = Show page text +error-broken-page-text-title = Page text +error-non-mews = This page is not a Mews page, so it is shown plainly. +error-image-blocked = This image is on another site and is not shown. +error-image-unavailable = This image could not be loaded. diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..c5f351f --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,805 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + actors: + dependency: "direct main" + description: + name: actors + sha256: cd382bde6457d84bc176559b6fce578ebb7b3469604bfa8304802ba219d94384 + url: "https://pub.dev" + source: hosted + version: "0.11.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + drift: + dependency: "direct main" + description: + name: drift + sha256: bab55d542d63765f6250ee3e2c62e11bfa1145d4ec2ffa05353c8fb4e10b52c1 + url: "https://pub.dev" + source: hosted + version: "2.34.4" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7" + url: "https://pub.dev" + source: hosted + version: "2.34.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fluent: + dependency: "direct main" + description: + name: fluent + sha256: "314a7f36fd4bd765764100e2e3dd84837cfbe9c342edf90b89f8cb288e2a9cac" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_hooks: + dependency: transitive + description: + name: flutter_hooks + sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42" + url: "https://pub.dev" + source: hosted + version: "0.21.3+1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + 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" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + preact_signals: + dependency: transitive + description: + name: preact_signals + sha256: "18f45e7f91a4dfad8bd0138efa6b85770bb25e13278b863fdaafd28a56579b16" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + signals: + dependency: "direct main" + description: + name: signals + sha256: "3b6be91d04808b542612a525eaeb1875e50be153671e618a42f74e0607088ecd" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + signals_core: + dependency: transitive + description: + name: signals_core + sha256: "04d7ccb0265264191a4e2666050031129f47c2bbf4ac3f31f6586889a9903b62" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + signals_flutter: + dependency: transitive + description: + name: signals_flutter + sha256: f0285fd2f98d720724df879c258dc41725f260f8e5b1184655ef64ba679197ad + url: "https://pub.dev" + source: hosted + version: "7.1.0" + signals_hooks: + dependency: transitive + description: + name: signals_hooks + sha256: af6c2c4be47f06214292fedab6f6e943433c02086c956eeaffe45c4a23a57413 + url: "https://pub.dev" + source: hosted + version: "7.1.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d" + url: "https://pub.dev" + source: hosted + version: "0.44.5" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.38.4" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..ef9aefa --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,48 @@ +name: mews +description: "A reader for Mews pages: local-first, reader-controlled presentation." +publish_to: 'none' + +version: 0.1.0+1 + +environment: + sdk: ^3.11.5 + +dependencies: + flutter: + sdk: flutter + + fluent: ^2.0.4 + signals: ^7.1.0 + actors: ^0.11.0 + xml: ^7.0.1 + http: ^1.6.0 + drift: ^2.34.0 + sqlite3_flutter_libs: ^0.6.0 + path: ^1.9.0 + path_provider: ^2.1.6 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + drift_dev: ^2.34.0 + build_runner: ^2.14.1 + +flutter: + uses-material-design: true + + assets: + - locales/en-US/main.ftl + + fonts: + - family: Atkinson Hyperlegible + fonts: + - asset: assets/fonts/AtkinsonHyperlegible-Regular.ttf + - asset: assets/fonts/AtkinsonHyperlegible-Italic.ttf + style: italic + - asset: assets/fonts/AtkinsonHyperlegible-Bold.ttf + weight: 700 + - asset: assets/fonts/AtkinsonHyperlegible-BoldItalic.ttf + weight: 700 + style: italic diff --git a/app/test/controllers_test.dart b/app/test/controllers_test.dart new file mode 100644 index 0000000..8181297 --- /dev/null +++ b/app/test/controllers_test.dart @@ -0,0 +1,116 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/application/controllers/reader_settings_controller.dart'; +import 'package:mews/database/database.dart'; +import 'package:mews/mews/document.dart'; + +void main() { + late MewsDatabase db; + + setUp(() { + db = MewsDatabase.test(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + test('settings round-trip through the database', () async { + final settings = ReaderSettingsController(db); + await settings.setFont('Atkinson Hyperlegible'); + await settings.increaseSize(); + await settings.setThemeMode('dark'); + await settings.setVariant('warm'); + + final reloaded = ReaderSettingsController(db); + await reloaded.load(); + expect(reloaded.font.value, 'Atkinson Hyperlegible'); + expect(reloaded.fontScale.value, 1.125); + expect(reloaded.themeMode.value, 'dark'); + expect(reloaded.variant.value, 'warm'); + }); + + test('text size stays within its limits', () async { + final settings = ReaderSettingsController(db); + for (var i = 0; i < 30; i++) { + await settings.increaseSize(); + } + expect(settings.fontScale.value, ReaderSettingsController.maxScale); + for (var i = 0; i < 40; i++) { + await settings.decreaseSize(); + } + expect(settings.fontScale.value, ReaderSettingsController.minScale); + }); + + test('bookmarks toggle and persist', () async { + final library = LibraryController(db); + await library.load(); + await library.toggleBookmark('https://example.com/', 'Example'); + expect(library.isBookmarked('https://example.com/'), isTrue); + + final reloaded = LibraryController(db); + await reloaded.load(); + expect(reloaded.isBookmarked('https://example.com/'), isTrue); + + await reloaded.toggleBookmark('https://example.com/', 'Example'); + expect(reloaded.isBookmarked('https://example.com/'), isFalse); + }); + + test('subscription refresh reports only unseen entries', () async { + final library = LibraryController(db); + await library.load(); + + final firstPost = MewsEntry( + url: 'https://example.com/a.html', date: DateTime(2026, 9, 21), title: 'First post'); + await library.toggleSubscription( + 'https://example.com/', 'Example', [firstPost]); + expect(library.isSubscribed('https://example.com/'), isTrue); + + // Nothing new yet. + await library.refreshSubscription(_page('https://example.com/', [firstPost])); + expect(library.newEntries.value['https://example.com/'], isNull); + + // A new post appears. + final newPost = MewsEntry( + url: 'https://example.com/b.html', date: DateTime(2026, 10, 8), title: 'Second post'); + await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost])); + final fresh = library.newEntries.value['https://example.com/']!; + expect(fresh, hasLength(1)); + expect(fresh.single.url, 'https://example.com/b.html'); + + // The refresh stored it; a repeat refresh reports nothing. + await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost])); + expect(library.newEntries.value['https://example.com/'], isNull); + }); + + test('caches pages for offline reopening', () async { + await db.saveCachedPage(CachedPagesCompanion.insert( + url: 'https://example.com/page.html', + source: 'html', + title: const Value('Page'), + etag: const Value('"v1"'), + )); + final cached = await db.cachedPage('https://example.com/page.html'); + expect(cached!.source, 'html'); + expect(cached.etag, '"v1"'); + expect(cached.title, 'Page'); + }); + + test('records history and returns the last visited page', () async { + await db.recordVisit('https://a.example/', 'A'); + await db.recordVisit('https://b.example/', 'B'); + expect(await db.lastVisitedUrl(), 'https://b.example/'); + }); +} + +MewsPage _page(String url, List entries) => MewsPage( + url: url, + body: const [], + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + entries: entries, + specVersion: '0.1', + ); diff --git a/app/test/parser_test.dart b/app/test/parser_test.dart new file mode 100644 index 0000000..c4a32e4 --- /dev/null +++ b/app/test/parser_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/mews/document.dart'; +import 'package:mews/mews/parser.dart'; + +const conformingPage = ''' + + + + + Signal Bars + + + + + + +

Signal Bars

+

Notes on old phones and the about page.

+ + + +'''; + +void main() { + test('parses a conforming Mews page', () { + final page = parseMewsPage(source: conformingPage, url: 'https://example.com/index.html'); + + expect(page.isMews, isTrue); + expect(page.specVersion, '0.1'); + expect(page.title, 'Signal Bars'); + expect(page.description, 'Notes on old phones.'); + expect(page.variant, MewsVariant.green); + expect(page.lang, 'en'); + + expect(page.entries, hasLength(2)); + expect(page.entries.first.url, 'https://example.com/2026-10-08-retro-phones.html'); + expect(page.entries.first.title, 'Collecting retro phones'); + expect(page.entries.first.date, DateTime(2026, 10, 8)); + // Entries are listed newest first. + expect(page.entries.last.date, DateTime(2026, 9, 21)); + + expect(page.body, hasLength(3)); + expect(page.body[0], isA()); + final paragraph = page.body[1] as ParagraphBlock; + expect(paragraph.inline, hasLength(5)); + final link = paragraph.inline[3] as MewsLink; + expect(link.href, 'https://example.com/about.html'); + }); + + test('rejects a page that is not well-formed', () { + expect( + () => parseMewsPage( + source: 'x

oops', + url: 'https://example.com/'), + throwsA(isA()), + ); + }); + + test('treats a page without the conformance marker as non-Mews', () { + final source = ''' + + + Plain +

Just XHTML.

+ +'''; + final page = parseMewsPage(source: source, url: 'https://example.com/plain.html'); + expect(page.isMews, isFalse); + expect(page.specVersion, isNull); + expect(page.variant, MewsVariant.plain); + }); + + test('resolves relative URLs against the page', () { + final page = parseMewsPage(source: conformingPage, url: 'https://example.com/posts/index.html'); + final paragraph = page.body[1] as ParagraphBlock; + final link = paragraph.inline[3] as MewsLink; + expect(link.href, 'https://example.com/posts/about.html'); + }); + + test('allows same-site images and refuses off-site and data URIs', () { + final source = ''' + + + Pics + +

A cat and A dog

+

A bird

+

Inline

+ + +'''; + final page = parseMewsPage(source: source, url: 'https://example.com/pics.html'); + final images = page.body.whereType().toList(); + expect(images, hasLength(4)); + expect(images[0].src, 'https://example.com/cat.png'); + expect(images[0].allowed, isTrue); + expect(images[1].src, 'https://img.example.com/dog.png'); + expect(images[1].allowed, isTrue, reason: 'subdomains share the registered domain'); + expect(images[2].allowed, isFalse, reason: 'another site'); + expect(images[3].allowed, isFalse, reason: 'data URI'); + }); + + test('drops scripts and styles entirely', () { + final source = ''' + + + Spooky +

Keep this.

+ +'''; + final page = parseMewsPage(source: source, url: 'https://example.com/spooky.html'); + expect(page.body, hasLength(1)); + final paragraph = page.body[0] as ParagraphBlock; + expect((paragraph.inline[0] as MewsText).text, 'Keep this.'); + }); + + test('honors dir=rtl', () { + final source = ''' + + + Shalom +

Text.

+ +'''; + final page = parseMewsPage(source: source, url: 'https://example.com/he.html'); + expect(page.direction, TextDirection.rtl); + }); + + test('builds form fields with labels', () { + final source = ''' + + + Guestbook + +
+ + + + +
+ + +'''; + final page = parseMewsPage(source: source, url: 'https://example.com/guest.html'); + final form = page.body.single as FormBlock; + expect(form.action, 'https://example.com/sign'); + expect(form.method, 'post'); + final nameField = form.fields[0] as MewsTextField; + expect(nameField.name, 'name'); + expect(nameField.label, 'Your name'); + final hidden = form.fields[1] as MewsHiddenField; + expect(hidden.value, '1'); + final submit = form.fields[2] as MewsSubmitField; + expect(submit.label, 'Sign guestbook'); + }); + + test('mewsPlainText recovers text from broken markup', () { + const broken = 'Half

Visible & intact.

'; + final text = mewsPlainText(broken); + expect(text, contains('Visible & intact.')); + }); +} diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart new file mode 100644 index 0000000..23f52f2 --- /dev/null +++ b/app/test/reader_widget_test.dart @@ -0,0 +1,88 @@ +import 'dart:io' show File; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/database/database.dart'; +import 'package:mews/l10n/strings.dart'; +import 'package:mews/mews/document.dart'; +import 'package:mews/presentation/app.dart'; +import 'package:mews/presentation/reader/fob.dart'; +import 'package:mews/presentation/reader/mews_page_view.dart'; +import 'package:mews/presentation/reader/palettes.dart'; + +Future _app() async { + final strings = Strings(); + await strings.load(); + final deps = AppDependencies(database: MewsDatabase.test(NativeDatabase.memory())); + return MewsApp(deps: deps, strings: strings); +} + +void main() { + testWidgets('starts with the address bar and no FOB', (tester) async { + await tester.pumpWidget(await _app()); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Enter a web address'), findsOneWidget); + // The FOB only appears once the reader scrolls a page; without a page it + // stays invisible and untouchable. + final fob = tester.widget(find.byType(Fob)); + expect(fob.visible, isFalse); + }); + + testWidgets('renders a page and follows links', (tester) async { + final page = MewsPage( + url: 'https://example.com/index.html', + title: 'Test page', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: '0.1', + entries: const [], + body: const [ + HeadingBlock(1, [MewsText('A heading')]), + ParagraphBlock([MewsText('Read on for more.')]), + ParagraphBlock([ + MewsLink('https://example.com/next.html', [MewsText('the next page')]), + ]), + ], + ); + + String? opened; + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: (await _strings()), + child: MewsPageView( + page: page, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (url) => opened = url, + onFormSubmit: (_, _) {}, + ), + ), + ), + ); + + expect(find.text('A heading'), findsOneWidget); + expect(find.textContaining('the next page'), findsOneWidget); + + // The paragraph Text stretches the full column width, so tap on the + // glyphs of the link itself, not the widget's center. + final rect = tester.getRect(find.textContaining('the next page')); + await tester.tapAt(rect.topLeft + const Offset(30, 13)); + await tester.pump(); + expect(opened, 'https://example.com/next.html'); + }); +} + +Future _strings() async { + final strings = Strings(); + strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); + return strings; +} diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..dfefed2 --- /dev/null +++ b/devbox.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/main/schema/schemas/devbox.schema.json", + "packages": { + "flutter@3.41.9-sdk-links": {}, + "path:./nix-android-sdk#android-sdk": {} + }, + "shell": { + "scripts": { + "analyze": "cd app && flutter analyze", + "test": "cd app && flutter test", + "run": "cd app && flutter run" + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..667115b --- /dev/null +++ b/devbox.lock @@ -0,0 +1,57 @@ +{ + "lockfile_version": "1", + "packages": { + "flutter@3.41.9-sdk-links": { + "last_modified": "2026-05-21T08:15:18Z", + "resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#flutter", + "source": "devbox-search", + "version": "3.41.9-sdk-links", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-09-29T03:29:29Z", + "resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D" + } + } +}