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