From 01a36e8aee374b57921507e0003c8af8e3162090 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 09:33:25 +0300 Subject: [PATCH] feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor --- .gitignore | 1 + SPEC.md | 13 +- app/.gitignore | 45 + app/.metadata | 30 + app/README.md | 8 + app/analysis_options.yaml | 28 + app/android/.gitignore | 14 + app/android/app/build.gradle.kts | 44 + app/android/app/src/debug/AndroidManifest.xml | 7 + app/android/app/src/main/AndroidManifest.xml | 48 + .../kotlin/page/mews/mews/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + app/android/build.gradle.kts | 24 + app/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + app/android/settings.gradle.kts | 26 + .../fonts/AtkinsonHyperlegible-Bold.ttf | Bin 0 -> 55256 bytes .../fonts/AtkinsonHyperlegible-BoldItalic.ttf | Bin 0 -> 55288 bytes .../fonts/AtkinsonHyperlegible-Italic.ttf | Bin 0 -> 54892 bytes .../fonts/AtkinsonHyperlegible-Regular.ttf | Bin 0 -> 54348 bytes app/assets/fonts/OFL.txt | 92 + app/assets/logo.svg | 57 + app/lib/application/actors/fetch_actor.dart | 160 + .../controllers/browser_controller.dart | 151 + .../reader_settings_controller.dart | 135 + app/lib/database/database.dart | 157 + app/lib/database/database.g.dart | 2760 +++++++++++++++++ app/lib/l10n/strings.dart | 39 + app/lib/main.dart | 16 + app/lib/mews/document.dart | 291 ++ app/lib/mews/parser.dart | 434 +++ app/lib/presentation/app.dart | 100 + app/lib/presentation/reader/address_bar.dart | 80 + app/lib/presentation/reader/fob.dart | 34 + .../presentation/reader/mews_page_view.dart | 482 +++ app/lib/presentation/reader/palettes.dart | 140 + app/lib/presentation/reader/reader_fonts.dart | 15 + .../presentation/reader/reader_screen.dart | 261 ++ .../presentation/reader/settings_sheet.dart | 169 + app/locales/en-US/main.ftl | 54 + app/pubspec.lock | 805 +++++ app/pubspec.yaml | 48 + app/test/controllers_test.dart | 116 + app/test/parser_test.dart | 167 + app/test/reader_widget_test.dart | 88 + devbox.json | 14 + devbox.lock | 57 + 56 files changed, 7287 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/.metadata create mode 100644 app/README.md create mode 100644 app/analysis_options.yaml create mode 100644 app/android/.gitignore create mode 100644 app/android/app/build.gradle.kts create mode 100644 app/android/app/src/debug/AndroidManifest.xml create mode 100644 app/android/app/src/main/AndroidManifest.xml create mode 100644 app/android/app/src/main/kotlin/page/mews/mews/MainActivity.kt create mode 100644 app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/values-night/styles.xml create mode 100644 app/android/app/src/main/res/values/styles.xml create mode 100644 app/android/app/src/profile/AndroidManifest.xml create mode 100644 app/android/build.gradle.kts create mode 100644 app/android/gradle.properties create mode 100644 app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 app/android/settings.gradle.kts create mode 100644 app/assets/fonts/AtkinsonHyperlegible-Bold.ttf create mode 100644 app/assets/fonts/AtkinsonHyperlegible-BoldItalic.ttf create mode 100644 app/assets/fonts/AtkinsonHyperlegible-Italic.ttf create mode 100644 app/assets/fonts/AtkinsonHyperlegible-Regular.ttf create mode 100644 app/assets/fonts/OFL.txt create mode 100644 app/assets/logo.svg create mode 100644 app/lib/application/actors/fetch_actor.dart create mode 100644 app/lib/application/controllers/browser_controller.dart create mode 100644 app/lib/application/controllers/reader_settings_controller.dart create mode 100644 app/lib/database/database.dart create mode 100644 app/lib/database/database.g.dart create mode 100644 app/lib/l10n/strings.dart create mode 100644 app/lib/main.dart create mode 100644 app/lib/mews/document.dart create mode 100644 app/lib/mews/parser.dart create mode 100644 app/lib/presentation/app.dart create mode 100644 app/lib/presentation/reader/address_bar.dart create mode 100644 app/lib/presentation/reader/fob.dart create mode 100644 app/lib/presentation/reader/mews_page_view.dart create mode 100644 app/lib/presentation/reader/palettes.dart create mode 100644 app/lib/presentation/reader/reader_fonts.dart create mode 100644 app/lib/presentation/reader/reader_screen.dart create mode 100644 app/lib/presentation/reader/settings_sheet.dart create mode 100644 app/locales/en-US/main.ftl create mode 100644 app/pubspec.lock create mode 100644 app/pubspec.yaml create mode 100644 app/test/controllers_test.dart create mode 100644 app/test/parser_test.dart create mode 100644 app/test/reader_widget_test.dart create mode 100644 devbox.json create mode 100644 devbox.lock 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0y~yVDJE84rT@hh9qO>QU(SF$r9IylHmNblJdl&R0hYC z{G?O`&)mfH)S%SFl*+=BsWuD@4C?}XLR=Xb7#RJ!7{h04Zu=H;^aq4w44VZJaXaum zG@>0o`I5 z6U6gpN>(hBs@ovlyiK}omrVN}&28UY_JdsVcA=Cl0|SF_NswPKgMfg5KtMnO2sE4r zf%}pIPku2lFue41aSVw#{PxUsz9t6&hrp0GdlTMw^~h@f`fs1s!olsWbZ#C0v7PJ9 zExVjNeb(Ol`RU07XFf|X@qSK7qke0_C&kUH>MnD(8u-mi5B zt(&|xZ@%@sBNk%nt_5+E&U1QAlyT>>sMB2Zd5T5*R3&Sld5=98o%0qdm+@@-JjKB8 z(xcUiGOB?O=e)LCdt!o~wXBiQlFwyn4J{(JaZG%Q-e|yQz{EjrrIztFmwg@gt#&=FffMC4mtWmbKAF&qdyqKXG0ipk&vT5 zAmZ%d{SXF=bCKq@Z-GZZ;tV#~j83JDzFmxd-Hahq8AGNqhE8V;o5>h9i!po_WB6=l zU2kT?Kvt(bR^JA;;7+;DUGlvL6sMk6nsrHO&Q;BA-wby`jSBSdXJueu5G@Jv3uX`y zP*5;1FzBB@fBpXR=g+?ff&5MV!VC-yOFdm2Lp+YZy>e2h$w0vMVh8iNMNLhM)++IJ z{QqCg*LgRGXKpKZaOLSMJFo8MNc{figWv3mbJp+w-BoBm(|-hT|4dcbJ2lEyX!??Y`jpZ81*D{;rF6b`?;0>>pbJRKPRw2)?8wK8GH9> zL)n=k&vkA#ITl(SSsBW`??>AG{pM|AK2Jc#t6>y4N#$)H8ZEGkUc!`m{6pbTImMF#2{f z`gJk-_b~?aGX_m$44TXsJQ-wH$W+GA>5O4B7{g{VhRtFOpT!tHo5?tW$tsn}K99+{ zjLE&4*|Uz>tC88eNi3sZJbQ{n(LBkDd1t=Sn-plsE283-d*czTa-zm@iQD#kujt@|Kvt4hp|H z71QJA-@DE-vCc=}m3vdfeqD}l&W>B;IduNM%M_}RPx;yPVrk2E_T?&?d+vqXsA}xF zGk52_Ny78;-$vOoENNLjTll#WOG?YLuZ?eO;uVxk)@?h*_~6>qo(~l}_f@BaD>p=c zm>yaWRpaQm|`^KGX?!;c=*_;KycpYksb1 rdBk${xlomwC<})`ozHbP0l+XkKcl<5t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0y~yV3+{H9Lx+13>Rhybuln7NS3%plmzFem6RtIr7}3C z$Dctjh(SM?K|h4SAdJB{hQT_6!9JhSFr3jep3y9U z(L9OKDxJ|flhG!d(I$t{HiywRm(ebd(Y}z;v6#`Zgwd&#(Yb=rrHawDn$fL>(XE!z zt&Y*Xj?trm(W8;kvysuWiP5u}(X)lotC`WOh0&*-(Wisaw}a8QlhLo6(XWTmzlYJk zmocD^F|eO8Z~|lCM8?2Lj6o9_gC>F8A3T{ccnV|4RK}2LjG@yRLuW9C&0q|h$rv_^ zF?<$d_-uBwU=HgjPRC?Uw`?x2A}+rQ?!a2^kVfvX7VhwN?#NE=s4niP9-inPl?69d z7T#1{bVqf`J=LWT)RsR~Tk%+JQT9|ZQFKYxDR#LKT37?|`uT^vIyZoR#b9vmDf()v(?iz7_L)oF78 zkJJ<)r8pUt8M$!5-N#*39u$gn zR>-(G$wg0`aolam=a?PG9$oC(GH>-!X0d*^l*gJvuFJp95x7~EvFLL3EEhhxt`pq; z%_lBC*86lUSiR`uj?O)w{s`}^n&Wz}@?RH+ochG+^SU~oY!t72t=ZWjXFc({Ty9i< zoXX3Kohi2Ju9xPeP296QFr{gETuR&Xh?K77YDUL{MQ1+I?2fS6ciP3jaR24wmqlki zKfgU-wdbO}>o1>vX;$n~9ltDoY4ItEi|?MFbkSNqMRUkMU-}~Bs z)+NdNzgg`VE)XKX=tVT|2z5{L4{YJjHWS*rIm-E6Ws`IFvdRnm87H fR&jd1QhDAN-h`R^uRUjAU|{fc^>bP0l+XkKAv&s= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmeAS@N?(olHy`uVBq!ia0y~yU^oE69Lx+145>_WOc@v$BuiW)N`mv#O3D+9QW+dm z@{>{(JaZG%Q-e|yQz{EjrrIztFy0FA32|j$U7=vLLgJC#>aTJ4T9D`W`gLwjjbtZ#N4x^eAqnb0LmM5cOIHO54qe%>- zNi3sjJfm3xqeU{KMGB*38lzP@qg4i@bta=t7NczrqirsuT`r?t9;018qkTT3eF39G z5u;-~4uK+6lW`UY0|SF-NswPKgMfko7&thbzyBTz-gcI+Wnf^o^K@|x zskrs_3VX0{ph#Pyi^G})N%|2R%{L?-ZV(U%Y+lqcfu(WAio}U)uIrV(+3WUaecD+A zmQ6SJem|Gdo_Kp z-*x@+`J3Y19B#kQTkx(t_`-K<>p6dn#rh_GuokuWC9d+nXX1zFdIcZVw{qEhc&=6O z^|eUFqwV?=KCBk6c(Pr4!l%_@70=G=P581}wBp75D22D-njfy%?{qk*AOB#Z{bq-g z`q2+|)-Q6ns2@Io{eMN{rubzF`u|EA+wSjG@UL6#aQc1N1oQe;4(H#;OmN?SS>~@F ztIeOC+&g|0i+=ccTfE@YZP5>((!~ot`|Cb zb^PFdrt-VXypJEaJ95?T2$v)Ws{O#V4Ednvz`|Pb} zf4VOe!@bWom-}e`tT2YtP3!%mny=z+}-K6mV2ZEt5Icwo(g z%Zr!wX>UAuh9QhqUj7<;)5IBxDQpH>KRz6|eoM6P*rQ_`EdPlXYgN?$U-3W2|NYf9 z3~H?J-^u1!lq%j3KgpL6_~F~TxcJXA&m~w0?N0Rh)m167XXobE9?sqiHmlnth&$|i z@b&H6->;epMCgPeb()B(Tr|RH~O+1PwlVxdT!ROU2OGQ3PvhLeQAwP zO|AMWqpQ}lM;-?Ix_3Z(ZoZK*%HiZ|EUDeVf^rGH<21r@EM zSf|zZ`p5aNP1ut4@|gFs0GYW>Kcl68^QPJ_Y + + + + + + 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 0000000000000000000000000000000000000000..ca3f613847de62b8da83d2dab500ec391c8fe477 GIT binary patch literal 55256 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 Xn0h7#IR+j)Va9_qW + + + + + + + + + + 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

+
+ + + +'''; + +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" + } + } +}

Notes on old phones and the about page.