nandi/gleanpublic Fork 0
af65e66
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add the Flutter client's API layer

First stage of the Flutter port: the parts that have to be right before any
UI is worth writing. Dart mirrors of every wire type in internal/server/
api.go, envelopes for each response, and a typed client covering all ~50
routes in setupRoutes() -- grouped in the same order so the two can be
diffed by eye.

Auth is the one place the native client cannot just copy the web app. The
Go server uses a session cookie plus a double-submit CSRF token, and Dart's
http package has no cookie jar, so GleanSession is one: it persists cookies
across restarts, echoes glean_csrf back as X-CSRF-Token on unsafe methods,
and can adopt cookies captured from the OAuth webview.

The live tests are tagged rather than default because they hit a real
server. They also have to clear HttpOverrides.global: flutter_test installs
a mock HttpClient that answers every request with a bodiless 400, which
looks exactly like a server rejecting you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T20:01:29-07:00 Browse files
af65e66 parent: b3424a2
modified .gitignore +6 -0
@@ -40,3 +40,9 @@ todo.md
4040 # python bytecode
4141 __pycache__/
4242 *.pyc
43+
44+# flutter
45+app/.dart_tool/
46+app/build/
47+app/.flutter-plugins-dependencies
48+app/*.iml
@@ -40,3 +40,9 @@ todo.md
40 # python bytecode40 # python bytecode
41 __pycache__/41 __pycache__/
42 *.pyc42 *.pyc
43+
44+# flutter
45+app/.dart_tool/
46+app/build/
47+app/.flutter-plugins-dependencies
48+app/*.iml
added app/.gitignore +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+# Miscellaneous
2+*.class
3+*.log
4+*.pyc
5+*.swp
6+.DS_Store
7+.atom/
8+.build/
9+.buildlog/
10+.history
11+.svn/
12+.swiftpm/
13+migrate_working_dir/
14+
15+# IntelliJ related
16+*.iml
17+*.ipr
18+*.iws
19+.idea/
20+
21+# The .vscode folder contains launch configuration and tasks you configure in
22+# VS Code which you may wish to be included in version control, so this line
23+# is commented out by default.
24+#.vscode/
25+
26+# Flutter/Dart/Pub related
27+**/doc/api/
28+**/ios/Flutter/.last_build_id
29+.dart_tool/
30+.flutter-plugins-dependencies
31+.pub-cache/
32+.pub/
33+/build/
34+/coverage/
35+
36+# Symbolication related
37+app.*.symbols
38+
39+# Obfuscation related
40+app.*.map.json
41+
42+# Android Studio will place build artifacts here
43+/android/app/debug
44+/android/app/profile
45+/android/app/release
46+
47+# Widget Preview related
48+.widget_preview/
new file mode 100644
@@ -0,0 +1,48 @@
1+# Miscellaneous
2+*.class
3+*.log
4+*.pyc
5+*.swp
6+.DS_Store
7+.atom/
8+.build/
9+.buildlog/
10+.history
11+.svn/
12+.swiftpm/
13+migrate_working_dir/
14+
15+# IntelliJ related
16+*.iml
17+*.ipr
18+*.iws
19+.idea/
20+
21+# The .vscode folder contains launch configuration and tasks you configure in
22+# VS Code which you may wish to be included in version control, so this line
23+# is commented out by default.
24+#.vscode/
25+
26+# Flutter/Dart/Pub related
27+**/doc/api/
28+**/ios/Flutter/.last_build_id
29+.dart_tool/
30+.flutter-plugins-dependencies
31+.pub-cache/
32+.pub/
33+/build/
34+/coverage/
35+
36+# Symbolication related
37+app.*.symbols
38+
39+# Obfuscation related
40+app.*.map.json
41+
42+# Android Studio will place build artifacts here
43+/android/app/debug
44+/android/app/profile
45+/android/app/release
46+
47+# Widget Preview related
48+.widget_preview/
added app/.metadata +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+# This file tracks properties of this Flutter project.
2+# Used by Flutter tool to assess capabilities and perform upgrades etc.
3+#
4+# This file should be version controlled and should not be manually edited.
5+
6+version:
7+ revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
8+ channel: "stable"
9+
10+project_type: app
11+
12+# Tracks metadata for the flutter migrate command
13+migration:
14+ platforms:
15+ - platform: root
16+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
17+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
18+ - platform: android
19+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
20+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
21+ - platform: linux
22+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
23+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
24+ - platform: web
25+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
26+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
27+
28+ # User provided section
29+
30+ # List of Local paths (relative to this file) that should be
31+ # ignored by the migrate tool.
32+ #
33+ # Files that are not part of the templates will be ignored by default.
34+ unmanaged_files:
35+ - 'lib/main.dart'
36+ - 'ios/Runner.xcodeproj/project.pbxproj'
new file mode 100644
@@ -0,0 +1,36 @@
1+# This file tracks properties of this Flutter project.
2+# Used by Flutter tool to assess capabilities and perform upgrades etc.
3+#
4+# This file should be version controlled and should not be manually edited.
5+
6+version:
7+ revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
8+ channel: "stable"
9+
10+project_type: app
11+
12+# Tracks metadata for the flutter migrate command
13+migration:
14+ platforms:
15+ - platform: root
16+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
17+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
18+ - platform: android
19+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
20+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
21+ - platform: linux
22+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
23+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
24+ - platform: web
25+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
26+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
27+
28+ # User provided section
29+
30+ # List of Local paths (relative to this file) that should be
31+ # ignored by the migrate tool.
32+ #
33+ # Files that are not part of the templates will be ignored by default.
34+ unmanaged_files:
35+ - 'lib/main.dart'
36+ - 'ios/Runner.xcodeproj/project.pbxproj'
added app/README.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+# glean_app
2+
3+A new Flutter project.
4+
5+## Getting Started
6+
7+This project is a starting point for a Flutter application.
8+
9+A few resources to get you started if this is your first Flutter project:
10+
11+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
12+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
13+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
14+
15+For help getting started with Flutter development, view the
16+[online documentation](https://docs.flutter.dev/), which offers tutorials,
17+samples, guidance on mobile development, and a full API reference.
new file mode 100644
@@ -0,0 +1,17 @@
1+# glean_app
2+
3+A new Flutter project.
4+
5+## Getting Started
6+
7+This project is a starting point for a Flutter application.
8+
9+A few resources to get you started if this is your first Flutter project:
10+
11+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
12+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
13+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
14+
15+For help getting started with Flutter development, view the
16+[online documentation](https://docs.flutter.dev/), which offers tutorials,
17+samples, guidance on mobile development, and a full API reference.
added app/analysis_options.yaml +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+# This file configures the analyzer, which statically analyzes Dart code to
2+# check for errors, warnings, and lints.
3+#
4+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
5+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
6+# invoked from the command line by running `flutter analyze`.
7+
8+# The following line activates a set of recommended lints for Flutter apps,
9+# packages, and plugins designed to encourage good coding practices.
10+include: package:flutter_lints/flutter.yaml
11+
12+analyzer:
13+ exclude:
14+ - build/**
15+ - android/**
16+ - web/**
17+ - linux/**
18+
19+linter:
20+ # The lint rules applied to this project can be customized in the
21+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
22+ # included above or to enable additional rules. A list of all available lints
23+ # and their documentation is published at https://dart.dev/lints.
24+ #
25+ # Instead of disabling a lint rule for the entire project in the
26+ # section below, it can also be suppressed for a single line of code
27+ # or a specific dart file by using the `// ignore: name_of_lint` and
28+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
29+ # producing the lint.
30+ rules:
31+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
32+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
33+
34+# Additional information about this file can be found at
35+# https://dart.dev/guides/language/analysis-options
new file mode 100644
@@ -0,0 +1,35 @@
1+# This file configures the analyzer, which statically analyzes Dart code to
2+# check for errors, warnings, and lints.
3+#
4+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
5+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
6+# invoked from the command line by running `flutter analyze`.
7+
8+# The following line activates a set of recommended lints for Flutter apps,
9+# packages, and plugins designed to encourage good coding practices.
10+include: package:flutter_lints/flutter.yaml
11+
12+analyzer:
13+ exclude:
14+ - build/**
15+ - android/**
16+ - web/**
17+ - linux/**
18+
19+linter:
20+ # The lint rules applied to this project can be customized in the
21+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
22+ # included above or to enable additional rules. A list of all available lints
23+ # and their documentation is published at https://dart.dev/lints.
24+ #
25+ # Instead of disabling a lint rule for the entire project in the
26+ # section below, it can also be suppressed for a single line of code
27+ # or a specific dart file by using the `// ignore: name_of_lint` and
28+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
29+ # producing the lint.
30+ rules:
31+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
32+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
33+
34+# Additional information about this file can be found at
35+# https://dart.dev/guides/language/analysis-options
added app/android/.gitignore +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+gradle-wrapper.jar
2+/.gradle
3+/captures/
4+/gradlew
5+/gradlew.bat
6+/local.properties
7+GeneratedPluginRegistrant.java
8+.cxx/
9+
10+# Remember to never publicly share your keystore.
11+# See https://flutter.dev/to/reference-keystore
12+key.properties
13+**/*.keystore
14+**/*.jks
new file mode 100644
@@ -0,0 +1,14 @@
1+gradle-wrapper.jar
2+/.gradle
3+/captures/
4+/gradlew
5+/gradlew.bat
6+/local.properties
7+GeneratedPluginRegistrant.java
8+.cxx/
9+
10+# Remember to never publicly share your keystore.
11+# See https://flutter.dev/to/reference-keystore
12+key.properties
13+**/*.keystore
14+**/*.jks
added app/android/app/build.gradle.kts +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+plugins {
2+ id("com.android.application")
3+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
4+ id("dev.flutter.flutter-gradle-plugin")
5+}
6+
7+android {
8+ namespace = "fr.rbrt.glean.glean_app"
9+ compileSdk = flutter.compileSdkVersion
10+ ndkVersion = flutter.ndkVersion
11+
12+ compileOptions {
13+ sourceCompatibility = JavaVersion.VERSION_17
14+ targetCompatibility = JavaVersion.VERSION_17
15+ }
16+
17+ defaultConfig {
18+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
19+ applicationId = "fr.rbrt.glean.glean_app"
20+ // You can update the following values to match your application needs.
21+ // For more information, see: https://flutter.dev/to/review-gradle-config.
22+ minSdk = flutter.minSdkVersion
23+ targetSdk = flutter.targetSdkVersion
24+ // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
25+ // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
26+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
27+ // flag during build.
28+ versionCode = flutter.versionCode
29+ versionName = flutter.versionName
30+ }
31+
32+ buildTypes {
33+ release {
34+ // TODO: Add your own signing config for the release build.
35+ // Signing with the debug keys for now, so `flutter run --release` works.
36+ signingConfig = signingConfigs.getByName("debug")
37+ }
38+ }
39+}
40+
41+kotlin {
42+ compilerOptions {
43+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
44+ }
45+}
46+
47+flutter {
48+ source = "../.."
49+}
new file mode 100644
@@ -0,0 +1,49 @@
1+plugins {
2+ id("com.android.application")
3+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
4+ id("dev.flutter.flutter-gradle-plugin")
5+}
6+
7+android {
8+ namespace = "fr.rbrt.glean.glean_app"
9+ compileSdk = flutter.compileSdkVersion
10+ ndkVersion = flutter.ndkVersion
11+
12+ compileOptions {
13+ sourceCompatibility = JavaVersion.VERSION_17
14+ targetCompatibility = JavaVersion.VERSION_17
15+ }
16+
17+ defaultConfig {
18+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
19+ applicationId = "fr.rbrt.glean.glean_app"
20+ // You can update the following values to match your application needs.
21+ // For more information, see: https://flutter.dev/to/review-gradle-config.
22+ minSdk = flutter.minSdkVersion
23+ targetSdk = flutter.targetSdkVersion
24+ // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
25+ // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
26+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
27+ // flag during build.
28+ versionCode = flutter.versionCode
29+ versionName = flutter.versionName
30+ }
31+
32+ buildTypes {
33+ release {
34+ // TODO: Add your own signing config for the release build.
35+ // Signing with the debug keys for now, so `flutter run --release` works.
36+ signingConfig = signingConfigs.getByName("debug")
37+ }
38+ }
39+}
40+
41+kotlin {
42+ compilerOptions {
43+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
44+ }
45+}
46+
47+flutter {
48+ source = "../.."
49+}
added app/android/app/src/debug/AndroidManifest.xml +7 -0
new file mode 100644
@@ -0,0 +1,7 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The INTERNET permission is required for development. Specifically,
3+ the Flutter tool needs it to communicate with the running application
4+ to allow setting breakpoints, to provide hot reload, etc.
5+ -->
6+ <uses-permission android:name="android.permission.INTERNET"/>
7+</manifest>
new file mode 100644
@@ -0,0 +1,7 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The INTERNET permission is required for development. Specifically,
3+ the Flutter tool needs it to communicate with the running application
4+ to allow setting breakpoints, to provide hot reload, etc.
5+ -->
6+ <uses-permission android:name="android.permission.INTERNET"/>
7+</manifest>
added app/android/app/src/main/AndroidManifest.xml +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <application
3+ android:label="glean_app"
4+ android:name="${applicationName}"
5+ android:icon="@mipmap/ic_launcher">
6+ <activity
7+ android:name=".MainActivity"
8+ android:exported="true"
9+ android:launchMode="singleTop"
10+ android:taskAffinity=""
11+ android:theme="@style/LaunchTheme"
12+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
13+ android:hardwareAccelerated="true"
14+ android:windowSoftInputMode="adjustResize">
15+ <!-- Specifies an Android theme to apply to this Activity as soon as
16+ the Android process has started. This theme is visible to the user
17+ while the Flutter UI initializes. After that, this theme continues
18+ to determine the Window background behind the Flutter UI. -->
19+ <meta-data
20+ android:name="io.flutter.embedding.android.NormalTheme"
21+ android:resource="@style/NormalTheme"
22+ />
23+ <intent-filter>
24+ <action android:name="android.intent.action.MAIN"/>
25+ <category android:name="android.intent.category.LAUNCHER"/>
26+ </intent-filter>
27+ </activity>
28+ <!-- Don't delete the meta-data below.
29+ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
30+ <meta-data
31+ android:name="flutterEmbedding"
32+ android:value="2" />
33+ </application>
34+ <!-- Required to query activities that can process text, see:
35+ https://developer.android.com/training/package-visibility and
36+ https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
37+
38+ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
39+ <queries>
40+ <intent>
41+ <action android:name="android.intent.action.PROCESS_TEXT"/>
42+ <data android:mimeType="text/plain"/>
43+ </intent>
44+ </queries>
45+</manifest>
new file mode 100644
@@ -0,0 +1,45 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <application
3+ android:label="glean_app"
4+ android:name="${applicationName}"
5+ android:icon="@mipmap/ic_launcher">
6+ <activity
7+ android:name=".MainActivity"
8+ android:exported="true"
9+ android:launchMode="singleTop"
10+ android:taskAffinity=""
11+ android:theme="@style/LaunchTheme"
12+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
13+ android:hardwareAccelerated="true"
14+ android:windowSoftInputMode="adjustResize">
15+ <!-- Specifies an Android theme to apply to this Activity as soon as
16+ the Android process has started. This theme is visible to the user
17+ while the Flutter UI initializes. After that, this theme continues
18+ to determine the Window background behind the Flutter UI. -->
19+ <meta-data
20+ android:name="io.flutter.embedding.android.NormalTheme"
21+ android:resource="@style/NormalTheme"
22+ />
23+ <intent-filter>
24+ <action android:name="android.intent.action.MAIN"/>
25+ <category android:name="android.intent.category.LAUNCHER"/>
26+ </intent-filter>
27+ </activity>
28+ <!-- Don't delete the meta-data below.
29+ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
30+ <meta-data
31+ android:name="flutterEmbedding"
32+ android:value="2" />
33+ </application>
34+ <!-- Required to query activities that can process text, see:
35+ https://developer.android.com/training/package-visibility and
36+ https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
37+
38+ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
39+ <queries>
40+ <intent>
41+ <action android:name="android.intent.action.PROCESS_TEXT"/>
42+ <data android:mimeType="text/plain"/>
43+ </intent>
44+ </queries>
45+</manifest>
added app/android/app/src/main/kotlin/fr/rbrt/glean/glean_app/MainActivity.kt +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+package fr.rbrt.glean.glean_app
2+
3+import io.flutter.embedding.android.FlutterActivity
4+
5+class MainActivity : FlutterActivity()
new file mode 100644
@@ -0,0 +1,5 @@
1+package fr.rbrt.glean.glean_app
2+
3+import io.flutter.embedding.android.FlutterActivity
4+
5+class MainActivity : FlutterActivity()
added app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Modify this file to customize your launch splash screen -->
3+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
4+ <item android:drawable="?android:colorBackground" />
5+
6+ <!-- You can insert your own image assets here -->
7+ <!-- <item>
8+ <bitmap
9+ android:gravity="center"
10+ android:src="@mipmap/launch_image" />
11+ </item> -->
12+</layer-list>
new file mode 100644
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Modify this file to customize your launch splash screen -->
3+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
4+ <item android:drawable="?android:colorBackground" />
5+
6+ <!-- You can insert your own image assets here -->
7+ <!-- <item>
8+ <bitmap
9+ android:gravity="center"
10+ android:src="@mipmap/launch_image" />
11+ </item> -->
12+</layer-list>
added app/android/app/src/main/res/drawable/launch_background.xml +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Modify this file to customize your launch splash screen -->
3+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
4+ <item android:drawable="@android:color/white" />
5+
6+ <!-- You can insert your own image assets here -->
7+ <!-- <item>
8+ <bitmap
9+ android:gravity="center"
10+ android:src="@mipmap/launch_image" />
11+ </item> -->
12+</layer-list>
new file mode 100644
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Modify this file to customize your launch splash screen -->
3+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
4+ <item android:drawable="@android:color/white" />
5+
6+ <!-- You can insert your own image assets here -->
7+ <!-- <item>
8+ <bitmap
9+ android:gravity="center"
10+ android:src="@mipmap/launch_image" />
11+ </item> -->
12+</layer-list>
added app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differBinary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
added app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differBinary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
added app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differBinary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
added app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differBinary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
added app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
new file mode 100644
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differBinary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
added app/android/app/src/main/res/values-night/styles.xml +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
4+ <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
5+ <!-- Show a splash screen on the activity. Automatically removed when
6+ the Flutter engine draws its first frame -->
7+ <item name="android:windowBackground">@drawable/launch_background</item>
8+ </style>
9+ <!-- Theme applied to the Android Window as soon as the process has started.
10+ This theme determines the color of the Android Window while your
11+ Flutter UI initializes, as well as behind your Flutter UI while its
12+ running.
13+
14+ This Theme is only used starting with V2 of Flutter's Android embedding. -->
15+ <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
16+ <item name="android:windowBackground">?android:colorBackground</item>
17+ </style>
18+</resources>
new file mode 100644
@@ -0,0 +1,18 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
4+ <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
5+ <!-- Show a splash screen on the activity. Automatically removed when
6+ the Flutter engine draws its first frame -->
7+ <item name="android:windowBackground">@drawable/launch_background</item>
8+ </style>
9+ <!-- Theme applied to the Android Window as soon as the process has started.
10+ This theme determines the color of the Android Window while your
11+ Flutter UI initializes, as well as behind your Flutter UI while its
12+ running.
13+
14+ This Theme is only used starting with V2 of Flutter's Android embedding. -->
15+ <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
16+ <item name="android:windowBackground">?android:colorBackground</item>
17+ </style>
18+</resources>
added app/android/app/src/main/res/values/styles.xml +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
4+ <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
5+ <!-- Show a splash screen on the activity. Automatically removed when
6+ the Flutter engine draws its first frame -->
7+ <item name="android:windowBackground">@drawable/launch_background</item>
8+ </style>
9+ <!-- Theme applied to the Android Window as soon as the process has started.
10+ This theme determines the color of the Android Window while your
11+ Flutter UI initializes, as well as behind your Flutter UI while its
12+ running.
13+
14+ This Theme is only used starting with V2 of Flutter's Android embedding. -->
15+ <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
16+ <item name="android:windowBackground">?android:colorBackground</item>
17+ </style>
18+</resources>
new file mode 100644
@@ -0,0 +1,18 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
4+ <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
5+ <!-- Show a splash screen on the activity. Automatically removed when
6+ the Flutter engine draws its first frame -->
7+ <item name="android:windowBackground">@drawable/launch_background</item>
8+ </style>
9+ <!-- Theme applied to the Android Window as soon as the process has started.
10+ This theme determines the color of the Android Window while your
11+ Flutter UI initializes, as well as behind your Flutter UI while its
12+ running.
13+
14+ This Theme is only used starting with V2 of Flutter's Android embedding. -->
15+ <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
16+ <item name="android:windowBackground">?android:colorBackground</item>
17+ </style>
18+</resources>
added app/android/app/src/profile/AndroidManifest.xml +7 -0
new file mode 100644
@@ -0,0 +1,7 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The INTERNET permission is required for development. Specifically,
3+ the Flutter tool needs it to communicate with the running application
4+ to allow setting breakpoints, to provide hot reload, etc.
5+ -->
6+ <uses-permission android:name="android.permission.INTERNET"/>
7+</manifest>
new file mode 100644
@@ -0,0 +1,7 @@
1+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The INTERNET permission is required for development. Specifically,
3+ the Flutter tool needs it to communicate with the running application
4+ to allow setting breakpoints, to provide hot reload, etc.
5+ -->
6+ <uses-permission android:name="android.permission.INTERNET"/>
7+</manifest>
added app/android/build.gradle.kts +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+allprojects {
2+ repositories {
3+ google()
4+ mavenCentral()
5+ }
6+}
7+
8+val newBuildDir: Directory =
9+ rootProject.layout.buildDirectory
10+ .dir("../../build")
11+ .get()
12+rootProject.layout.buildDirectory.value(newBuildDir)
13+
14+subprojects {
15+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
16+ project.layout.buildDirectory.value(newSubprojectBuildDir)
17+}
18+subprojects {
19+ project.evaluationDependsOn(":app")
20+}
21+
22+tasks.register<Delete>("clean") {
23+ delete(rootProject.layout.buildDirectory)
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+allprojects {
2+ repositories {
3+ google()
4+ mavenCentral()
5+ }
6+}
7+
8+val newBuildDir: Directory =
9+ rootProject.layout.buildDirectory
10+ .dir("../../build")
11+ .get()
12+rootProject.layout.buildDirectory.value(newBuildDir)
13+
14+subprojects {
15+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
16+ project.layout.buildDirectory.value(newSubprojectBuildDir)
17+}
18+subprojects {
19+ project.evaluationDependsOn(":app")
20+}
21+
22+tasks.register<Delete>("clean") {
23+ delete(rootProject.layout.buildDirectory)
24+}
added app/android/gradle.properties +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
2+android.useAndroidX=true
3+# This newDsl flag was added by the Flutter template
4+android.newDsl=false
5+# This builtInKotlin flag was added by the Flutter template
6+android.builtInKotlin=false
new file mode 100644
@@ -0,0 +1,6 @@
1+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
2+android.useAndroidX=true
3+# This newDsl flag was added by the Flutter template
4+android.newDsl=false
5+# This builtInKotlin flag was added by the Flutter template
6+android.builtInKotlin=false
added app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+zipStoreBase=GRADLE_USER_HOME
4+zipStorePath=wrapper/dists
5+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
new file mode 100644
@@ -0,0 +1,5 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+zipStoreBase=GRADLE_USER_HOME
4+zipStorePath=wrapper/dists
5+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
added app/android/settings.gradle.kts +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+pluginManagement {
2+ val flutterSdkPath =
3+ run {
4+ val properties = java.util.Properties()
5+ file("local.properties").inputStream().use { properties.load(it) }
6+ val flutterSdkPath = properties.getProperty("flutter.sdk")
7+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
8+ flutterSdkPath
9+ }
10+
11+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
12+
13+ repositories {
14+ google()
15+ mavenCentral()
16+ gradlePluginPortal()
17+ }
18+}
19+
20+plugins {
21+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
22+ id("com.android.application") version "9.1.0" apply false
23+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
24+}
25+
26+include(":app")
new file mode 100644
@@ -0,0 +1,26 @@
1+pluginManagement {
2+ val flutterSdkPath =
3+ run {
4+ val properties = java.util.Properties()
5+ file("local.properties").inputStream().use { properties.load(it) }
6+ val flutterSdkPath = properties.getProperty("flutter.sdk")
7+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
8+ flutterSdkPath
9+ }
10+
11+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
12+
13+ repositories {
14+ google()
15+ mavenCentral()
16+ gradlePluginPortal()
17+ }
18+}
19+
20+plugins {
21+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
22+ id("com.android.application") version "9.1.0" apply false
23+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
24+}
25+
26+include(":app")
added app/lib/main.dart +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+import 'package:flutter/material.dart';
2+
3+void main() {
4+ runApp(const MyApp());
5+}
6+
7+class MyApp extends StatelessWidget {
8+ const MyApp({super.key});
9+
10+ // This widget is the root of your application.
11+ @override
12+ Widget build(BuildContext context) {
13+ return MaterialApp(
14+ title: 'Flutter Demo',
15+ theme: ThemeData(
16+ // This is the theme of your application.
17+ //
18+ // TRY THIS: Try running your application with "flutter run". You'll see
19+ // the application has a purple toolbar. Then, without quitting the app,
20+ // try changing the seedColor in the colorScheme below to Colors.green
21+ // and then invoke "hot reload" (save your changes or press the "hot
22+ // reload" button in a Flutter-supported IDE, or press "r" if you used
23+ // the command line to start the app).
24+ //
25+ // Notice that the counter didn't reset back to zero; the application
26+ // state is not lost during the reload. To reset the state, use hot
27+ // restart instead.
28+ //
29+ // This works for code too, not just values: Most code changes can be
30+ // tested with just a hot reload.
31+ colorScheme: .fromSeed(seedColor: Colors.deepPurple),
32+ ),
33+ home: const MyHomePage(title: 'Flutter Demo Home Page'),
34+ );
35+ }
36+}
37+
38+class MyHomePage extends StatefulWidget {
39+ const MyHomePage({super.key, required this.title});
40+
41+ // This widget is the home page of your application. It is stateful, meaning
42+ // that it has a State object (defined below) that contains fields that affect
43+ // how it looks.
44+
45+ // This class is the configuration for the state. It holds the values (in this
46+ // case the title) provided by the parent (in this case the App widget) and
47+ // used by the build method of the State. Fields in a Widget subclass are
48+ // always marked "final".
49+
50+ final String title;
51+
52+ @override
53+ State<MyHomePage> createState() => _MyHomePageState();
54+}
55+
56+class _MyHomePageState extends State<MyHomePage> {
57+ int _counter = 0;
58+
59+ void _incrementCounter() {
60+ setState(() {
61+ // This call to setState tells the Flutter framework that something has
62+ // changed in this State, which causes it to rerun the build method below
63+ // so that the display can reflect the updated values. If we changed
64+ // _counter without calling setState(), then the build method would not be
65+ // called again, and so nothing would appear to happen.
66+ _counter++;
67+ });
68+ }
69+
70+ @override
71+ Widget build(BuildContext context) {
72+ // This method is rerun every time setState is called, for instance as done
73+ // by the _incrementCounter method above.
74+ //
75+ // The Flutter framework has been optimized to make rerunning build methods
76+ // fast, so that you can just rebuild anything that needs updating rather
77+ // than having to individually change instances of widgets.
78+ return Scaffold(
79+ appBar: AppBar(
80+ // TRY THIS: Try changing the color here to a specific color (to
81+ // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
82+ // change color while the other colors stay the same.
83+ backgroundColor: Theme.of(context).colorScheme.inversePrimary,
84+ // Here we take the value from the MyHomePage object that was created by
85+ // the App.build method, and use it to set our appbar title.
86+ title: Text(widget.title),
87+ ),
88+ body: Center(
89+ // Center is a layout widget. It takes a single child and positions it
90+ // in the middle of the parent.
91+ child: Column(
92+ // Column is also a layout widget. It takes a list of children and
93+ // arranges them vertically. By default, it sizes itself to fit its
94+ // children horizontally, and tries to be as tall as its parent.
95+ //
96+ // Column has various properties to control how it sizes itself and
97+ // how it positions its children. Here we use mainAxisAlignment to
98+ // center the children vertically; the main axis here is the vertical
99+ // axis because Columns are vertical (the cross axis would be
100+ // horizontal).
101+ //
102+ // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
103+ // action in the IDE, or press "p" in the console), to see the
104+ // wireframe for each widget.
105+ mainAxisAlignment: .center,
106+ children: [
107+ const Text('You have pushed the button this many times:'),
108+ Text(
109+ '$_counter',
110+ style: Theme.of(context).textTheme.headlineMedium,
111+ ),
112+ ],
113+ ),
114+ ),
115+ floatingActionButton: FloatingActionButton(
116+ onPressed: _incrementCounter,
117+ tooltip: 'Increment',
118+ child: const Icon(Icons.add),
119+ ),
120+ );
121+ }
122+}
new file mode 100644
@@ -0,0 +1,122 @@
1+import 'package:flutter/material.dart';
2+
3+void main() {
4+ runApp(const MyApp());
5+}
6+
7+class MyApp extends StatelessWidget {
8+ const MyApp({super.key});
9+
10+ // This widget is the root of your application.
11+ @override
12+ Widget build(BuildContext context) {
13+ return MaterialApp(
14+ title: 'Flutter Demo',
15+ theme: ThemeData(
16+ // This is the theme of your application.
17+ //
18+ // TRY THIS: Try running your application with "flutter run". You'll see
19+ // the application has a purple toolbar. Then, without quitting the app,
20+ // try changing the seedColor in the colorScheme below to Colors.green
21+ // and then invoke "hot reload" (save your changes or press the "hot
22+ // reload" button in a Flutter-supported IDE, or press "r" if you used
23+ // the command line to start the app).
24+ //
25+ // Notice that the counter didn't reset back to zero; the application
26+ // state is not lost during the reload. To reset the state, use hot
27+ // restart instead.
28+ //
29+ // This works for code too, not just values: Most code changes can be
30+ // tested with just a hot reload.
31+ colorScheme: .fromSeed(seedColor: Colors.deepPurple),
32+ ),
33+ home: const MyHomePage(title: 'Flutter Demo Home Page'),
34+ );
35+ }
36+}
37+
38+class MyHomePage extends StatefulWidget {
39+ const MyHomePage({super.key, required this.title});
40+
41+ // This widget is the home page of your application. It is stateful, meaning
42+ // that it has a State object (defined below) that contains fields that affect
43+ // how it looks.
44+
45+ // This class is the configuration for the state. It holds the values (in this
46+ // case the title) provided by the parent (in this case the App widget) and
47+ // used by the build method of the State. Fields in a Widget subclass are
48+ // always marked "final".
49+
50+ final String title;
51+
52+ @override
53+ State<MyHomePage> createState() => _MyHomePageState();
54+}
55+
56+class _MyHomePageState extends State<MyHomePage> {
57+ int _counter = 0;
58+
59+ void _incrementCounter() {
60+ setState(() {
61+ // This call to setState tells the Flutter framework that something has
62+ // changed in this State, which causes it to rerun the build method below
63+ // so that the display can reflect the updated values. If we changed
64+ // _counter without calling setState(), then the build method would not be
65+ // called again, and so nothing would appear to happen.
66+ _counter++;
67+ });
68+ }
69+
70+ @override
71+ Widget build(BuildContext context) {
72+ // This method is rerun every time setState is called, for instance as done
73+ // by the _incrementCounter method above.
74+ //
75+ // The Flutter framework has been optimized to make rerunning build methods
76+ // fast, so that you can just rebuild anything that needs updating rather
77+ // than having to individually change instances of widgets.
78+ return Scaffold(
79+ appBar: AppBar(
80+ // TRY THIS: Try changing the color here to a specific color (to
81+ // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
82+ // change color while the other colors stay the same.
83+ backgroundColor: Theme.of(context).colorScheme.inversePrimary,
84+ // Here we take the value from the MyHomePage object that was created by
85+ // the App.build method, and use it to set our appbar title.
86+ title: Text(widget.title),
87+ ),
88+ body: Center(
89+ // Center is a layout widget. It takes a single child and positions it
90+ // in the middle of the parent.
91+ child: Column(
92+ // Column is also a layout widget. It takes a list of children and
93+ // arranges them vertically. By default, it sizes itself to fit its
94+ // children horizontally, and tries to be as tall as its parent.
95+ //
96+ // Column has various properties to control how it sizes itself and
97+ // how it positions its children. Here we use mainAxisAlignment to
98+ // center the children vertically; the main axis here is the vertical
99+ // axis because Columns are vertical (the cross axis would be
100+ // horizontal).
101+ //
102+ // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
103+ // action in the IDE, or press "p" in the console), to see the
104+ // wireframe for each widget.
105+ mainAxisAlignment: .center,
106+ children: [
107+ const Text('You have pushed the button this many times:'),
108+ Text(
109+ '$_counter',
110+ style: Theme.of(context).textTheme.headlineMedium,
111+ ),
112+ ],
113+ ),
114+ ),
115+ floatingActionButton: FloatingActionButton(
116+ onPressed: _incrementCounter,
117+ tooltip: 'Increment',
118+ child: const Icon(Icons.add),
119+ ),
120+ );
121+ }
122+}
added app/lib/src/api/client.dart +247 -0
new file mode 100644
@@ -0,0 +1,247 @@
1+import 'dart:convert';
2+
3+import 'package:http/http.dart' as http;
4+
5+import '../models/models.dart';
6+import 'responses.dart';
7+import 'session.dart';
8+
9+/// Thrown for any non-2xx API response. The server puts a human-readable
10+/// message in `{"error": "..."}` (writeAPIError), so surface that when present.
11+class ApiException implements Exception {
12+ ApiException(this.statusCode, this.message);
13+
14+ final int statusCode;
15+ final String message;
16+
17+ bool get isUnauthorized => statusCode == 401 || statusCode == 403;
18+
19+ @override
20+ String toString() => message;
21+}
22+
23+/// Typed wrapper over the Glean JSON API (internal/server/server.go).
24+///
25+/// Every method here corresponds to exactly one route; the grouping and order
26+/// follow setupRoutes() so the two can be diffed by eye.
27+class GleanClient {
28+ GleanClient(this.session);
29+
30+ final GleanSession session;
31+
32+ // --- plumbing ---
33+
34+ Map<String, dynamic> _decode(http.Response res) {
35+ final body = res.body.isEmpty ? '{}' : res.body;
36+ late final Map<String, dynamic> json;
37+ try {
38+ json = jsonDecode(body) as Map<String, dynamic>;
39+ } on FormatException {
40+ // A non-JSON body means we hit something other than the API -- a proxy
41+ // error page, or an HTML redirect from an expired session.
42+ throw ApiException(res.statusCode, 'Unexpected non-JSON response from server.');
43+ }
44+ if (res.statusCode >= 200 && res.statusCode < 300) return json;
45+ final msg = (json['error'] as String?) ?? 'Request failed (${res.statusCode}).';
46+ throw ApiException(res.statusCode, msg);
47+ }
48+
49+ Future<Map<String, dynamic>> _get(String path, [Map<String, dynamic>? q]) async =>
50+ _decode(await session.get(path, q));
51+
52+ Future<Map<String, dynamic>> _post(String path,
53+ {Map<String, dynamic>? query, Map<String, String>? form}) async =>
54+ _decode(await session.send('POST', path, query: query, form: form));
55+
56+ Future<Map<String, dynamic>> _delete(String path,
57+ {Map<String, dynamic>? query, Map<String, String>? form}) async =>
58+ _decode(await session.send('DELETE', path, query: query, form: form));
59+
60+ // --- identity ---
61+
62+ Future<MeResponse> me() async => MeResponse.fromJson(await _get('/api/me'));
63+
64+ // --- dashboard ---
65+
66+ Future<DashboardResponse> dashboard() async =>
67+ DashboardResponse.fromJson(await _get('/api/dashboard/'));
68+
69+ // --- articles ---
70+
71+ Future<ArticlesResponse> articles({
72+ int page = 1,
73+ String? feedUrl,
74+ String? status,
75+ String? search,
76+ String? category,
77+ bool sortOldest = false,
78+ }) async =>
79+ ArticlesResponse.fromJson(await _get('/api/articles/', {
80+ 'page': page,
81+ 'feed_url': feedUrl,
82+ 'status': status,
83+ 'q': search,
84+ 'category': category,
85+ if (sortOldest) 'sort': 'oldest',
86+ }));
87+
88+ Future<ArticleDetailResponse> article(int id) async =>
89+ ArticleDetailResponse.fromJson(await _get('/api/articles/$id'));
90+
91+ Future<int> newArticleCount() async =>
92+ jsonInt((await _get('/api/articles/new-count'))['count']);
93+
94+ Future<bool> markRead(int id) async =>
95+ jsonBool((await _post('/api/articles/$id/read'))['is_read']);
96+
97+ Future<bool> markUnread(int id) async =>
98+ jsonBool((await _post('/api/articles/$id/unread'))['is_read']);
99+
100+ Future<LikeResult> likeArticle(int id) async =>
101+ LikeResult.fromJson(await _post('/api/articles/$id/like'));
102+
103+ Future<String> fetchContent(int id) async =>
104+ jsonStr((await _post('/api/articles/$id/fetch-content'))['full_content']);
105+
106+ Future<void> markAllRead({String? feedUrl, String? category}) =>
107+ _post('/api/articles/mark-all-read', form: {
108+ 'feed_url': ?feedUrl,
109+ 'category': ?category,
110+ });
111+
112+ // --- feeds ---
113+
114+ Future<FeedsResponse> feeds({int page = 1, String? category}) async =>
115+ FeedsResponse.fromJson(await _get('/api/feeds/', {'page': page, 'category': category}));
116+
117+ Future<Subscription> addFeed(String url, {String? category}) async {
118+ final j = await _post('/api/feeds/add', form: {
119+ 'url': url,
120+ 'category': ?category,
121+ });
122+ return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
123+ }
124+
125+ Future<Subscription> editFeed(String feedUrl, {String? category, String? title}) async {
126+ final j = await _post('/api/feeds/edit', form: {
127+ 'feed_url': feedUrl,
128+ 'category': ?category,
129+ 'title': ?title,
130+ });
131+ return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
132+ }
133+
134+ Future<void> removeFeed(String feedUrl) =>
135+ _delete('/api/feeds/remove', form: {'feed_url': feedUrl});
136+
137+ Future<int> uploadOpml(String opml) async =>
138+ jsonInt((await _post('/api/feeds/opml/upload', form: {'opml': opml}))['added']);
139+
140+ Future<String> downloadOpml() async {
141+ final res = await session.get('/api/feeds/opml/download');
142+ if (res.statusCode >= 300) {
143+ throw ApiException(res.statusCode, 'Could not export subscriptions.');
144+ }
145+ return res.body;
146+ }
147+
148+ Future<void> refreshFeeds() => _post('/api/feeds/refresh');
149+
150+ Future<void> retryFeed(String feedUrl) =>
151+ _post('/api/feeds/retry', form: {'feed_url': feedUrl});
152+
153+ Future<List<Subscription>> feedList() async =>
154+ jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson);
155+
156+ Future<void> clearAllSubscriptions() => _post('/api/feeds/clear');
157+
158+ // --- trending ---
159+
160+ Future<TrendingResponse> trending({int page = 1, String? scope}) async =>
161+ TrendingResponse.fromJson(await _get('/api/trending/', {'page': page, 'scope': scope}));
162+
163+ // --- profile ---
164+
165+ Future<ProfileResponse> profile(String did) async =>
166+ ProfileResponse.fromJson(await _get('/api/profile/$did'));
167+
168+ // --- library ---
169+
170+ Future<LibraryResponse> library({int likedPage = 1, int annotPage = 1}) async =>
171+ LibraryResponse.fromJson(
172+ await _get('/api/library/', {'liked_page': likedPage, 'annot_page': annotPage}));
173+
174+ Future<Annotation> createAnnotation({
175+ required String articleUrl,
176+ required String feedUrl,
177+ String quote = '',
178+ String note = '',
179+ List<String> tags = const [],
180+ int? rating,
181+ }) async {
182+ final j = await _post('/api/library/create', form: {
183+ 'article_url': articleUrl,
184+ 'feed_url': feedUrl,
185+ 'quote': quote,
186+ 'note': note,
187+ 'tags': tags.join(','),
188+ if (rating != null) 'rating': '$rating',
189+ });
190+ return Annotation.fromJson(j['annotation'] as Map<String, dynamic>);
191+ }
192+
193+ Future<void> deleteAnnotation(int id) => _post('/api/library/$id/delete');
194+
195+ // --- recommendations ---
196+
197+ Future<List<Article>> articleRecs() async =>
198+ jsonList((await _get('/api/recs/articles'))['articles'], Article.fromJson);
199+
200+ Future<FeedRecsResponse> feedRecs() async =>
201+ FeedRecsResponse.fromJson(await _get('/api/recs/feeds'));
202+
203+ Future<PeopleRecsResponse> peopleRecs() async =>
204+ PeopleRecsResponse.fromJson(await _get('/api/recs/people'));
205+
206+ Future<void> dismissFeedRec(String feedUrl) =>
207+ _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl});
208+
209+ Future<void> dismissArticleRec(int articleId) =>
210+ _post('/api/recs/dismiss-article', form: {'article_id': '$articleId'});
211+
212+ Future<void> dismissPersonRec(String did) =>
213+ _post('/api/recs/dismiss-person', form: {'did': did});
214+
215+ // --- settings ---
216+
217+ Future<List<String>> toggleLanguage(String code) async =>
218+ jsonStrList((await _post('/api/settings/languages/$code'))['languages']);
219+
220+ Future<bool> toggleExpandedView() async =>
221+ jsonBool((await _post('/api/settings/expanded-view'))['expanded_view']);
222+
223+ Future<bool> toggleDigestEnabled() async =>
224+ jsonBool((await _post('/api/settings/digest-enabled'))['digest_enabled']);
225+
226+ // --- digest ---
227+
228+ Future<Map<String, dynamic>> digest() => _get('/api/digest');
229+
230+ Future<void> markDigestRead() => _post('/api/digest/mark-read');
231+
232+ // --- auth ---
233+
234+ /// Resolve a partial handle to candidate accounts for the login field.
235+ Future<List<Map<String, dynamic>>> resolveActors(String query) async {
236+ final j = await _get('/api/auth/actors', {'q': query});
237+ return ((j['actors'] as List?) ?? const []).cast<Map<String, dynamic>>();
238+ }
239+
240+ /// Returns the URL the user must visit to authorize. The server answers with
241+ /// `{"redirect": ...}` -- either the PDS authorization endpoint, or
242+ /// `/dashboard` when it fell back to creating a read-only account.
243+ Future<String> startAuth(String handle) async =>
244+ jsonStr((await _post('/api/auth/start', form: {'handle': handle}))['redirect']);
245+
246+ Future<void> logout() => _post('/api/auth/logout');
247+}
new file mode 100644
@@ -0,0 +1,247 @@
1+import 'dart:convert';
2+
3+import 'package:http/http.dart' as http;
4+
5+import '../models/models.dart';
6+import 'responses.dart';
7+import 'session.dart';
8+
9+/// Thrown for any non-2xx API response. The server puts a human-readable
10+/// message in `{"error": "..."}` (writeAPIError), so surface that when present.
11+class ApiException implements Exception {
12+ ApiException(this.statusCode, this.message);
13+
14+ final int statusCode;
15+ final String message;
16+
17+ bool get isUnauthorized => statusCode == 401 || statusCode == 403;
18+
19+ @override
20+ String toString() => message;
21+}
22+
23+/// Typed wrapper over the Glean JSON API (internal/server/server.go).
24+///
25+/// Every method here corresponds to exactly one route; the grouping and order
26+/// follow setupRoutes() so the two can be diffed by eye.
27+class GleanClient {
28+ GleanClient(this.session);
29+
30+ final GleanSession session;
31+
32+ // --- plumbing ---
33+
34+ Map<String, dynamic> _decode(http.Response res) {
35+ final body = res.body.isEmpty ? '{}' : res.body;
36+ late final Map<String, dynamic> json;
37+ try {
38+ json = jsonDecode(body) as Map<String, dynamic>;
39+ } on FormatException {
40+ // A non-JSON body means we hit something other than the API -- a proxy
41+ // error page, or an HTML redirect from an expired session.
42+ throw ApiException(res.statusCode, 'Unexpected non-JSON response from server.');
43+ }
44+ if (res.statusCode >= 200 && res.statusCode < 300) return json;
45+ final msg = (json['error'] as String?) ?? 'Request failed (${res.statusCode}).';
46+ throw ApiException(res.statusCode, msg);
47+ }
48+
49+ Future<Map<String, dynamic>> _get(String path, [Map<String, dynamic>? q]) async =>
50+ _decode(await session.get(path, q));
51+
52+ Future<Map<String, dynamic>> _post(String path,
53+ {Map<String, dynamic>? query, Map<String, String>? form}) async =>
54+ _decode(await session.send('POST', path, query: query, form: form));
55+
56+ Future<Map<String, dynamic>> _delete(String path,
57+ {Map<String, dynamic>? query, Map<String, String>? form}) async =>
58+ _decode(await session.send('DELETE', path, query: query, form: form));
59+
60+ // --- identity ---
61+
62+ Future<MeResponse> me() async => MeResponse.fromJson(await _get('/api/me'));
63+
64+ // --- dashboard ---
65+
66+ Future<DashboardResponse> dashboard() async =>
67+ DashboardResponse.fromJson(await _get('/api/dashboard/'));
68+
69+ // --- articles ---
70+
71+ Future<ArticlesResponse> articles({
72+ int page = 1,
73+ String? feedUrl,
74+ String? status,
75+ String? search,
76+ String? category,
77+ bool sortOldest = false,
78+ }) async =>
79+ ArticlesResponse.fromJson(await _get('/api/articles/', {
80+ 'page': page,
81+ 'feed_url': feedUrl,
82+ 'status': status,
83+ 'q': search,
84+ 'category': category,
85+ if (sortOldest) 'sort': 'oldest',
86+ }));
87+
88+ Future<ArticleDetailResponse> article(int id) async =>
89+ ArticleDetailResponse.fromJson(await _get('/api/articles/$id'));
90+
91+ Future<int> newArticleCount() async =>
92+ jsonInt((await _get('/api/articles/new-count'))['count']);
93+
94+ Future<bool> markRead(int id) async =>
95+ jsonBool((await _post('/api/articles/$id/read'))['is_read']);
96+
97+ Future<bool> markUnread(int id) async =>
98+ jsonBool((await _post('/api/articles/$id/unread'))['is_read']);
99+
100+ Future<LikeResult> likeArticle(int id) async =>
101+ LikeResult.fromJson(await _post('/api/articles/$id/like'));
102+
103+ Future<String> fetchContent(int id) async =>
104+ jsonStr((await _post('/api/articles/$id/fetch-content'))['full_content']);
105+
106+ Future<void> markAllRead({String? feedUrl, String? category}) =>
107+ _post('/api/articles/mark-all-read', form: {
108+ 'feed_url': ?feedUrl,
109+ 'category': ?category,
110+ });
111+
112+ // --- feeds ---
113+
114+ Future<FeedsResponse> feeds({int page = 1, String? category}) async =>
115+ FeedsResponse.fromJson(await _get('/api/feeds/', {'page': page, 'category': category}));
116+
117+ Future<Subscription> addFeed(String url, {String? category}) async {
118+ final j = await _post('/api/feeds/add', form: {
119+ 'url': url,
120+ 'category': ?category,
121+ });
122+ return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
123+ }
124+
125+ Future<Subscription> editFeed(String feedUrl, {String? category, String? title}) async {
126+ final j = await _post('/api/feeds/edit', form: {
127+ 'feed_url': feedUrl,
128+ 'category': ?category,
129+ 'title': ?title,
130+ });
131+ return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
132+ }
133+
134+ Future<void> removeFeed(String feedUrl) =>
135+ _delete('/api/feeds/remove', form: {'feed_url': feedUrl});
136+
137+ Future<int> uploadOpml(String opml) async =>
138+ jsonInt((await _post('/api/feeds/opml/upload', form: {'opml': opml}))['added']);
139+
140+ Future<String> downloadOpml() async {
141+ final res = await session.get('/api/feeds/opml/download');
142+ if (res.statusCode >= 300) {
143+ throw ApiException(res.statusCode, 'Could not export subscriptions.');
144+ }
145+ return res.body;
146+ }
147+
148+ Future<void> refreshFeeds() => _post('/api/feeds/refresh');
149+
150+ Future<void> retryFeed(String feedUrl) =>
151+ _post('/api/feeds/retry', form: {'feed_url': feedUrl});
152+
153+ Future<List<Subscription>> feedList() async =>
154+ jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson);
155+
156+ Future<void> clearAllSubscriptions() => _post('/api/feeds/clear');
157+
158+ // --- trending ---
159+
160+ Future<TrendingResponse> trending({int page = 1, String? scope}) async =>
161+ TrendingResponse.fromJson(await _get('/api/trending/', {'page': page, 'scope': scope}));
162+
163+ // --- profile ---
164+
165+ Future<ProfileResponse> profile(String did) async =>
166+ ProfileResponse.fromJson(await _get('/api/profile/$did'));
167+
168+ // --- library ---
169+
170+ Future<LibraryResponse> library({int likedPage = 1, int annotPage = 1}) async =>
171+ LibraryResponse.fromJson(
172+ await _get('/api/library/', {'liked_page': likedPage, 'annot_page': annotPage}));
173+
174+ Future<Annotation> createAnnotation({
175+ required String articleUrl,
176+ required String feedUrl,
177+ String quote = '',
178+ String note = '',
179+ List<String> tags = const [],
180+ int? rating,
181+ }) async {
182+ final j = await _post('/api/library/create', form: {
183+ 'article_url': articleUrl,
184+ 'feed_url': feedUrl,
185+ 'quote': quote,
186+ 'note': note,
187+ 'tags': tags.join(','),
188+ if (rating != null) 'rating': '$rating',
189+ });
190+ return Annotation.fromJson(j['annotation'] as Map<String, dynamic>);
191+ }
192+
193+ Future<void> deleteAnnotation(int id) => _post('/api/library/$id/delete');
194+
195+ // --- recommendations ---
196+
197+ Future<List<Article>> articleRecs() async =>
198+ jsonList((await _get('/api/recs/articles'))['articles'], Article.fromJson);
199+
200+ Future<FeedRecsResponse> feedRecs() async =>
201+ FeedRecsResponse.fromJson(await _get('/api/recs/feeds'));
202+
203+ Future<PeopleRecsResponse> peopleRecs() async =>
204+ PeopleRecsResponse.fromJson(await _get('/api/recs/people'));
205+
206+ Future<void> dismissFeedRec(String feedUrl) =>
207+ _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl});
208+
209+ Future<void> dismissArticleRec(int articleId) =>
210+ _post('/api/recs/dismiss-article', form: {'article_id': '$articleId'});
211+
212+ Future<void> dismissPersonRec(String did) =>
213+ _post('/api/recs/dismiss-person', form: {'did': did});
214+
215+ // --- settings ---
216+
217+ Future<List<String>> toggleLanguage(String code) async =>
218+ jsonStrList((await _post('/api/settings/languages/$code'))['languages']);
219+
220+ Future<bool> toggleExpandedView() async =>
221+ jsonBool((await _post('/api/settings/expanded-view'))['expanded_view']);
222+
223+ Future<bool> toggleDigestEnabled() async =>
224+ jsonBool((await _post('/api/settings/digest-enabled'))['digest_enabled']);
225+
226+ // --- digest ---
227+
228+ Future<Map<String, dynamic>> digest() => _get('/api/digest');
229+
230+ Future<void> markDigestRead() => _post('/api/digest/mark-read');
231+
232+ // --- auth ---
233+
234+ /// Resolve a partial handle to candidate accounts for the login field.
235+ Future<List<Map<String, dynamic>>> resolveActors(String query) async {
236+ final j = await _get('/api/auth/actors', {'q': query});
237+ return ((j['actors'] as List?) ?? const []).cast<Map<String, dynamic>>();
238+ }
239+
240+ /// Returns the URL the user must visit to authorize. The server answers with
241+ /// `{"redirect": ...}` -- either the PDS authorization endpoint, or
242+ /// `/dashboard` when it fell back to creating a read-only account.
243+ Future<String> startAuth(String handle) async =>
244+ jsonStr((await _post('/api/auth/start', form: {'handle': handle}))['redirect']);
245+
246+ Future<void> logout() => _post('/api/auth/logout');
247+}
added app/lib/src/api/responses.dart +261 -0
new file mode 100644
@@ -0,0 +1,261 @@
1+import '../models/models.dart';
2+
3+/// Response envelopes for each endpoint, mirroring the unexported structs at
4+/// the bottom of internal/server/api.go.
5+
6+class MeResponse {
7+ const MeResponse({this.user, required this.csrfToken, required this.hasLlm, required this.clientId});
8+
9+ final User? user;
10+ final String csrfToken;
11+ final bool hasLlm;
12+ final String clientId;
13+
14+ bool get signedIn => user != null;
15+
16+ /// True when the server is running without a real OAuth client id, in which
17+ /// case sign-in cannot complete (see GLEAN_OAUTH_CLIENT_ID).
18+ bool get oauthConfigured => clientId.isNotEmpty;
19+
20+ factory MeResponse.fromJson(Map<String, dynamic> j) => MeResponse(
21+ user: j['user'] == null ? null : User.fromJson(j['user'] as Map<String, dynamic>),
22+ csrfToken: jsonStr(j['csrf_token']),
23+ hasLlm: jsonBool(j['has_llm']),
24+ clientId: jsonStr(j['client_id']),
25+ );
26+}
27+
28+class DashboardResponse {
29+ const DashboardResponse({
30+ required this.user,
31+ required this.subscriptionCount,
32+ required this.unreadCount,
33+ required this.articles,
34+ required this.personalTrending,
35+ required this.globalTrending,
36+ required this.digestEnabled,
37+ required this.hasLlm,
38+ });
39+
40+ final User user;
41+ final int subscriptionCount;
42+ final int unreadCount;
43+ final List<Article> articles;
44+ final List<TrendingItem> personalTrending;
45+ final List<TrendingItem> globalTrending;
46+ final bool digestEnabled;
47+ final bool hasLlm;
48+
49+ factory DashboardResponse.fromJson(Map<String, dynamic> j) => DashboardResponse(
50+ user: User.fromJson((j['user'] as Map<String, dynamic>?) ?? const {}),
51+ subscriptionCount: jsonInt(j['subscription_count']),
52+ unreadCount: jsonInt(j['unread_count']),
53+ articles: jsonList(j['articles'], Article.fromJson),
54+ personalTrending: jsonList(j['personal_trending'], TrendingItem.fromJson),
55+ globalTrending: jsonList(j['global_trending'], TrendingItem.fromJson),
56+ digestEnabled: jsonBool(j['digest_enabled']),
57+ hasLlm: jsonBool(j['has_llm']),
58+ );
59+}
60+
61+class ArticlesResponse {
62+ const ArticlesResponse({
63+ required this.articles,
64+ required this.feedUrl,
65+ required this.status,
66+ required this.searchQuery,
67+ required this.sortOldest,
68+ required this.category,
69+ required this.categories,
70+ required this.expandedView,
71+ required this.pagination,
72+ this.feed,
73+ required this.isSubscribed,
74+ });
75+
76+ final List<Article> articles;
77+ final String feedUrl;
78+ final String status;
79+ final String searchQuery;
80+ final bool sortOldest;
81+ final String category;
82+ final List<String> categories;
83+ final bool expandedView;
84+ final Pagination pagination;
85+ final Feed? feed;
86+ final bool isSubscribed;
87+
88+ factory ArticlesResponse.fromJson(Map<String, dynamic> j) => ArticlesResponse(
89+ articles: jsonList(j['articles'], Article.fromJson),
90+ feedUrl: jsonStr(j['feed_url']),
91+ status: jsonStr(j['status']),
92+ searchQuery: jsonStr(j['search_query']),
93+ sortOldest: jsonBool(j['sort_oldest']),
94+ category: jsonStr(j['category']),
95+ categories: jsonStrList(j['categories']),
96+ expandedView: jsonBool(j['expanded_view']),
97+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
98+ feed: j['feed'] == null ? null : Feed.fromJson(j['feed'] as Map<String, dynamic>),
99+ isSubscribed: jsonBool(j['is_subscribed']),
100+ );
101+}
102+
103+class ArticleDetailResponse {
104+ const ArticleDetailResponse({
105+ required this.currentUserDid,
106+ required this.article,
107+ required this.feed,
108+ required this.annotations,
109+ this.nextId,
110+ });
111+
112+ final String currentUserDid;
113+ final Article article;
114+ final Feed feed;
115+ final List<Annotation> annotations;
116+ final int? nextId;
117+
118+ factory ArticleDetailResponse.fromJson(Map<String, dynamic> j) => ArticleDetailResponse(
119+ currentUserDid: jsonStr(j['current_user_did']),
120+ article: Article.fromJson((j['article'] as Map<String, dynamic>?) ?? const {}),
121+ feed: Feed.fromJson((j['feed'] as Map<String, dynamic>?) ?? const {}),
122+ annotations: jsonList(j['annotations'], Annotation.fromJson),
123+ nextId: (j['next_id'] as num?)?.toInt(),
124+ );
125+}
126+
127+class FeedsResponse {
128+ const FeedsResponse({
129+ required this.subscriptions,
130+ required this.subscriptionCount,
131+ required this.categories,
132+ required this.category,
133+ required this.deadFeeds,
134+ required this.pagination,
135+ });
136+
137+ final List<Subscription> subscriptions;
138+ final int subscriptionCount;
139+ final List<String> categories;
140+ final String category;
141+ final List<Feed> deadFeeds;
142+ final Pagination pagination;
143+
144+ factory FeedsResponse.fromJson(Map<String, dynamic> j) => FeedsResponse(
145+ subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
146+ subscriptionCount: jsonInt(j['subscription_count']),
147+ categories: jsonStrList(j['categories']),
148+ category: jsonStr(j['category']),
149+ deadFeeds: jsonList(j['dead_feeds'], Feed.fromJson),
150+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
151+ );
152+}
153+
154+class TrendingResponse {
155+ const TrendingResponse({required this.trending, required this.scope, required this.pagination});
156+
157+ final List<TrendingItem> trending;
158+ final String scope;
159+ final Pagination pagination;
160+
161+ factory TrendingResponse.fromJson(Map<String, dynamic> j) => TrendingResponse(
162+ trending: jsonList(j['trending'], TrendingItem.fromJson),
163+ scope: jsonStr(j['scope']),
164+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
165+ );
166+}
167+
168+class LibraryResponse {
169+ const LibraryResponse({
170+ required this.articles,
171+ required this.annotations,
172+ required this.likedPage,
173+ required this.annotPage,
174+ });
175+
176+ final List<Article> articles;
177+ final List<Annotation> annotations;
178+ final Pagination likedPage;
179+ final Pagination annotPage;
180+
181+ factory LibraryResponse.fromJson(Map<String, dynamic> j) => LibraryResponse(
182+ articles: jsonList(j['articles'], Article.fromJson),
183+ annotations: jsonList(j['annotations'], Annotation.fromJson),
184+ likedPage: Pagination.fromJson(j['liked_page'] as Map<String, dynamic>?),
185+ annotPage: Pagination.fromJson(j['annot_page'] as Map<String, dynamic>?),
186+ );
187+}
188+
189+class ProfileResponse {
190+ const ProfileResponse({
191+ required this.profileUser,
192+ required this.subscriptions,
193+ required this.annotations,
194+ required this.subscriptionCount,
195+ required this.annotationCount,
196+ required this.userLanguages,
197+ required this.availableLanguages,
198+ required this.expandedView,
199+ required this.digestEnabled,
200+ });
201+
202+ final User profileUser;
203+ final List<Subscription> subscriptions;
204+ final List<Annotation> annotations;
205+ final int subscriptionCount;
206+ final int annotationCount;
207+ final List<String> userLanguages;
208+ final List<Language> availableLanguages;
209+ final bool expandedView;
210+ final bool digestEnabled;
211+
212+ factory ProfileResponse.fromJson(Map<String, dynamic> j) => ProfileResponse(
213+ profileUser: User.fromJson((j['profile_user'] as Map<String, dynamic>?) ?? const {}),
214+ subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
215+ annotations: jsonList(j['annotations'], Annotation.fromJson),
216+ subscriptionCount: jsonInt(j['subscription_count']),
217+ annotationCount: jsonInt(j['annotation_count']),
218+ userLanguages: jsonStrList(j['user_languages']),
219+ availableLanguages: jsonList(j['available_languages'], Language.fromJson),
220+ expandedView: jsonBool(j['expanded_view']),
221+ digestEnabled: jsonBool(j['digest_enabled']),
222+ );
223+}
224+
225+class PeopleRecsResponse {
226+ const PeopleRecsResponse({required this.followed, required this.discover});
227+
228+ final List<PersonRecommendation> followed;
229+ final List<PersonRecommendation> discover;
230+
231+ factory PeopleRecsResponse.fromJson(Map<String, dynamic> j) => PeopleRecsResponse(
232+ followed: jsonList(j['followed'], PersonRecommendation.fromJson),
233+ discover: jsonList(j['discover'], PersonRecommendation.fromJson),
234+ );
235+}
236+
237+class FeedRecsResponse {
238+ const FeedRecsResponse({required this.feeds, required this.subscriptionCount});
239+
240+ final List<FeedRecommendation> feeds;
241+ final int subscriptionCount;
242+
243+ factory FeedRecsResponse.fromJson(Map<String, dynamic> j) => FeedRecsResponse(
244+ feeds: jsonList(j['feeds'], FeedRecommendation.fromJson),
245+ subscriptionCount: jsonInt(j['subscription_count']),
246+ );
247+}
248+
249+class LikeResult {
250+ const LikeResult({required this.id, required this.liked, required this.likeCount});
251+
252+ final int id;
253+ final bool liked;
254+ final int likeCount;
255+
256+ factory LikeResult.fromJson(Map<String, dynamic> j) => LikeResult(
257+ id: jsonInt(j['id']),
258+ liked: jsonBool(j['liked']),
259+ likeCount: jsonInt(j['like_count']),
260+ );
261+}
new file mode 100644
@@ -0,0 +1,261 @@
1+import '../models/models.dart';
2+
3+/// Response envelopes for each endpoint, mirroring the unexported structs at
4+/// the bottom of internal/server/api.go.
5+
6+class MeResponse {
7+ const MeResponse({this.user, required this.csrfToken, required this.hasLlm, required this.clientId});
8+
9+ final User? user;
10+ final String csrfToken;
11+ final bool hasLlm;
12+ final String clientId;
13+
14+ bool get signedIn => user != null;
15+
16+ /// True when the server is running without a real OAuth client id, in which
17+ /// case sign-in cannot complete (see GLEAN_OAUTH_CLIENT_ID).
18+ bool get oauthConfigured => clientId.isNotEmpty;
19+
20+ factory MeResponse.fromJson(Map<String, dynamic> j) => MeResponse(
21+ user: j['user'] == null ? null : User.fromJson(j['user'] as Map<String, dynamic>),
22+ csrfToken: jsonStr(j['csrf_token']),
23+ hasLlm: jsonBool(j['has_llm']),
24+ clientId: jsonStr(j['client_id']),
25+ );
26+}
27+
28+class DashboardResponse {
29+ const DashboardResponse({
30+ required this.user,
31+ required this.subscriptionCount,
32+ required this.unreadCount,
33+ required this.articles,
34+ required this.personalTrending,
35+ required this.globalTrending,
36+ required this.digestEnabled,
37+ required this.hasLlm,
38+ });
39+
40+ final User user;
41+ final int subscriptionCount;
42+ final int unreadCount;
43+ final List<Article> articles;
44+ final List<TrendingItem> personalTrending;
45+ final List<TrendingItem> globalTrending;
46+ final bool digestEnabled;
47+ final bool hasLlm;
48+
49+ factory DashboardResponse.fromJson(Map<String, dynamic> j) => DashboardResponse(
50+ user: User.fromJson((j['user'] as Map<String, dynamic>?) ?? const {}),
51+ subscriptionCount: jsonInt(j['subscription_count']),
52+ unreadCount: jsonInt(j['unread_count']),
53+ articles: jsonList(j['articles'], Article.fromJson),
54+ personalTrending: jsonList(j['personal_trending'], TrendingItem.fromJson),
55+ globalTrending: jsonList(j['global_trending'], TrendingItem.fromJson),
56+ digestEnabled: jsonBool(j['digest_enabled']),
57+ hasLlm: jsonBool(j['has_llm']),
58+ );
59+}
60+
61+class ArticlesResponse {
62+ const ArticlesResponse({
63+ required this.articles,
64+ required this.feedUrl,
65+ required this.status,
66+ required this.searchQuery,
67+ required this.sortOldest,
68+ required this.category,
69+ required this.categories,
70+ required this.expandedView,
71+ required this.pagination,
72+ this.feed,
73+ required this.isSubscribed,
74+ });
75+
76+ final List<Article> articles;
77+ final String feedUrl;
78+ final String status;
79+ final String searchQuery;
80+ final bool sortOldest;
81+ final String category;
82+ final List<String> categories;
83+ final bool expandedView;
84+ final Pagination pagination;
85+ final Feed? feed;
86+ final bool isSubscribed;
87+
88+ factory ArticlesResponse.fromJson(Map<String, dynamic> j) => ArticlesResponse(
89+ articles: jsonList(j['articles'], Article.fromJson),
90+ feedUrl: jsonStr(j['feed_url']),
91+ status: jsonStr(j['status']),
92+ searchQuery: jsonStr(j['search_query']),
93+ sortOldest: jsonBool(j['sort_oldest']),
94+ category: jsonStr(j['category']),
95+ categories: jsonStrList(j['categories']),
96+ expandedView: jsonBool(j['expanded_view']),
97+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
98+ feed: j['feed'] == null ? null : Feed.fromJson(j['feed'] as Map<String, dynamic>),
99+ isSubscribed: jsonBool(j['is_subscribed']),
100+ );
101+}
102+
103+class ArticleDetailResponse {
104+ const ArticleDetailResponse({
105+ required this.currentUserDid,
106+ required this.article,
107+ required this.feed,
108+ required this.annotations,
109+ this.nextId,
110+ });
111+
112+ final String currentUserDid;
113+ final Article article;
114+ final Feed feed;
115+ final List<Annotation> annotations;
116+ final int? nextId;
117+
118+ factory ArticleDetailResponse.fromJson(Map<String, dynamic> j) => ArticleDetailResponse(
119+ currentUserDid: jsonStr(j['current_user_did']),
120+ article: Article.fromJson((j['article'] as Map<String, dynamic>?) ?? const {}),
121+ feed: Feed.fromJson((j['feed'] as Map<String, dynamic>?) ?? const {}),
122+ annotations: jsonList(j['annotations'], Annotation.fromJson),
123+ nextId: (j['next_id'] as num?)?.toInt(),
124+ );
125+}
126+
127+class FeedsResponse {
128+ const FeedsResponse({
129+ required this.subscriptions,
130+ required this.subscriptionCount,
131+ required this.categories,
132+ required this.category,
133+ required this.deadFeeds,
134+ required this.pagination,
135+ });
136+
137+ final List<Subscription> subscriptions;
138+ final int subscriptionCount;
139+ final List<String> categories;
140+ final String category;
141+ final List<Feed> deadFeeds;
142+ final Pagination pagination;
143+
144+ factory FeedsResponse.fromJson(Map<String, dynamic> j) => FeedsResponse(
145+ subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
146+ subscriptionCount: jsonInt(j['subscription_count']),
147+ categories: jsonStrList(j['categories']),
148+ category: jsonStr(j['category']),
149+ deadFeeds: jsonList(j['dead_feeds'], Feed.fromJson),
150+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
151+ );
152+}
153+
154+class TrendingResponse {
155+ const TrendingResponse({required this.trending, required this.scope, required this.pagination});
156+
157+ final List<TrendingItem> trending;
158+ final String scope;
159+ final Pagination pagination;
160+
161+ factory TrendingResponse.fromJson(Map<String, dynamic> j) => TrendingResponse(
162+ trending: jsonList(j['trending'], TrendingItem.fromJson),
163+ scope: jsonStr(j['scope']),
164+ pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
165+ );
166+}
167+
168+class LibraryResponse {
169+ const LibraryResponse({
170+ required this.articles,
171+ required this.annotations,
172+ required this.likedPage,
173+ required this.annotPage,
174+ });
175+
176+ final List<Article> articles;
177+ final List<Annotation> annotations;
178+ final Pagination likedPage;
179+ final Pagination annotPage;
180+
181+ factory LibraryResponse.fromJson(Map<String, dynamic> j) => LibraryResponse(
182+ articles: jsonList(j['articles'], Article.fromJson),
183+ annotations: jsonList(j['annotations'], Annotation.fromJson),
184+ likedPage: Pagination.fromJson(j['liked_page'] as Map<String, dynamic>?),
185+ annotPage: Pagination.fromJson(j['annot_page'] as Map<String, dynamic>?),
186+ );
187+}
188+
189+class ProfileResponse {
190+ const ProfileResponse({
191+ required this.profileUser,
192+ required this.subscriptions,
193+ required this.annotations,
194+ required this.subscriptionCount,
195+ required this.annotationCount,
196+ required this.userLanguages,
197+ required this.availableLanguages,
198+ required this.expandedView,
199+ required this.digestEnabled,
200+ });
201+
202+ final User profileUser;
203+ final List<Subscription> subscriptions;
204+ final List<Annotation> annotations;
205+ final int subscriptionCount;
206+ final int annotationCount;
207+ final List<String> userLanguages;
208+ final List<Language> availableLanguages;
209+ final bool expandedView;
210+ final bool digestEnabled;
211+
212+ factory ProfileResponse.fromJson(Map<String, dynamic> j) => ProfileResponse(
213+ profileUser: User.fromJson((j['profile_user'] as Map<String, dynamic>?) ?? const {}),
214+ subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
215+ annotations: jsonList(j['annotations'], Annotation.fromJson),
216+ subscriptionCount: jsonInt(j['subscription_count']),
217+ annotationCount: jsonInt(j['annotation_count']),
218+ userLanguages: jsonStrList(j['user_languages']),
219+ availableLanguages: jsonList(j['available_languages'], Language.fromJson),
220+ expandedView: jsonBool(j['expanded_view']),
221+ digestEnabled: jsonBool(j['digest_enabled']),
222+ );
223+}
224+
225+class PeopleRecsResponse {
226+ const PeopleRecsResponse({required this.followed, required this.discover});
227+
228+ final List<PersonRecommendation> followed;
229+ final List<PersonRecommendation> discover;
230+
231+ factory PeopleRecsResponse.fromJson(Map<String, dynamic> j) => PeopleRecsResponse(
232+ followed: jsonList(j['followed'], PersonRecommendation.fromJson),
233+ discover: jsonList(j['discover'], PersonRecommendation.fromJson),
234+ );
235+}
236+
237+class FeedRecsResponse {
238+ const FeedRecsResponse({required this.feeds, required this.subscriptionCount});
239+
240+ final List<FeedRecommendation> feeds;
241+ final int subscriptionCount;
242+
243+ factory FeedRecsResponse.fromJson(Map<String, dynamic> j) => FeedRecsResponse(
244+ feeds: jsonList(j['feeds'], FeedRecommendation.fromJson),
245+ subscriptionCount: jsonInt(j['subscription_count']),
246+ );
247+}
248+
249+class LikeResult {
250+ const LikeResult({required this.id, required this.liked, required this.likeCount});
251+
252+ final int id;
253+ final bool liked;
254+ final int likeCount;
255+
256+ factory LikeResult.fromJson(Map<String, dynamic> j) => LikeResult(
257+ id: jsonInt(j['id']),
258+ liked: jsonBool(j['liked']),
259+ likeCount: jsonInt(j['like_count']),
260+ );
261+}
added app/lib/src/api/session.dart +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+import 'dart:io';
2+
3+import 'package:http/http.dart' as http;
4+import 'package:shared_preferences/shared_preferences.dart';
5+
6+/// Cookie-backed session against the Glean API.
7+///
8+/// The Go server authenticates with a session cookie and guards unsafe methods
9+/// with a double-submit CSRF token: the value lives in the `glean_csrf` cookie
10+/// and must be echoed back in the `X-CSRF-Token` header (internal/server/
11+/// middleware.go). Dart's http package has no cookie jar, so this class is one
12+/// -- deliberately minimal, since the server sets only a handful of cookies on
13+/// a single origin.
14+class GleanSession {
15+ GleanSession({required this.baseUrl, http.Client? client})
16+ : _client = client ?? http.Client();
17+
18+ final String baseUrl;
19+ final http.Client _client;
20+ final Map<String, String> _cookies = {};
21+
22+ static const _prefsKey = 'glean_cookies';
23+ static const _csrfCookie = 'glean_csrf';
24+
25+ bool get hasSession => _cookies.isNotEmpty;
26+ String? get csrfToken => _cookies[_csrfCookie];
27+
28+ /// Cookies survive restarts so a signed-in user stays signed in. They are
29+ /// session credentials, so callers on mobile may prefer secure storage; this
30+ /// uses shared_preferences to keep the dependency surface small.
31+ Future<void> load() async {
32+ final prefs = await SharedPreferences.getInstance();
33+ final raw = prefs.getStringList(_prefsKey) ?? const [];
34+ for (final entry in raw) {
35+ final i = entry.indexOf('=');
36+ if (i > 0) _cookies[entry.substring(0, i)] = entry.substring(i + 1);
37+ }
38+ }
39+
40+ Future<void> _persist() async {
41+ final prefs = await SharedPreferences.getInstance();
42+ await prefs.setStringList(
43+ _prefsKey,
44+ _cookies.entries.map((e) => '${e.key}=${e.value}').toList(),
45+ );
46+ }
47+
48+ Future<void> clear() async {
49+ _cookies.clear();
50+ await _persist();
51+ }
52+
53+ /// Adopt cookies captured elsewhere -- notably the webview that completes the
54+ /// ATProto OAuth redirect, which is where the session cookie is first issued.
55+ Future<void> adopt(Map<String, String> cookies) async {
56+ _cookies.addAll(cookies);
57+ await _persist();
58+ }
59+
60+ Map<String, String> _headers({bool unsafe = false, Map<String, String>? extra}) {
61+ final h = <String, String>{'Accept': 'application/json'};
62+ if (_cookies.isNotEmpty) {
63+ h['Cookie'] = _cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
64+ }
65+ // Only unsafe methods are CSRF-checked; sending it on GET is harmless but
66+ // noisy, and mirrors what the web client does.
67+ if (unsafe && csrfToken != null) h['X-CSRF-Token'] = csrfToken!;
68+ if (extra != null) h.addAll(extra);
69+ return h;
70+ }
71+
72+ void _absorb(http.BaseResponse res) {
73+ final raw = res.headers['set-cookie'];
74+ if (raw == null) return;
75+ for (final c in _splitSetCookie(raw)) {
76+ try {
77+ final cookie = Cookie.fromSetCookieValue(c);
78+ // An expiry in the past is the server deleting the cookie (logout).
79+ final expired = cookie.expires != null && cookie.expires!.isBefore(DateTime.now());
80+ if (cookie.value.isEmpty || expired) {
81+ _cookies.remove(cookie.name);
82+ } else {
83+ _cookies[cookie.name] = cookie.value;
84+ }
85+ } on Exception {
86+ // A malformed Set-Cookie should not fail the request it rode in on.
87+ continue;
88+ }
89+ }
90+ _persist();
91+ }
92+
93+ /// Dart folds repeated Set-Cookie headers into one comma-joined string, but
94+ /// commas also appear inside `Expires=Wed, 21 Oct 2026 ...`. Split only on
95+ /// commas that begin a new `name=` pair.
96+ static List<String> _splitSetCookie(String header) {
97+ final parts = <String>[];
98+ var start = 0;
99+ for (var i = 0; i < header.length; i++) {
100+ if (header[i] != ',') continue;
101+ final rest = header.substring(i + 1);
102+ final eq = rest.indexOf('=');
103+ final semi = rest.indexOf(';');
104+ if (eq > 0 && (semi < 0 || eq < semi) && !rest.substring(0, eq).contains(' ')) {
105+ parts.add(header.substring(start, i).trim());
106+ start = i + 1;
107+ }
108+ }
109+ parts.add(header.substring(start).trim());
110+ return parts.where((p) => p.isNotEmpty).toList();
111+ }
112+
113+ Uri _uri(String path, [Map<String, dynamic>? query]) {
114+ final q = query?.entries
115+ .where((e) => e.value != null && '${e.value}'.isNotEmpty)
116+ .map((e) => MapEntry(e.key, '${e.value}'));
117+ return Uri.parse('$baseUrl$path').replace(
118+ queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
119+ );
120+ }
121+
122+ Future<http.Response> get(String path, [Map<String, dynamic>? query]) async {
123+ final res = await _client.get(_uri(path, query), headers: _headers());
124+ _absorb(res);
125+ return res;
126+ }
127+
128+ Future<http.Response> send(
129+ String method,
130+ String path, {
131+ Map<String, dynamic>? query,
132+ Map<String, String>? form,
133+ }) async {
134+ final req = http.Request(method, _uri(path, query))
135+ ..headers.addAll(_headers(
136+ unsafe: true,
137+ extra: form == null ? null : {'Content-Type': 'application/x-www-form-urlencoded'},
138+ ));
139+ if (form != null) req.bodyFields = form;
140+ final res = await http.Response.fromStream(await _client.send(req));
141+ _absorb(res);
142+ return res;
143+ }
144+
145+ void close() => _client.close();
146+}
new file mode 100644
@@ -0,0 +1,146 @@
1+import 'dart:io';
2+
3+import 'package:http/http.dart' as http;
4+import 'package:shared_preferences/shared_preferences.dart';
5+
6+/// Cookie-backed session against the Glean API.
7+///
8+/// The Go server authenticates with a session cookie and guards unsafe methods
9+/// with a double-submit CSRF token: the value lives in the `glean_csrf` cookie
10+/// and must be echoed back in the `X-CSRF-Token` header (internal/server/
11+/// middleware.go). Dart's http package has no cookie jar, so this class is one
12+/// -- deliberately minimal, since the server sets only a handful of cookies on
13+/// a single origin.
14+class GleanSession {
15+ GleanSession({required this.baseUrl, http.Client? client})
16+ : _client = client ?? http.Client();
17+
18+ final String baseUrl;
19+ final http.Client _client;
20+ final Map<String, String> _cookies = {};
21+
22+ static const _prefsKey = 'glean_cookies';
23+ static const _csrfCookie = 'glean_csrf';
24+
25+ bool get hasSession => _cookies.isNotEmpty;
26+ String? get csrfToken => _cookies[_csrfCookie];
27+
28+ /// Cookies survive restarts so a signed-in user stays signed in. They are
29+ /// session credentials, so callers on mobile may prefer secure storage; this
30+ /// uses shared_preferences to keep the dependency surface small.
31+ Future<void> load() async {
32+ final prefs = await SharedPreferences.getInstance();
33+ final raw = prefs.getStringList(_prefsKey) ?? const [];
34+ for (final entry in raw) {
35+ final i = entry.indexOf('=');
36+ if (i > 0) _cookies[entry.substring(0, i)] = entry.substring(i + 1);
37+ }
38+ }
39+
40+ Future<void> _persist() async {
41+ final prefs = await SharedPreferences.getInstance();
42+ await prefs.setStringList(
43+ _prefsKey,
44+ _cookies.entries.map((e) => '${e.key}=${e.value}').toList(),
45+ );
46+ }
47+
48+ Future<void> clear() async {
49+ _cookies.clear();
50+ await _persist();
51+ }
52+
53+ /// Adopt cookies captured elsewhere -- notably the webview that completes the
54+ /// ATProto OAuth redirect, which is where the session cookie is first issued.
55+ Future<void> adopt(Map<String, String> cookies) async {
56+ _cookies.addAll(cookies);
57+ await _persist();
58+ }
59+
60+ Map<String, String> _headers({bool unsafe = false, Map<String, String>? extra}) {
61+ final h = <String, String>{'Accept': 'application/json'};
62+ if (_cookies.isNotEmpty) {
63+ h['Cookie'] = _cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
64+ }
65+ // Only unsafe methods are CSRF-checked; sending it on GET is harmless but
66+ // noisy, and mirrors what the web client does.
67+ if (unsafe && csrfToken != null) h['X-CSRF-Token'] = csrfToken!;
68+ if (extra != null) h.addAll(extra);
69+ return h;
70+ }
71+
72+ void _absorb(http.BaseResponse res) {
73+ final raw = res.headers['set-cookie'];
74+ if (raw == null) return;
75+ for (final c in _splitSetCookie(raw)) {
76+ try {
77+ final cookie = Cookie.fromSetCookieValue(c);
78+ // An expiry in the past is the server deleting the cookie (logout).
79+ final expired = cookie.expires != null && cookie.expires!.isBefore(DateTime.now());
80+ if (cookie.value.isEmpty || expired) {
81+ _cookies.remove(cookie.name);
82+ } else {
83+ _cookies[cookie.name] = cookie.value;
84+ }
85+ } on Exception {
86+ // A malformed Set-Cookie should not fail the request it rode in on.
87+ continue;
88+ }
89+ }
90+ _persist();
91+ }
92+
93+ /// Dart folds repeated Set-Cookie headers into one comma-joined string, but
94+ /// commas also appear inside `Expires=Wed, 21 Oct 2026 ...`. Split only on
95+ /// commas that begin a new `name=` pair.
96+ static List<String> _splitSetCookie(String header) {
97+ final parts = <String>[];
98+ var start = 0;
99+ for (var i = 0; i < header.length; i++) {
100+ if (header[i] != ',') continue;
101+ final rest = header.substring(i + 1);
102+ final eq = rest.indexOf('=');
103+ final semi = rest.indexOf(';');
104+ if (eq > 0 && (semi < 0 || eq < semi) && !rest.substring(0, eq).contains(' ')) {
105+ parts.add(header.substring(start, i).trim());
106+ start = i + 1;
107+ }
108+ }
109+ parts.add(header.substring(start).trim());
110+ return parts.where((p) => p.isNotEmpty).toList();
111+ }
112+
113+ Uri _uri(String path, [Map<String, dynamic>? query]) {
114+ final q = query?.entries
115+ .where((e) => e.value != null && '${e.value}'.isNotEmpty)
116+ .map((e) => MapEntry(e.key, '${e.value}'));
117+ return Uri.parse('$baseUrl$path').replace(
118+ queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
119+ );
120+ }
121+
122+ Future<http.Response> get(String path, [Map<String, dynamic>? query]) async {
123+ final res = await _client.get(_uri(path, query), headers: _headers());
124+ _absorb(res);
125+ return res;
126+ }
127+
128+ Future<http.Response> send(
129+ String method,
130+ String path, {
131+ Map<String, dynamic>? query,
132+ Map<String, String>? form,
133+ }) async {
134+ final req = http.Request(method, _uri(path, query))
135+ ..headers.addAll(_headers(
136+ unsafe: true,
137+ extra: form == null ? null : {'Content-Type': 'application/x-www-form-urlencoded'},
138+ ));
139+ if (form != null) req.bodyFields = form;
140+ final res = await http.Response.fromStream(await _client.send(req));
141+ _absorb(res);
142+ return res;
143+ }
144+
145+ void close() => _client.close();
146+}
added app/lib/src/models/models.dart +410 -0
new file mode 100644
@@ -0,0 +1,410 @@
1+/// Dart mirrors of the wire types in internal/server/api.go.
2+///
3+/// Field names and nullability follow the Go structs exactly: a Go `*time.Time`
4+/// is a nullable DateTime here, and a `string` that the server coerces from
5+/// sql.NullString is a non-null String defaulting to "". Keeping the two in
6+/// step matters more than Dart-idiomatic naming, so the JSON keys are the
7+/// source of truth.
8+library;
9+
10+DateTime? _time(Object? v) =>
11+ v == null ? null : DateTime.tryParse(v as String)?.toLocal();
12+
13+String _str(Object? v) => (v as String?) ?? '';
14+int _int(Object? v) => (v as num?)?.toInt() ?? 0;
15+double _dbl(Object? v) => (v as num?)?.toDouble() ?? 0;
16+bool _bool(Object? v) => (v as bool?) ?? false;
17+
18+List<String> _strList(Object? v) =>
19+ (v as List?)?.map((e) => e as String).toList() ?? const [];
20+
21+List<T> _list<T>(Object? v, T Function(Map<String, dynamic>) f) =>
22+ (v as List?)?.map((e) => f(e as Map<String, dynamic>)).toList() ?? <T>[];
23+
24+class User {
25+ const User({
26+ required this.did,
27+ required this.handle,
28+ required this.displayName,
29+ required this.avatarUrl,
30+ });
31+
32+ final String did;
33+ final String handle;
34+ final String displayName;
35+ final String avatarUrl;
36+
37+ /// The handle is the stable identity; display name is optional decoration.
38+ String get label => displayName.isNotEmpty ? displayName : handle;
39+
40+ static const empty = User(did: '', handle: '', displayName: '', avatarUrl: '');
41+
42+ factory User.fromJson(Map<String, dynamic> j) => User(
43+ did: _str(j['did']),
44+ handle: _str(j['handle']),
45+ displayName: _str(j['display_name']),
46+ avatarUrl: _str(j['avatar_url']),
47+ );
48+}
49+
50+class Article {
51+ const Article({
52+ required this.id,
53+ required this.feedUrl,
54+ required this.feedTitle,
55+ required this.feedFaviconUrl,
56+ required this.title,
57+ required this.url,
58+ required this.author,
59+ required this.summary,
60+ required this.content,
61+ required this.fullContent,
62+ required this.published,
63+ required this.updated,
64+ required this.isRead,
65+ required this.likeCount,
66+ required this.hasLiked,
67+ });
68+
69+ final int id;
70+ final String feedUrl;
71+ final String feedTitle;
72+ final String feedFaviconUrl;
73+ final String title;
74+ final String url;
75+ final String author;
76+ final String summary;
77+ final String content;
78+ final String fullContent;
79+ final DateTime? published;
80+ final DateTime? updated;
81+ final bool isRead;
82+ final int likeCount;
83+ final bool hasLiked;
84+
85+ /// What the reader view shows, preferring scraped full text over the feed's
86+ /// own (often truncated) content.
87+ String get readable =>
88+ fullContent.isNotEmpty ? fullContent : (content.isNotEmpty ? content : summary);
89+
90+ factory Article.fromJson(Map<String, dynamic> j) => Article(
91+ id: _int(j['id']),
92+ feedUrl: _str(j['feed_url']),
93+ feedTitle: _str(j['feed_title']),
94+ feedFaviconUrl: _str(j['feed_favicon_url']),
95+ title: _str(j['title']),
96+ url: _str(j['url']),
97+ author: _str(j['author']),
98+ summary: _str(j['summary']),
99+ content: _str(j['content']),
100+ fullContent: _str(j['full_content']),
101+ published: _time(j['published']),
102+ updated: _time(j['updated']),
103+ isRead: _bool(j['is_read']),
104+ likeCount: _int(j['like_count']),
105+ hasLiked: _bool(j['has_liked']),
106+ );
107+
108+ Article copyWith({bool? isRead, bool? hasLiked, int? likeCount, String? fullContent}) =>
109+ Article(
110+ id: id,
111+ feedUrl: feedUrl,
112+ feedTitle: feedTitle,
113+ feedFaviconUrl: feedFaviconUrl,
114+ title: title,
115+ url: url,
116+ author: author,
117+ summary: summary,
118+ content: content,
119+ fullContent: fullContent ?? this.fullContent,
120+ published: published,
121+ updated: updated,
122+ isRead: isRead ?? this.isRead,
123+ likeCount: likeCount ?? this.likeCount,
124+ hasLiked: hasLiked ?? this.hasLiked,
125+ );
126+}
127+
128+class Feed {
129+ const Feed({
130+ required this.feedUrl,
131+ required this.title,
132+ required this.siteUrl,
133+ required this.description,
134+ required this.feedType,
135+ required this.faviconUrl,
136+ required this.subscriberCount,
137+ required this.errorCount,
138+ required this.lastError,
139+ required this.lastFetchedAt,
140+ });
141+
142+ final String feedUrl;
143+ final String title;
144+ final String siteUrl;
145+ final String description;
146+ final String feedType;
147+ final String faviconUrl;
148+ final int subscriberCount;
149+ final int errorCount;
150+ final String lastError;
151+ final DateTime? lastFetchedAt;
152+
153+ factory Feed.fromJson(Map<String, dynamic> j) => Feed(
154+ feedUrl: _str(j['feed_url']),
155+ title: _str(j['title']),
156+ siteUrl: _str(j['site_url']),
157+ description: _str(j['description']),
158+ feedType: _str(j['feed_type']),
159+ faviconUrl: _str(j['favicon_url']),
160+ subscriberCount: _int(j['subscriber_count']),
161+ errorCount: _int(j['error_count']),
162+ lastError: _str(j['last_error']),
163+ lastFetchedAt: _time(j['last_fetched_at']),
164+ );
165+}
166+
167+class Subscription {
168+ const Subscription({
169+ required this.id,
170+ required this.feedUrl,
171+ required this.feedTitle,
172+ required this.category,
173+ required this.addedAt,
174+ required this.unreadCount,
175+ required this.faviconUrl,
176+ });
177+
178+ final int id;
179+ final String feedUrl;
180+ final String feedTitle;
181+ final String category;
182+ final DateTime? addedAt;
183+ final int unreadCount;
184+ final String faviconUrl;
185+
186+ factory Subscription.fromJson(Map<String, dynamic> j) => Subscription(
187+ id: _int(j['id']),
188+ feedUrl: _str(j['feed_url']),
189+ feedTitle: _str(j['feed_title']),
190+ category: _str(j['category']),
191+ addedAt: _time(j['added_at']),
192+ unreadCount: _int(j['unread_count']),
193+ faviconUrl: _str(j['favicon_url']),
194+ );
195+}
196+
197+class Annotation {
198+ const Annotation({
199+ required this.id,
200+ required this.authorDid,
201+ required this.authorHandle,
202+ required this.feedUrl,
203+ required this.articleUrl,
204+ required this.articleId,
205+ required this.quote,
206+ required this.note,
207+ required this.tags,
208+ required this.rating,
209+ required this.createdAt,
210+ });
211+
212+ final int id;
213+ final String authorDid;
214+ final String authorHandle;
215+ final String feedUrl;
216+ final String articleUrl;
217+ final int? articleId;
218+ final String quote;
219+ final String note;
220+ final List<String> tags;
221+ final int? rating;
222+ final DateTime? createdAt;
223+
224+ factory Annotation.fromJson(Map<String, dynamic> j) => Annotation(
225+ id: _int(j['id']),
226+ authorDid: _str(j['author_did']),
227+ authorHandle: _str(j['author_handle']),
228+ feedUrl: _str(j['feed_url']),
229+ articleUrl: _str(j['article_url']),
230+ articleId: (j['article_id'] as num?)?.toInt(),
231+ quote: _str(j['quote']),
232+ note: _str(j['note']),
233+ tags: _strList(j['tags']),
234+ rating: (j['rating'] as num?)?.toInt(),
235+ createdAt: _time(j['created_at']),
236+ );
237+}
238+
239+class TrendingItem {
240+ const TrendingItem({
241+ required this.articleId,
242+ required this.title,
243+ required this.url,
244+ required this.author,
245+ required this.summary,
246+ required this.feedUrl,
247+ required this.feedTitle,
248+ required this.faviconUrl,
249+ required this.likeCount,
250+ required this.annotationCount,
251+ required this.hasLiked,
252+ });
253+
254+ final int articleId;
255+ final String title;
256+ final String url;
257+ final String author;
258+ final String summary;
259+ final String feedUrl;
260+ final String feedTitle;
261+ final String faviconUrl;
262+ final int likeCount;
263+ final int annotationCount;
264+ final bool hasLiked;
265+
266+ factory TrendingItem.fromJson(Map<String, dynamic> j) => TrendingItem(
267+ articleId: _int(j['article_id']),
268+ title: _str(j['title']),
269+ url: _str(j['url']),
270+ author: _str(j['author']),
271+ summary: _str(j['summary']),
272+ feedUrl: _str(j['feed_url']),
273+ feedTitle: _str(j['feed_title']),
274+ faviconUrl: _str(j['favicon_url']),
275+ likeCount: _int(j['like_count']),
276+ annotationCount: _int(j['annotation_count']),
277+ hasLiked: _bool(j['has_liked']),
278+ );
279+
280+ TrendingItem copyWith({bool? hasLiked, int? likeCount}) => TrendingItem(
281+ articleId: articleId,
282+ title: title,
283+ url: url,
284+ author: author,
285+ summary: summary,
286+ feedUrl: feedUrl,
287+ feedTitle: feedTitle,
288+ faviconUrl: faviconUrl,
289+ likeCount: likeCount ?? this.likeCount,
290+ annotationCount: annotationCount,
291+ hasLiked: hasLiked ?? this.hasLiked,
292+ );
293+}
294+
295+class FeedRecommendation {
296+ const FeedRecommendation({
297+ required this.feedUrl,
298+ required this.title,
299+ required this.siteUrl,
300+ required this.description,
301+ required this.subscriberCount,
302+ required this.faviconUrl,
303+ required this.score,
304+ });
305+
306+ final String feedUrl;
307+ final String title;
308+ final String siteUrl;
309+ final String description;
310+ final int subscriberCount;
311+ final String faviconUrl;
312+ final double score;
313+
314+ factory FeedRecommendation.fromJson(Map<String, dynamic> j) => FeedRecommendation(
315+ feedUrl: _str(j['feed_url']),
316+ title: _str(j['title']),
317+ siteUrl: _str(j['site_url']),
318+ description: _str(j['description']),
319+ subscriberCount: _int(j['subscriber_count']),
320+ faviconUrl: _str(j['favicon_url']),
321+ score: _dbl(j['score']),
322+ );
323+}
324+
325+class PersonRecommendation {
326+ const PersonRecommendation({
327+ required this.did,
328+ required this.handle,
329+ required this.displayName,
330+ required this.avatarUrl,
331+ required this.commonFeeds,
332+ required this.commonLikes,
333+ required this.commonTags,
334+ required this.isFollowed,
335+ required this.score,
336+ });
337+
338+ final String did;
339+ final String handle;
340+ final String displayName;
341+ final String avatarUrl;
342+ final int commonFeeds;
343+ final int commonLikes;
344+ final int commonTags;
345+ final bool isFollowed;
346+ final double score;
347+
348+ String get label => displayName.isNotEmpty ? displayName : handle;
349+
350+ factory PersonRecommendation.fromJson(Map<String, dynamic> j) => PersonRecommendation(
351+ did: _str(j['did']),
352+ handle: _str(j['handle']),
353+ displayName: _str(j['display_name']),
354+ avatarUrl: _str(j['avatar_url']),
355+ commonFeeds: _int(j['common_feeds']),
356+ commonLikes: _int(j['common_likes']),
357+ commonTags: _int(j['common_tags']),
358+ isFollowed: _bool(j['is_followed']),
359+ score: _dbl(j['score']),
360+ );
361+}
362+
363+class Pagination {
364+ const Pagination({
365+ required this.page,
366+ required this.pageSize,
367+ required this.hasPrev,
368+ required this.hasNext,
369+ required this.prevPage,
370+ required this.nextPage,
371+ });
372+
373+ final int page;
374+ final int pageSize;
375+ final bool hasPrev;
376+ final bool hasNext;
377+ final int prevPage;
378+ final int nextPage;
379+
380+ static const first =
381+ Pagination(page: 1, pageSize: 25, hasPrev: false, hasNext: false, prevPage: 0, nextPage: 0);
382+
383+ factory Pagination.fromJson(Map<String, dynamic>? j) => j == null
384+ ? first
385+ : Pagination(
386+ page: _int(j['page']),
387+ pageSize: _int(j['page_size']),
388+ hasPrev: _bool(j['has_prev']),
389+ hasNext: _bool(j['has_next']),
390+ prevPage: _int(j['prev_page']),
391+ nextPage: _int(j['next_page']),
392+ );
393+}
394+
395+class Language {
396+ const Language({required this.code, required this.name});
397+
398+ final String code;
399+ final String name;
400+
401+ factory Language.fromJson(Map<String, dynamic> j) =>
402+ Language(code: _str(j['code']), name: _str(j['name']));
403+}
404+
405+/// Helpers re-exported for the response envelopes in api/responses.dart.
406+List<T> jsonList<T>(Object? v, T Function(Map<String, dynamic>) f) => _list(v, f);
407+List<String> jsonStrList(Object? v) => _strList(v);
408+String jsonStr(Object? v) => _str(v);
409+int jsonInt(Object? v) => _int(v);
410+bool jsonBool(Object? v) => _bool(v);
new file mode 100644
@@ -0,0 +1,410 @@
1+/// Dart mirrors of the wire types in internal/server/api.go.
2+///
3+/// Field names and nullability follow the Go structs exactly: a Go `*time.Time`
4+/// is a nullable DateTime here, and a `string` that the server coerces from
5+/// sql.NullString is a non-null String defaulting to "". Keeping the two in
6+/// step matters more than Dart-idiomatic naming, so the JSON keys are the
7+/// source of truth.
8+library;
9+
10+DateTime? _time(Object? v) =>
11+ v == null ? null : DateTime.tryParse(v as String)?.toLocal();
12+
13+String _str(Object? v) => (v as String?) ?? '';
14+int _int(Object? v) => (v as num?)?.toInt() ?? 0;
15+double _dbl(Object? v) => (v as num?)?.toDouble() ?? 0;
16+bool _bool(Object? v) => (v as bool?) ?? false;
17+
18+List<String> _strList(Object? v) =>
19+ (v as List?)?.map((e) => e as String).toList() ?? const [];
20+
21+List<T> _list<T>(Object? v, T Function(Map<String, dynamic>) f) =>
22+ (v as List?)?.map((e) => f(e as Map<String, dynamic>)).toList() ?? <T>[];
23+
24+class User {
25+ const User({
26+ required this.did,
27+ required this.handle,
28+ required this.displayName,
29+ required this.avatarUrl,
30+ });
31+
32+ final String did;
33+ final String handle;
34+ final String displayName;
35+ final String avatarUrl;
36+
37+ /// The handle is the stable identity; display name is optional decoration.
38+ String get label => displayName.isNotEmpty ? displayName : handle;
39+
40+ static const empty = User(did: '', handle: '', displayName: '', avatarUrl: '');
41+
42+ factory User.fromJson(Map<String, dynamic> j) => User(
43+ did: _str(j['did']),
44+ handle: _str(j['handle']),
45+ displayName: _str(j['display_name']),
46+ avatarUrl: _str(j['avatar_url']),
47+ );
48+}
49+
50+class Article {
51+ const Article({
52+ required this.id,
53+ required this.feedUrl,
54+ required this.feedTitle,
55+ required this.feedFaviconUrl,
56+ required this.title,
57+ required this.url,
58+ required this.author,
59+ required this.summary,
60+ required this.content,
61+ required this.fullContent,
62+ required this.published,
63+ required this.updated,
64+ required this.isRead,
65+ required this.likeCount,
66+ required this.hasLiked,
67+ });
68+
69+ final int id;
70+ final String feedUrl;
71+ final String feedTitle;
72+ final String feedFaviconUrl;
73+ final String title;
74+ final String url;
75+ final String author;
76+ final String summary;
77+ final String content;
78+ final String fullContent;
79+ final DateTime? published;
80+ final DateTime? updated;
81+ final bool isRead;
82+ final int likeCount;
83+ final bool hasLiked;
84+
85+ /// What the reader view shows, preferring scraped full text over the feed's
86+ /// own (often truncated) content.
87+ String get readable =>
88+ fullContent.isNotEmpty ? fullContent : (content.isNotEmpty ? content : summary);
89+
90+ factory Article.fromJson(Map<String, dynamic> j) => Article(
91+ id: _int(j['id']),
92+ feedUrl: _str(j['feed_url']),
93+ feedTitle: _str(j['feed_title']),
94+ feedFaviconUrl: _str(j['feed_favicon_url']),
95+ title: _str(j['title']),
96+ url: _str(j['url']),
97+ author: _str(j['author']),
98+ summary: _str(j['summary']),
99+ content: _str(j['content']),
100+ fullContent: _str(j['full_content']),
101+ published: _time(j['published']),
102+ updated: _time(j['updated']),
103+ isRead: _bool(j['is_read']),
104+ likeCount: _int(j['like_count']),
105+ hasLiked: _bool(j['has_liked']),
106+ );
107+
108+ Article copyWith({bool? isRead, bool? hasLiked, int? likeCount, String? fullContent}) =>
109+ Article(
110+ id: id,
111+ feedUrl: feedUrl,
112+ feedTitle: feedTitle,
113+ feedFaviconUrl: feedFaviconUrl,
114+ title: title,
115+ url: url,
116+ author: author,
117+ summary: summary,
118+ content: content,
119+ fullContent: fullContent ?? this.fullContent,
120+ published: published,
121+ updated: updated,
122+ isRead: isRead ?? this.isRead,
123+ likeCount: likeCount ?? this.likeCount,
124+ hasLiked: hasLiked ?? this.hasLiked,
125+ );
126+}
127+
128+class Feed {
129+ const Feed({
130+ required this.feedUrl,
131+ required this.title,
132+ required this.siteUrl,
133+ required this.description,
134+ required this.feedType,
135+ required this.faviconUrl,
136+ required this.subscriberCount,
137+ required this.errorCount,
138+ required this.lastError,
139+ required this.lastFetchedAt,
140+ });
141+
142+ final String feedUrl;
143+ final String title;
144+ final String siteUrl;
145+ final String description;
146+ final String feedType;
147+ final String faviconUrl;
148+ final int subscriberCount;
149+ final int errorCount;
150+ final String lastError;
151+ final DateTime? lastFetchedAt;
152+
153+ factory Feed.fromJson(Map<String, dynamic> j) => Feed(
154+ feedUrl: _str(j['feed_url']),
155+ title: _str(j['title']),
156+ siteUrl: _str(j['site_url']),
157+ description: _str(j['description']),
158+ feedType: _str(j['feed_type']),
159+ faviconUrl: _str(j['favicon_url']),
160+ subscriberCount: _int(j['subscriber_count']),
161+ errorCount: _int(j['error_count']),
162+ lastError: _str(j['last_error']),
163+ lastFetchedAt: _time(j['last_fetched_at']),
164+ );
165+}
166+
167+class Subscription {
168+ const Subscription({
169+ required this.id,
170+ required this.feedUrl,
171+ required this.feedTitle,
172+ required this.category,
173+ required this.addedAt,
174+ required this.unreadCount,
175+ required this.faviconUrl,
176+ });
177+
178+ final int id;
179+ final String feedUrl;
180+ final String feedTitle;
181+ final String category;
182+ final DateTime? addedAt;
183+ final int unreadCount;
184+ final String faviconUrl;
185+
186+ factory Subscription.fromJson(Map<String, dynamic> j) => Subscription(
187+ id: _int(j['id']),
188+ feedUrl: _str(j['feed_url']),
189+ feedTitle: _str(j['feed_title']),
190+ category: _str(j['category']),
191+ addedAt: _time(j['added_at']),
192+ unreadCount: _int(j['unread_count']),
193+ faviconUrl: _str(j['favicon_url']),
194+ );
195+}
196+
197+class Annotation {
198+ const Annotation({
199+ required this.id,
200+ required this.authorDid,
201+ required this.authorHandle,
202+ required this.feedUrl,
203+ required this.articleUrl,
204+ required this.articleId,
205+ required this.quote,
206+ required this.note,
207+ required this.tags,
208+ required this.rating,
209+ required this.createdAt,
210+ });
211+
212+ final int id;
213+ final String authorDid;
214+ final String authorHandle;
215+ final String feedUrl;
216+ final String articleUrl;
217+ final int? articleId;
218+ final String quote;
219+ final String note;
220+ final List<String> tags;
221+ final int? rating;
222+ final DateTime? createdAt;
223+
224+ factory Annotation.fromJson(Map<String, dynamic> j) => Annotation(
225+ id: _int(j['id']),
226+ authorDid: _str(j['author_did']),
227+ authorHandle: _str(j['author_handle']),
228+ feedUrl: _str(j['feed_url']),
229+ articleUrl: _str(j['article_url']),
230+ articleId: (j['article_id'] as num?)?.toInt(),
231+ quote: _str(j['quote']),
232+ note: _str(j['note']),
233+ tags: _strList(j['tags']),
234+ rating: (j['rating'] as num?)?.toInt(),
235+ createdAt: _time(j['created_at']),
236+ );
237+}
238+
239+class TrendingItem {
240+ const TrendingItem({
241+ required this.articleId,
242+ required this.title,
243+ required this.url,
244+ required this.author,
245+ required this.summary,
246+ required this.feedUrl,
247+ required this.feedTitle,
248+ required this.faviconUrl,
249+ required this.likeCount,
250+ required this.annotationCount,
251+ required this.hasLiked,
252+ });
253+
254+ final int articleId;
255+ final String title;
256+ final String url;
257+ final String author;
258+ final String summary;
259+ final String feedUrl;
260+ final String feedTitle;
261+ final String faviconUrl;
262+ final int likeCount;
263+ final int annotationCount;
264+ final bool hasLiked;
265+
266+ factory TrendingItem.fromJson(Map<String, dynamic> j) => TrendingItem(
267+ articleId: _int(j['article_id']),
268+ title: _str(j['title']),
269+ url: _str(j['url']),
270+ author: _str(j['author']),
271+ summary: _str(j['summary']),
272+ feedUrl: _str(j['feed_url']),
273+ feedTitle: _str(j['feed_title']),
274+ faviconUrl: _str(j['favicon_url']),
275+ likeCount: _int(j['like_count']),
276+ annotationCount: _int(j['annotation_count']),
277+ hasLiked: _bool(j['has_liked']),
278+ );
279+
280+ TrendingItem copyWith({bool? hasLiked, int? likeCount}) => TrendingItem(
281+ articleId: articleId,
282+ title: title,
283+ url: url,
284+ author: author,
285+ summary: summary,
286+ feedUrl: feedUrl,
287+ feedTitle: feedTitle,
288+ faviconUrl: faviconUrl,
289+ likeCount: likeCount ?? this.likeCount,
290+ annotationCount: annotationCount,
291+ hasLiked: hasLiked ?? this.hasLiked,
292+ );
293+}
294+
295+class FeedRecommendation {
296+ const FeedRecommendation({
297+ required this.feedUrl,
298+ required this.title,
299+ required this.siteUrl,
300+ required this.description,
301+ required this.subscriberCount,
302+ required this.faviconUrl,
303+ required this.score,
304+ });
305+
306+ final String feedUrl;
307+ final String title;
308+ final String siteUrl;
309+ final String description;
310+ final int subscriberCount;
311+ final String faviconUrl;
312+ final double score;
313+
314+ factory FeedRecommendation.fromJson(Map<String, dynamic> j) => FeedRecommendation(
315+ feedUrl: _str(j['feed_url']),
316+ title: _str(j['title']),
317+ siteUrl: _str(j['site_url']),
318+ description: _str(j['description']),
319+ subscriberCount: _int(j['subscriber_count']),
320+ faviconUrl: _str(j['favicon_url']),
321+ score: _dbl(j['score']),
322+ );
323+}
324+
325+class PersonRecommendation {
326+ const PersonRecommendation({
327+ required this.did,
328+ required this.handle,
329+ required this.displayName,
330+ required this.avatarUrl,
331+ required this.commonFeeds,
332+ required this.commonLikes,
333+ required this.commonTags,
334+ required this.isFollowed,
335+ required this.score,
336+ });
337+
338+ final String did;
339+ final String handle;
340+ final String displayName;
341+ final String avatarUrl;
342+ final int commonFeeds;
343+ final int commonLikes;
344+ final int commonTags;
345+ final bool isFollowed;
346+ final double score;
347+
348+ String get label => displayName.isNotEmpty ? displayName : handle;
349+
350+ factory PersonRecommendation.fromJson(Map<String, dynamic> j) => PersonRecommendation(
351+ did: _str(j['did']),
352+ handle: _str(j['handle']),
353+ displayName: _str(j['display_name']),
354+ avatarUrl: _str(j['avatar_url']),
355+ commonFeeds: _int(j['common_feeds']),
356+ commonLikes: _int(j['common_likes']),
357+ commonTags: _int(j['common_tags']),
358+ isFollowed: _bool(j['is_followed']),
359+ score: _dbl(j['score']),
360+ );
361+}
362+
363+class Pagination {
364+ const Pagination({
365+ required this.page,
366+ required this.pageSize,
367+ required this.hasPrev,
368+ required this.hasNext,
369+ required this.prevPage,
370+ required this.nextPage,
371+ });
372+
373+ final int page;
374+ final int pageSize;
375+ final bool hasPrev;
376+ final bool hasNext;
377+ final int prevPage;
378+ final int nextPage;
379+
380+ static const first =
381+ Pagination(page: 1, pageSize: 25, hasPrev: false, hasNext: false, prevPage: 0, nextPage: 0);
382+
383+ factory Pagination.fromJson(Map<String, dynamic>? j) => j == null
384+ ? first
385+ : Pagination(
386+ page: _int(j['page']),
387+ pageSize: _int(j['page_size']),
388+ hasPrev: _bool(j['has_prev']),
389+ hasNext: _bool(j['has_next']),
390+ prevPage: _int(j['prev_page']),
391+ nextPage: _int(j['next_page']),
392+ );
393+}
394+
395+class Language {
396+ const Language({required this.code, required this.name});
397+
398+ final String code;
399+ final String name;
400+
401+ factory Language.fromJson(Map<String, dynamic> j) =>
402+ Language(code: _str(j['code']), name: _str(j['name']));
403+}
404+
405+/// Helpers re-exported for the response envelopes in api/responses.dart.
406+List<T> jsonList<T>(Object? v, T Function(Map<String, dynamic>) f) => _list(v, f);
407+List<String> jsonStrList(Object? v) => _strList(v);
408+String jsonStr(Object? v) => _str(v);
409+int jsonInt(Object? v) => _int(v);
410+bool jsonBool(Object? v) => _bool(v);
added app/linux/.gitignore +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+flutter/ephemeral
new file mode 100644
@@ -0,0 +1 @@
1+flutter/ephemeral
added app/linux/CMakeLists.txt +128 -0
new file mode 100644
@@ -0,0 +1,128 @@
1+# Project-level configuration.
2+cmake_minimum_required(VERSION 3.13)
3+project(runner LANGUAGES CXX)
4+
5+# The name of the executable created for the application. Change this to change
6+# the on-disk name of your application.
7+set(BINARY_NAME "glean_app")
8+# The unique GTK application identifier for this application. See:
9+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
10+set(APPLICATION_ID "fr.rbrt.glean.glean_app")
11+
12+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
13+# versions of CMake.
14+cmake_policy(SET CMP0063 NEW)
15+
16+# Load bundled libraries from the lib/ directory relative to the binary.
17+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
18+
19+# Root filesystem for cross-building.
20+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
21+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
22+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
23+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
24+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
25+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
26+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
27+endif()
28+
29+# Define build configuration options.
30+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
31+ set(CMAKE_BUILD_TYPE "Debug" CACHE
32+ STRING "Flutter build mode" FORCE)
33+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
34+ "Debug" "Profile" "Release")
35+endif()
36+
37+# Compilation settings that should be applied to most targets.
38+#
39+# Be cautious about adding new options here, as plugins use this function by
40+# default. In most cases, you should add new options to specific targets instead
41+# of modifying this function.
42+function(APPLY_STANDARD_SETTINGS TARGET)
43+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
44+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
45+ target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
46+ target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
47+endfunction()
48+
49+# Flutter library and tool build rules.
50+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
51+add_subdirectory(${FLUTTER_MANAGED_DIR})
52+
53+# System-level dependencies.
54+find_package(PkgConfig REQUIRED)
55+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
56+
57+# Application build; see runner/CMakeLists.txt.
58+add_subdirectory("runner")
59+
60+# Run the Flutter tool portions of the build. This must not be removed.
61+add_dependencies(${BINARY_NAME} flutter_assemble)
62+
63+# Only the install-generated bundle's copy of the executable will launch
64+# correctly, since the resources must in the right relative locations. To avoid
65+# people trying to run the unbundled copy, put it in a subdirectory instead of
66+# the default top-level location.
67+set_target_properties(${BINARY_NAME}
68+ PROPERTIES
69+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
70+)
71+
72+
73+# Generated plugin build rules, which manage building the plugins and adding
74+# them to the application.
75+include(flutter/generated_plugins.cmake)
76+
77+
78+# === Installation ===
79+# By default, "installing" just makes a relocatable bundle in the build
80+# directory.
81+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
82+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
83+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
84+endif()
85+
86+# Start with a clean build bundle directory every time.
87+install(CODE "
88+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
89+ " COMPONENT Runtime)
90+
91+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
92+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
93+
94+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
95+ COMPONENT Runtime)
96+
97+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
98+ COMPONENT Runtime)
99+
100+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
101+ COMPONENT Runtime)
102+
103+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
104+ install(FILES "${bundled_library}"
105+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
106+ COMPONENT Runtime)
107+endforeach(bundled_library)
108+
109+# Copy the native assets provided by the build.dart from all packages.
110+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
111+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
112+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
113+ COMPONENT Runtime)
114+
115+# Fully re-copy the assets directory on each build to avoid having stale files
116+# from a previous install.
117+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
118+install(CODE "
119+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
120+ " COMPONENT Runtime)
121+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
122+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
123+
124+# Install the AOT library on non-Debug builds only.
125+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
126+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
127+ COMPONENT Runtime)
128+endif()
new file mode 100644
@@ -0,0 +1,128 @@
1+# Project-level configuration.
2+cmake_minimum_required(VERSION 3.13)
3+project(runner LANGUAGES CXX)
4+
5+# The name of the executable created for the application. Change this to change
6+# the on-disk name of your application.
7+set(BINARY_NAME "glean_app")
8+# The unique GTK application identifier for this application. See:
9+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
10+set(APPLICATION_ID "fr.rbrt.glean.glean_app")
11+
12+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
13+# versions of CMake.
14+cmake_policy(SET CMP0063 NEW)
15+
16+# Load bundled libraries from the lib/ directory relative to the binary.
17+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
18+
19+# Root filesystem for cross-building.
20+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
21+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
22+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
23+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
24+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
25+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
26+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
27+endif()
28+
29+# Define build configuration options.
30+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
31+ set(CMAKE_BUILD_TYPE "Debug" CACHE
32+ STRING "Flutter build mode" FORCE)
33+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
34+ "Debug" "Profile" "Release")
35+endif()
36+
37+# Compilation settings that should be applied to most targets.
38+#
39+# Be cautious about adding new options here, as plugins use this function by
40+# default. In most cases, you should add new options to specific targets instead
41+# of modifying this function.
42+function(APPLY_STANDARD_SETTINGS TARGET)
43+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
44+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
45+ target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
46+ target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
47+endfunction()
48+
49+# Flutter library and tool build rules.
50+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
51+add_subdirectory(${FLUTTER_MANAGED_DIR})
52+
53+# System-level dependencies.
54+find_package(PkgConfig REQUIRED)
55+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
56+
57+# Application build; see runner/CMakeLists.txt.
58+add_subdirectory("runner")
59+
60+# Run the Flutter tool portions of the build. This must not be removed.
61+add_dependencies(${BINARY_NAME} flutter_assemble)
62+
63+# Only the install-generated bundle's copy of the executable will launch
64+# correctly, since the resources must in the right relative locations. To avoid
65+# people trying to run the unbundled copy, put it in a subdirectory instead of
66+# the default top-level location.
67+set_target_properties(${BINARY_NAME}
68+ PROPERTIES
69+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
70+)
71+
72+
73+# Generated plugin build rules, which manage building the plugins and adding
74+# them to the application.
75+include(flutter/generated_plugins.cmake)
76+
77+
78+# === Installation ===
79+# By default, "installing" just makes a relocatable bundle in the build
80+# directory.
81+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
82+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
83+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
84+endif()
85+
86+# Start with a clean build bundle directory every time.
87+install(CODE "
88+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
89+ " COMPONENT Runtime)
90+
91+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
92+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
93+
94+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
95+ COMPONENT Runtime)
96+
97+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
98+ COMPONENT Runtime)
99+
100+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
101+ COMPONENT Runtime)
102+
103+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
104+ install(FILES "${bundled_library}"
105+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
106+ COMPONENT Runtime)
107+endforeach(bundled_library)
108+
109+# Copy the native assets provided by the build.dart from all packages.
110+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
111+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
112+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
113+ COMPONENT Runtime)
114+
115+# Fully re-copy the assets directory on each build to avoid having stale files
116+# from a previous install.
117+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
118+install(CODE "
119+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
120+ " COMPONENT Runtime)
121+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
122+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
123+
124+# Install the AOT library on non-Debug builds only.
125+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
126+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
127+ COMPONENT Runtime)
128+endif()
added app/linux/flutter/CMakeLists.txt +88 -0
new file mode 100644
@@ -0,0 +1,88 @@
1+# This file controls Flutter-level build steps. It should not be edited.
2+cmake_minimum_required(VERSION 3.10)
3+
4+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
5+
6+# Configuration provided via flutter tool.
7+include(${EPHEMERAL_DIR}/generated_config.cmake)
8+
9+# TODO: Move the rest of this into files in ephemeral. See
10+# https://github.com/flutter/flutter/issues/57146.
11+
12+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
13+# which isn't available in 3.10.
14+function(list_prepend LIST_NAME PREFIX)
15+ set(NEW_LIST "")
16+ foreach(element ${${LIST_NAME}})
17+ list(APPEND NEW_LIST "${PREFIX}${element}")
18+ endforeach(element)
19+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
20+endfunction()
21+
22+# === Flutter Library ===
23+# System-level dependencies.
24+find_package(PkgConfig REQUIRED)
25+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
26+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
27+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
28+
29+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
30+
31+# Published to parent scope for install step.
32+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
33+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
34+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
35+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
36+
37+list(APPEND FLUTTER_LIBRARY_HEADERS
38+ "fl_basic_message_channel.h"
39+ "fl_binary_codec.h"
40+ "fl_binary_messenger.h"
41+ "fl_dart_project.h"
42+ "fl_engine.h"
43+ "fl_json_message_codec.h"
44+ "fl_json_method_codec.h"
45+ "fl_message_codec.h"
46+ "fl_method_call.h"
47+ "fl_method_channel.h"
48+ "fl_method_codec.h"
49+ "fl_method_response.h"
50+ "fl_plugin_registrar.h"
51+ "fl_plugin_registry.h"
52+ "fl_standard_message_codec.h"
53+ "fl_standard_method_codec.h"
54+ "fl_string_codec.h"
55+ "fl_value.h"
56+ "fl_view.h"
57+ "flutter_linux.h"
58+)
59+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
60+add_library(flutter INTERFACE)
61+target_include_directories(flutter INTERFACE
62+ "${EPHEMERAL_DIR}"
63+)
64+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
65+target_link_libraries(flutter INTERFACE
66+ PkgConfig::GTK
67+ PkgConfig::GLIB
68+ PkgConfig::GIO
69+)
70+add_dependencies(flutter flutter_assemble)
71+
72+# === Flutter tool backend ===
73+# _phony_ is a non-existent file to force this command to run every time,
74+# since currently there's no way to get a full input/output list from the
75+# flutter tool.
76+add_custom_command(
77+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
78+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
79+ COMMAND ${CMAKE_COMMAND} -E env
80+ ${FLUTTER_TOOL_ENVIRONMENT}
81+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
82+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
83+ VERBATIM
84+)
85+add_custom_target(flutter_assemble DEPENDS
86+ "${FLUTTER_LIBRARY}"
87+ ${FLUTTER_LIBRARY_HEADERS}
88+)
new file mode 100644
@@ -0,0 +1,88 @@
1+# This file controls Flutter-level build steps. It should not be edited.
2+cmake_minimum_required(VERSION 3.10)
3+
4+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
5+
6+# Configuration provided via flutter tool.
7+include(${EPHEMERAL_DIR}/generated_config.cmake)
8+
9+# TODO: Move the rest of this into files in ephemeral. See
10+# https://github.com/flutter/flutter/issues/57146.
11+
12+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
13+# which isn't available in 3.10.
14+function(list_prepend LIST_NAME PREFIX)
15+ set(NEW_LIST "")
16+ foreach(element ${${LIST_NAME}})
17+ list(APPEND NEW_LIST "${PREFIX}${element}")
18+ endforeach(element)
19+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
20+endfunction()
21+
22+# === Flutter Library ===
23+# System-level dependencies.
24+find_package(PkgConfig REQUIRED)
25+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
26+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
27+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
28+
29+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
30+
31+# Published to parent scope for install step.
32+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
33+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
34+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
35+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
36+
37+list(APPEND FLUTTER_LIBRARY_HEADERS
38+ "fl_basic_message_channel.h"
39+ "fl_binary_codec.h"
40+ "fl_binary_messenger.h"
41+ "fl_dart_project.h"
42+ "fl_engine.h"
43+ "fl_json_message_codec.h"
44+ "fl_json_method_codec.h"
45+ "fl_message_codec.h"
46+ "fl_method_call.h"
47+ "fl_method_channel.h"
48+ "fl_method_codec.h"
49+ "fl_method_response.h"
50+ "fl_plugin_registrar.h"
51+ "fl_plugin_registry.h"
52+ "fl_standard_message_codec.h"
53+ "fl_standard_method_codec.h"
54+ "fl_string_codec.h"
55+ "fl_value.h"
56+ "fl_view.h"
57+ "flutter_linux.h"
58+)
59+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
60+add_library(flutter INTERFACE)
61+target_include_directories(flutter INTERFACE
62+ "${EPHEMERAL_DIR}"
63+)
64+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
65+target_link_libraries(flutter INTERFACE
66+ PkgConfig::GTK
67+ PkgConfig::GLIB
68+ PkgConfig::GIO
69+)
70+add_dependencies(flutter flutter_assemble)
71+
72+# === Flutter tool backend ===
73+# _phony_ is a non-existent file to force this command to run every time,
74+# since currently there's no way to get a full input/output list from the
75+# flutter tool.
76+add_custom_command(
77+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
78+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
79+ COMMAND ${CMAKE_COMMAND} -E env
80+ ${FLUTTER_TOOL_ENVIRONMENT}
81+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
82+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
83+ VERBATIM
84+)
85+add_custom_target(flutter_assemble DEPENDS
86+ "${FLUTTER_LIBRARY}"
87+ ${FLUTTER_LIBRARY_HEADERS}
88+)
added app/linux/flutter/generated_plugin_registrant.cc +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+//
2+// Generated file. Do not edit.
3+//
4+
5+// clang-format off
6+
7+#include "generated_plugin_registrant.h"
8+
9+#include <url_launcher_linux/url_launcher_plugin.h>
10+
11+void fl_register_plugins(FlPluginRegistry* registry) {
12+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
13+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
14+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
15+}
new file mode 100644
@@ -0,0 +1,15 @@
1+//
2+// Generated file. Do not edit.
3+//
4+
5+// clang-format off
6+
7+#include "generated_plugin_registrant.h"
8+
9+#include <url_launcher_linux/url_launcher_plugin.h>
10+
11+void fl_register_plugins(FlPluginRegistry* registry) {
12+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
13+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
14+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
15+}
added app/linux/flutter/generated_plugin_registrant.h +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+//
2+// Generated file. Do not edit.
3+//
4+
5+// clang-format off
6+
7+#ifndef GENERATED_PLUGIN_REGISTRANT_
8+#define GENERATED_PLUGIN_REGISTRANT_
9+
10+#include <flutter_linux/flutter_linux.h>
11+
12+// Registers Flutter plugins.
13+void fl_register_plugins(FlPluginRegistry* registry);
14+
15+#endif // GENERATED_PLUGIN_REGISTRANT_
new file mode 100644
@@ -0,0 +1,15 @@
1+//
2+// Generated file. Do not edit.
3+//
4+
5+// clang-format off
6+
7+#ifndef GENERATED_PLUGIN_REGISTRANT_
8+#define GENERATED_PLUGIN_REGISTRANT_
9+
10+#include <flutter_linux/flutter_linux.h>
11+
12+// Registers Flutter plugins.
13+void fl_register_plugins(FlPluginRegistry* registry);
14+
15+#endif // GENERATED_PLUGIN_REGISTRANT_
added app/linux/flutter/generated_plugins.cmake +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+#
2+# Generated file, do not edit.
3+#
4+
5+list(APPEND FLUTTER_PLUGIN_LIST
6+ url_launcher_linux
7+)
8+
9+list(APPEND FLUTTER_FFI_PLUGIN_LIST
10+)
11+
12+set(PLUGIN_BUNDLED_LIBRARIES)
13+
14+foreach(plugin ${FLUTTER_PLUGIN_LIST})
15+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
16+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
17+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
18+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
19+endforeach(plugin)
20+
21+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
22+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
23+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
24+endforeach(ffi_plugin)
new file mode 100644
@@ -0,0 +1,24 @@
1+#
2+# Generated file, do not edit.
3+#
4+
5+list(APPEND FLUTTER_PLUGIN_LIST
6+ url_launcher_linux
7+)
8+
9+list(APPEND FLUTTER_FFI_PLUGIN_LIST
10+)
11+
12+set(PLUGIN_BUNDLED_LIBRARIES)
13+
14+foreach(plugin ${FLUTTER_PLUGIN_LIST})
15+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
16+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
17+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
18+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
19+endforeach(plugin)
20+
21+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
22+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
23+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
24+endforeach(ffi_plugin)
added app/linux/runner/CMakeLists.txt +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+cmake_minimum_required(VERSION 3.13)
2+project(runner LANGUAGES CXX)
3+
4+# Define the application target. To change its name, change BINARY_NAME in the
5+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
6+# work.
7+#
8+# Any new source files that you add to the application should be added here.
9+add_executable(${BINARY_NAME}
10+ "main.cc"
11+ "my_application.cc"
12+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
13+)
14+
15+# Apply the standard set of build settings. This can be removed for applications
16+# that need different build settings.
17+apply_standard_settings(${BINARY_NAME})
18+
19+# Add preprocessor definitions for the application ID.
20+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
21+
22+# Add dependency libraries. Add any application-specific dependencies here.
23+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
24+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
25+
26+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
new file mode 100644
@@ -0,0 +1,26 @@
1+cmake_minimum_required(VERSION 3.13)
2+project(runner LANGUAGES CXX)
3+
4+# Define the application target. To change its name, change BINARY_NAME in the
5+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
6+# work.
7+#
8+# Any new source files that you add to the application should be added here.
9+add_executable(${BINARY_NAME}
10+ "main.cc"
11+ "my_application.cc"
12+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
13+)
14+
15+# Apply the standard set of build settings. This can be removed for applications
16+# that need different build settings.
17+apply_standard_settings(${BINARY_NAME})
18+
19+# Add preprocessor definitions for the application ID.
20+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
21+
22+# Add dependency libraries. Add any application-specific dependencies here.
23+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
24+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
25+
26+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
added app/linux/runner/main.cc +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+#include "my_application.h"
2+
3+int main(int argc, char** argv) {
4+ g_autoptr(MyApplication) app = my_application_new();
5+ return g_application_run(G_APPLICATION(app), argc, argv);
6+}
new file mode 100644
@@ -0,0 +1,6 @@
1+#include "my_application.h"
2+
3+int main(int argc, char** argv) {
4+ g_autoptr(MyApplication) app = my_application_new();
5+ return g_application_run(G_APPLICATION(app), argc, argv);
6+}
added app/linux/runner/my_application.cc +148 -0
new file mode 100644
@@ -0,0 +1,148 @@
1+#include "my_application.h"
2+
3+#include <flutter_linux/flutter_linux.h>
4+#ifdef GDK_WINDOWING_X11
5+#include <gdk/gdkx.h>
6+#endif
7+
8+#include "flutter/generated_plugin_registrant.h"
9+
10+struct _MyApplication {
11+ GtkApplication parent_instance;
12+ char** dart_entrypoint_arguments;
13+};
14+
15+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
16+
17+// Called when first Flutter frame received.
18+static void first_frame_cb(MyApplication* self, FlView* view) {
19+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
20+}
21+
22+// Implements GApplication::activate.
23+static void my_application_activate(GApplication* application) {
24+ MyApplication* self = MY_APPLICATION(application);
25+ GtkWindow* window =
26+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
27+
28+ // Use a header bar when running in GNOME as this is the common style used
29+ // by applications and is the setup most users will be using (e.g. Ubuntu
30+ // desktop).
31+ // If running on X and not using GNOME then just use a traditional title bar
32+ // in case the window manager does more exotic layout, e.g. tiling.
33+ // If running on Wayland assume the header bar will work (may need changing
34+ // if future cases occur).
35+ gboolean use_header_bar = TRUE;
36+#ifdef GDK_WINDOWING_X11
37+ GdkScreen* screen = gtk_window_get_screen(window);
38+ if (GDK_IS_X11_SCREEN(screen)) {
39+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
40+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
41+ use_header_bar = FALSE;
42+ }
43+ }
44+#endif
45+ if (use_header_bar) {
46+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
47+ gtk_widget_show(GTK_WIDGET(header_bar));
48+ gtk_header_bar_set_title(header_bar, "glean_app");
49+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
50+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
51+ } else {
52+ gtk_window_set_title(window, "glean_app");
53+ }
54+
55+ gtk_window_set_default_size(window, 1280, 720);
56+
57+ g_autoptr(FlDartProject) project = fl_dart_project_new();
58+ fl_dart_project_set_dart_entrypoint_arguments(
59+ project, self->dart_entrypoint_arguments);
60+
61+ FlView* view = fl_view_new(project);
62+ GdkRGBA background_color;
63+ // Background defaults to black, override it here if necessary, e.g. #00000000
64+ // for transparent.
65+ gdk_rgba_parse(&background_color, "#000000");
66+ fl_view_set_background_color(view, &background_color);
67+ gtk_widget_show(GTK_WIDGET(view));
68+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
69+
70+ // Show the window when Flutter renders.
71+ // Requires the view to be realized so we can start rendering.
72+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
73+ self);
74+ gtk_widget_realize(GTK_WIDGET(view));
75+
76+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
77+
78+ gtk_widget_grab_focus(GTK_WIDGET(view));
79+}
80+
81+// Implements GApplication::local_command_line.
82+static gboolean my_application_local_command_line(GApplication* application,
83+ gchar*** arguments,
84+ int* exit_status) {
85+ MyApplication* self = MY_APPLICATION(application);
86+ // Strip out the first argument as it is the binary name.
87+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
88+
89+ g_autoptr(GError) error = nullptr;
90+ if (!g_application_register(application, nullptr, &error)) {
91+ g_warning("Failed to register: %s", error->message);
92+ *exit_status = 1;
93+ return TRUE;
94+ }
95+
96+ g_application_activate(application);
97+ *exit_status = 0;
98+
99+ return TRUE;
100+}
101+
102+// Implements GApplication::startup.
103+static void my_application_startup(GApplication* application) {
104+ // MyApplication* self = MY_APPLICATION(object);
105+
106+ // Perform any actions required at application startup.
107+
108+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
109+}
110+
111+// Implements GApplication::shutdown.
112+static void my_application_shutdown(GApplication* application) {
113+ // MyApplication* self = MY_APPLICATION(object);
114+
115+ // Perform any actions required at application shutdown.
116+
117+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
118+}
119+
120+// Implements GObject::dispose.
121+static void my_application_dispose(GObject* object) {
122+ MyApplication* self = MY_APPLICATION(object);
123+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
124+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
125+}
126+
127+static void my_application_class_init(MyApplicationClass* klass) {
128+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
129+ G_APPLICATION_CLASS(klass)->local_command_line =
130+ my_application_local_command_line;
131+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
132+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
133+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
134+}
135+
136+static void my_application_init(MyApplication* self) {}
137+
138+MyApplication* my_application_new() {
139+ // Set the program name to the application ID, which helps various systems
140+ // like GTK and desktop environments map this running application to its
141+ // corresponding .desktop file. This ensures better integration by allowing
142+ // the application to be recognized beyond its binary name.
143+ g_set_prgname(APPLICATION_ID);
144+
145+ return MY_APPLICATION(g_object_new(my_application_get_type(),
146+ "application-id", APPLICATION_ID, "flags",
147+ G_APPLICATION_NON_UNIQUE, nullptr));
148+}
new file mode 100644
@@ -0,0 +1,148 @@
1+#include "my_application.h"
2+
3+#include <flutter_linux/flutter_linux.h>
4+#ifdef GDK_WINDOWING_X11
5+#include <gdk/gdkx.h>
6+#endif
7+
8+#include "flutter/generated_plugin_registrant.h"
9+
10+struct _MyApplication {
11+ GtkApplication parent_instance;
12+ char** dart_entrypoint_arguments;
13+};
14+
15+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
16+
17+// Called when first Flutter frame received.
18+static void first_frame_cb(MyApplication* self, FlView* view) {
19+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
20+}
21+
22+// Implements GApplication::activate.
23+static void my_application_activate(GApplication* application) {
24+ MyApplication* self = MY_APPLICATION(application);
25+ GtkWindow* window =
26+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
27+
28+ // Use a header bar when running in GNOME as this is the common style used
29+ // by applications and is the setup most users will be using (e.g. Ubuntu
30+ // desktop).
31+ // If running on X and not using GNOME then just use a traditional title bar
32+ // in case the window manager does more exotic layout, e.g. tiling.
33+ // If running on Wayland assume the header bar will work (may need changing
34+ // if future cases occur).
35+ gboolean use_header_bar = TRUE;
36+#ifdef GDK_WINDOWING_X11
37+ GdkScreen* screen = gtk_window_get_screen(window);
38+ if (GDK_IS_X11_SCREEN(screen)) {
39+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
40+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
41+ use_header_bar = FALSE;
42+ }
43+ }
44+#endif
45+ if (use_header_bar) {
46+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
47+ gtk_widget_show(GTK_WIDGET(header_bar));
48+ gtk_header_bar_set_title(header_bar, "glean_app");
49+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
50+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
51+ } else {
52+ gtk_window_set_title(window, "glean_app");
53+ }
54+
55+ gtk_window_set_default_size(window, 1280, 720);
56+
57+ g_autoptr(FlDartProject) project = fl_dart_project_new();
58+ fl_dart_project_set_dart_entrypoint_arguments(
59+ project, self->dart_entrypoint_arguments);
60+
61+ FlView* view = fl_view_new(project);
62+ GdkRGBA background_color;
63+ // Background defaults to black, override it here if necessary, e.g. #00000000
64+ // for transparent.
65+ gdk_rgba_parse(&background_color, "#000000");
66+ fl_view_set_background_color(view, &background_color);
67+ gtk_widget_show(GTK_WIDGET(view));
68+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
69+
70+ // Show the window when Flutter renders.
71+ // Requires the view to be realized so we can start rendering.
72+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
73+ self);
74+ gtk_widget_realize(GTK_WIDGET(view));
75+
76+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
77+
78+ gtk_widget_grab_focus(GTK_WIDGET(view));
79+}
80+
81+// Implements GApplication::local_command_line.
82+static gboolean my_application_local_command_line(GApplication* application,
83+ gchar*** arguments,
84+ int* exit_status) {
85+ MyApplication* self = MY_APPLICATION(application);
86+ // Strip out the first argument as it is the binary name.
87+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
88+
89+ g_autoptr(GError) error = nullptr;
90+ if (!g_application_register(application, nullptr, &error)) {
91+ g_warning("Failed to register: %s", error->message);
92+ *exit_status = 1;
93+ return TRUE;
94+ }
95+
96+ g_application_activate(application);
97+ *exit_status = 0;
98+
99+ return TRUE;
100+}
101+
102+// Implements GApplication::startup.
103+static void my_application_startup(GApplication* application) {
104+ // MyApplication* self = MY_APPLICATION(object);
105+
106+ // Perform any actions required at application startup.
107+
108+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
109+}
110+
111+// Implements GApplication::shutdown.
112+static void my_application_shutdown(GApplication* application) {
113+ // MyApplication* self = MY_APPLICATION(object);
114+
115+ // Perform any actions required at application shutdown.
116+
117+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
118+}
119+
120+// Implements GObject::dispose.
121+static void my_application_dispose(GObject* object) {
122+ MyApplication* self = MY_APPLICATION(object);
123+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
124+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
125+}
126+
127+static void my_application_class_init(MyApplicationClass* klass) {
128+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
129+ G_APPLICATION_CLASS(klass)->local_command_line =
130+ my_application_local_command_line;
131+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
132+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
133+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
134+}
135+
136+static void my_application_init(MyApplication* self) {}
137+
138+MyApplication* my_application_new() {
139+ // Set the program name to the application ID, which helps various systems
140+ // like GTK and desktop environments map this running application to its
141+ // corresponding .desktop file. This ensures better integration by allowing
142+ // the application to be recognized beyond its binary name.
143+ g_set_prgname(APPLICATION_ID);
144+
145+ return MY_APPLICATION(g_object_new(my_application_get_type(),
146+ "application-id", APPLICATION_ID, "flags",
147+ G_APPLICATION_NON_UNIQUE, nullptr));
148+}
added app/linux/runner/my_application.h +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+#ifndef FLUTTER_MY_APPLICATION_H_
2+#define FLUTTER_MY_APPLICATION_H_
3+
4+#include <gtk/gtk.h>
5+
6+G_DECLARE_FINAL_TYPE(MyApplication,
7+ my_application,
8+ MY,
9+ APPLICATION,
10+ GtkApplication)
11+
12+/**
13+ * my_application_new:
14+ *
15+ * Creates a new Flutter-based application.
16+ *
17+ * Returns: a new #MyApplication.
18+ */
19+MyApplication* my_application_new();
20+
21+#endif // FLUTTER_MY_APPLICATION_H_
new file mode 100644
@@ -0,0 +1,21 @@
1+#ifndef FLUTTER_MY_APPLICATION_H_
2+#define FLUTTER_MY_APPLICATION_H_
3+
4+#include <gtk/gtk.h>
5+
6+G_DECLARE_FINAL_TYPE(MyApplication,
7+ my_application,
8+ MY,
9+ APPLICATION,
10+ GtkApplication)
11+
12+/**
13+ * my_application_new:
14+ *
15+ * Creates a new Flutter-based application.
16+ *
17+ * Returns: a new #MyApplication.
18+ */
19+MyApplication* my_application_new();
20+
21+#endif // FLUTTER_MY_APPLICATION_H_
added app/pubspec.lock +506 -0
new file mode 100644
@@ -0,0 +1,506 @@
1+# Generated by pub
2+# See https://dart.dev/tools/pub/glossary#lockfile
3+packages:
4+ async:
5+ dependency: transitive
6+ description:
7+ name: async
8+ sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
9+ url: "https://pub.dev"
10+ source: hosted
11+ version: "2.13.1"
12+ boolean_selector:
13+ dependency: transitive
14+ description:
15+ name: boolean_selector
16+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
17+ url: "https://pub.dev"
18+ source: hosted
19+ version: "2.1.2"
20+ characters:
21+ dependency: transitive
22+ description:
23+ name: characters
24+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
25+ url: "https://pub.dev"
26+ source: hosted
27+ version: "1.4.1"
28+ clock:
29+ dependency: transitive
30+ description:
31+ name: clock
32+ sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
33+ url: "https://pub.dev"
34+ source: hosted
35+ version: "1.1.3"
36+ collection:
37+ dependency: transitive
38+ description:
39+ name: collection
40+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
41+ url: "https://pub.dev"
42+ source: hosted
43+ version: "1.19.1"
44+ csslib:
45+ dependency: transitive
46+ description:
47+ name: csslib
48+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
49+ url: "https://pub.dev"
50+ source: hosted
51+ version: "1.0.2"
52+ cupertino_icons:
53+ dependency: "direct main"
54+ description:
55+ name: cupertino_icons
56+ sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
57+ url: "https://pub.dev"
58+ source: hosted
59+ version: "1.0.9"
60+ fake_async:
61+ dependency: transitive
62+ description:
63+ name: fake_async
64+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
65+ url: "https://pub.dev"
66+ source: hosted
67+ version: "1.3.3"
68+ ffi:
69+ dependency: transitive
70+ description:
71+ name: ffi
72+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
73+ url: "https://pub.dev"
74+ source: hosted
75+ version: "2.2.0"
76+ file:
77+ dependency: transitive
78+ description:
79+ name: file
80+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
81+ url: "https://pub.dev"
82+ source: hosted
83+ version: "7.0.1"
84+ flutter:
85+ dependency: "direct main"
86+ description: flutter
87+ source: sdk
88+ version: "0.0.0"
89+ flutter_html:
90+ dependency: "direct main"
91+ description:
92+ name: flutter_html
93+ sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
94+ url: "https://pub.dev"
95+ source: hosted
96+ version: "3.0.0"
97+ flutter_lints:
98+ dependency: "direct dev"
99+ description:
100+ name: flutter_lints
101+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
102+ url: "https://pub.dev"
103+ source: hosted
104+ version: "6.0.0"
105+ flutter_test:
106+ dependency: "direct dev"
107+ description: flutter
108+ source: sdk
109+ version: "0.0.0"
110+ flutter_web_plugins:
111+ dependency: transitive
112+ description: flutter
113+ source: sdk
114+ version: "0.0.0"
115+ html:
116+ dependency: transitive
117+ description:
118+ name: html
119+ sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
120+ url: "https://pub.dev"
121+ source: hosted
122+ version: "0.15.7"
123+ http:
124+ dependency: "direct main"
125+ description:
126+ name: http
127+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
128+ url: "https://pub.dev"
129+ source: hosted
130+ version: "1.6.0"
131+ http_parser:
132+ dependency: transitive
133+ description:
134+ name: http_parser
135+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
136+ url: "https://pub.dev"
137+ source: hosted
138+ version: "4.1.2"
139+ intl:
140+ dependency: "direct main"
141+ description:
142+ name: intl
143+ sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
144+ url: "https://pub.dev"
145+ source: hosted
146+ version: "0.20.3"
147+ leak_tracker:
148+ dependency: transitive
149+ description:
150+ name: leak_tracker
151+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
152+ url: "https://pub.dev"
153+ source: hosted
154+ version: "11.0.2"
155+ leak_tracker_flutter_testing:
156+ dependency: transitive
157+ description:
158+ name: leak_tracker_flutter_testing
159+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
160+ url: "https://pub.dev"
161+ source: hosted
162+ version: "3.0.10"
163+ leak_tracker_testing:
164+ dependency: transitive
165+ description:
166+ name: leak_tracker_testing
167+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
168+ url: "https://pub.dev"
169+ source: hosted
170+ version: "3.0.2"
171+ lints:
172+ dependency: transitive
173+ description:
174+ name: lints
175+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
176+ url: "https://pub.dev"
177+ source: hosted
178+ version: "6.1.0"
179+ list_counter:
180+ dependency: transitive
181+ description:
182+ name: list_counter
183+ sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
184+ url: "https://pub.dev"
185+ source: hosted
186+ version: "1.0.2"
187+ matcher:
188+ dependency: transitive
189+ description:
190+ name: matcher
191+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
192+ url: "https://pub.dev"
193+ source: hosted
194+ version: "0.12.20"
195+ material_color_utilities:
196+ dependency: transitive
197+ description:
198+ name: material_color_utilities
199+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
200+ url: "https://pub.dev"
201+ source: hosted
202+ version: "0.13.0"
203+ meta:
204+ dependency: transitive
205+ description:
206+ name: meta
207+ sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
208+ url: "https://pub.dev"
209+ source: hosted
210+ version: "1.18.3"
211+ path:
212+ dependency: transitive
213+ description:
214+ name: path
215+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
216+ url: "https://pub.dev"
217+ source: hosted
218+ version: "1.9.1"
219+ path_provider_linux:
220+ dependency: transitive
221+ description:
222+ name: path_provider_linux
223+ sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
224+ url: "https://pub.dev"
225+ source: hosted
226+ version: "2.2.2"
227+ path_provider_platform_interface:
228+ dependency: transitive
229+ description:
230+ name: path_provider_platform_interface
231+ sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
232+ url: "https://pub.dev"
233+ source: hosted
234+ version: "2.1.3"
235+ path_provider_windows:
236+ dependency: transitive
237+ description:
238+ name: path_provider_windows
239+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
240+ url: "https://pub.dev"
241+ source: hosted
242+ version: "2.3.0"
243+ platform:
244+ dependency: transitive
245+ description:
246+ name: platform
247+ sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
248+ url: "https://pub.dev"
249+ source: hosted
250+ version: "3.2.0"
251+ plugin_platform_interface:
252+ dependency: transitive
253+ description:
254+ name: plugin_platform_interface
255+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
256+ url: "https://pub.dev"
257+ source: hosted
258+ version: "2.1.8"
259+ shared_preferences:
260+ dependency: "direct main"
261+ description:
262+ name: shared_preferences
263+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
264+ url: "https://pub.dev"
265+ source: hosted
266+ version: "2.5.5"
267+ shared_preferences_android:
268+ dependency: transitive
269+ description:
270+ name: shared_preferences_android
271+ sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
272+ url: "https://pub.dev"
273+ source: hosted
274+ version: "2.4.28"
275+ shared_preferences_foundation:
276+ dependency: transitive
277+ description:
278+ name: shared_preferences_foundation
279+ sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
280+ url: "https://pub.dev"
281+ source: hosted
282+ version: "2.5.7"
283+ shared_preferences_linux:
284+ dependency: transitive
285+ description:
286+ name: shared_preferences_linux
287+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
288+ url: "https://pub.dev"
289+ source: hosted
290+ version: "2.4.1"
291+ shared_preferences_platform_interface:
292+ dependency: transitive
293+ description:
294+ name: shared_preferences_platform_interface
295+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
296+ url: "https://pub.dev"
297+ source: hosted
298+ version: "2.4.2"
299+ shared_preferences_web:
300+ dependency: transitive
301+ description:
302+ name: shared_preferences_web
303+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
304+ url: "https://pub.dev"
305+ source: hosted
306+ version: "2.4.3"
307+ shared_preferences_windows:
308+ dependency: transitive
309+ description:
310+ name: shared_preferences_windows
311+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
312+ url: "https://pub.dev"
313+ source: hosted
314+ version: "2.4.1"
315+ sky_engine:
316+ dependency: transitive
317+ description: flutter
318+ source: sdk
319+ version: "0.0.0"
320+ source_span:
321+ dependency: transitive
322+ description:
323+ name: source_span
324+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
325+ url: "https://pub.dev"
326+ source: hosted
327+ version: "1.10.2"
328+ stack_trace:
329+ dependency: transitive
330+ description:
331+ name: stack_trace
332+ sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
333+ url: "https://pub.dev"
334+ source: hosted
335+ version: "1.12.2"
336+ stream_channel:
337+ dependency: transitive
338+ description:
339+ name: stream_channel
340+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
341+ url: "https://pub.dev"
342+ source: hosted
343+ version: "2.1.4"
344+ string_scanner:
345+ dependency: transitive
346+ description:
347+ name: string_scanner
348+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
349+ url: "https://pub.dev"
350+ source: hosted
351+ version: "1.4.1"
352+ term_glyph:
353+ dependency: transitive
354+ description:
355+ name: term_glyph
356+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
357+ url: "https://pub.dev"
358+ source: hosted
359+ version: "1.2.2"
360+ test_api:
361+ dependency: transitive
362+ description:
363+ name: test_api
364+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
365+ url: "https://pub.dev"
366+ source: hosted
367+ version: "0.7.12"
368+ typed_data:
369+ dependency: transitive
370+ description:
371+ name: typed_data
372+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
373+ url: "https://pub.dev"
374+ source: hosted
375+ version: "1.4.0"
376+ url_launcher:
377+ dependency: "direct main"
378+ description:
379+ name: url_launcher
380+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
381+ url: "https://pub.dev"
382+ source: hosted
383+ version: "6.3.2"
384+ url_launcher_android:
385+ dependency: transitive
386+ description:
387+ name: url_launcher_android
388+ sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
389+ url: "https://pub.dev"
390+ source: hosted
391+ version: "6.3.33"
392+ url_launcher_ios:
393+ dependency: transitive
394+ description:
395+ name: url_launcher_ios
396+ sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
397+ url: "https://pub.dev"
398+ source: hosted
399+ version: "6.4.2"
400+ url_launcher_linux:
401+ dependency: transitive
402+ description:
403+ name: url_launcher_linux
404+ sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
405+ url: "https://pub.dev"
406+ source: hosted
407+ version: "3.2.3"
408+ url_launcher_macos:
409+ dependency: transitive
410+ description:
411+ name: url_launcher_macos
412+ sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
413+ url: "https://pub.dev"
414+ source: hosted
415+ version: "3.2.6"
416+ url_launcher_platform_interface:
417+ dependency: transitive
418+ description:
419+ name: url_launcher_platform_interface
420+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
421+ url: "https://pub.dev"
422+ source: hosted
423+ version: "2.3.2"
424+ url_launcher_web:
425+ dependency: transitive
426+ description:
427+ name: url_launcher_web
428+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
429+ url: "https://pub.dev"
430+ source: hosted
431+ version: "2.4.3"
432+ url_launcher_windows:
433+ dependency: transitive
434+ description:
435+ name: url_launcher_windows
436+ sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
437+ url: "https://pub.dev"
438+ source: hosted
439+ version: "3.1.6"
440+ vector_math:
441+ dependency: transitive
442+ description:
443+ name: vector_math
444+ sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
445+ url: "https://pub.dev"
446+ source: hosted
447+ version: "2.4.0"
448+ vm_service:
449+ dependency: transitive
450+ description:
451+ name: vm_service
452+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
453+ url: "https://pub.dev"
454+ source: hosted
455+ version: "15.3.0"
456+ web:
457+ dependency: transitive
458+ description:
459+ name: web
460+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
461+ url: "https://pub.dev"
462+ source: hosted
463+ version: "1.1.1"
464+ webview_flutter:
465+ dependency: "direct main"
466+ description:
467+ name: webview_flutter
468+ sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "4.14.1"
472+ webview_flutter_android:
473+ dependency: transitive
474+ description:
475+ name: webview_flutter_android
476+ sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
477+ url: "https://pub.dev"
478+ source: hosted
479+ version: "4.14.1"
480+ webview_flutter_platform_interface:
481+ dependency: transitive
482+ description:
483+ name: webview_flutter_platform_interface
484+ sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
485+ url: "https://pub.dev"
486+ source: hosted
487+ version: "2.15.1"
488+ webview_flutter_wkwebview:
489+ dependency: transitive
490+ description:
491+ name: webview_flutter_wkwebview
492+ sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
493+ url: "https://pub.dev"
494+ source: hosted
495+ version: "3.26.1"
496+ xdg_directories:
497+ dependency: transitive
498+ description:
499+ name: xdg_directories
500+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
501+ url: "https://pub.dev"
502+ source: hosted
503+ version: "1.1.0"
504+sdks:
505+ dart: ">=3.13.4 <4.0.0"
506+ flutter: ">=3.44.0"
new file mode 100644
@@ -0,0 +1,506 @@
1+# Generated by pub
2+# See https://dart.dev/tools/pub/glossary#lockfile
3+packages:
4+ async:
5+ dependency: transitive
6+ description:
7+ name: async
8+ sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
9+ url: "https://pub.dev"
10+ source: hosted
11+ version: "2.13.1"
12+ boolean_selector:
13+ dependency: transitive
14+ description:
15+ name: boolean_selector
16+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
17+ url: "https://pub.dev"
18+ source: hosted
19+ version: "2.1.2"
20+ characters:
21+ dependency: transitive
22+ description:
23+ name: characters
24+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
25+ url: "https://pub.dev"
26+ source: hosted
27+ version: "1.4.1"
28+ clock:
29+ dependency: transitive
30+ description:
31+ name: clock
32+ sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
33+ url: "https://pub.dev"
34+ source: hosted
35+ version: "1.1.3"
36+ collection:
37+ dependency: transitive
38+ description:
39+ name: collection
40+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
41+ url: "https://pub.dev"
42+ source: hosted
43+ version: "1.19.1"
44+ csslib:
45+ dependency: transitive
46+ description:
47+ name: csslib
48+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
49+ url: "https://pub.dev"
50+ source: hosted
51+ version: "1.0.2"
52+ cupertino_icons:
53+ dependency: "direct main"
54+ description:
55+ name: cupertino_icons
56+ sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
57+ url: "https://pub.dev"
58+ source: hosted
59+ version: "1.0.9"
60+ fake_async:
61+ dependency: transitive
62+ description:
63+ name: fake_async
64+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
65+ url: "https://pub.dev"
66+ source: hosted
67+ version: "1.3.3"
68+ ffi:
69+ dependency: transitive
70+ description:
71+ name: ffi
72+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
73+ url: "https://pub.dev"
74+ source: hosted
75+ version: "2.2.0"
76+ file:
77+ dependency: transitive
78+ description:
79+ name: file
80+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
81+ url: "https://pub.dev"
82+ source: hosted
83+ version: "7.0.1"
84+ flutter:
85+ dependency: "direct main"
86+ description: flutter
87+ source: sdk
88+ version: "0.0.0"
89+ flutter_html:
90+ dependency: "direct main"
91+ description:
92+ name: flutter_html
93+ sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
94+ url: "https://pub.dev"
95+ source: hosted
96+ version: "3.0.0"
97+ flutter_lints:
98+ dependency: "direct dev"
99+ description:
100+ name: flutter_lints
101+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
102+ url: "https://pub.dev"
103+ source: hosted
104+ version: "6.0.0"
105+ flutter_test:
106+ dependency: "direct dev"
107+ description: flutter
108+ source: sdk
109+ version: "0.0.0"
110+ flutter_web_plugins:
111+ dependency: transitive
112+ description: flutter
113+ source: sdk
114+ version: "0.0.0"
115+ html:
116+ dependency: transitive
117+ description:
118+ name: html
119+ sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
120+ url: "https://pub.dev"
121+ source: hosted
122+ version: "0.15.7"
123+ http:
124+ dependency: "direct main"
125+ description:
126+ name: http
127+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
128+ url: "https://pub.dev"
129+ source: hosted
130+ version: "1.6.0"
131+ http_parser:
132+ dependency: transitive
133+ description:
134+ name: http_parser
135+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
136+ url: "https://pub.dev"
137+ source: hosted
138+ version: "4.1.2"
139+ intl:
140+ dependency: "direct main"
141+ description:
142+ name: intl
143+ sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
144+ url: "https://pub.dev"
145+ source: hosted
146+ version: "0.20.3"
147+ leak_tracker:
148+ dependency: transitive
149+ description:
150+ name: leak_tracker
151+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
152+ url: "https://pub.dev"
153+ source: hosted
154+ version: "11.0.2"
155+ leak_tracker_flutter_testing:
156+ dependency: transitive
157+ description:
158+ name: leak_tracker_flutter_testing
159+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
160+ url: "https://pub.dev"
161+ source: hosted
162+ version: "3.0.10"
163+ leak_tracker_testing:
164+ dependency: transitive
165+ description:
166+ name: leak_tracker_testing
167+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
168+ url: "https://pub.dev"
169+ source: hosted
170+ version: "3.0.2"
171+ lints:
172+ dependency: transitive
173+ description:
174+ name: lints
175+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
176+ url: "https://pub.dev"
177+ source: hosted
178+ version: "6.1.0"
179+ list_counter:
180+ dependency: transitive
181+ description:
182+ name: list_counter
183+ sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
184+ url: "https://pub.dev"
185+ source: hosted
186+ version: "1.0.2"
187+ matcher:
188+ dependency: transitive
189+ description:
190+ name: matcher
191+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
192+ url: "https://pub.dev"
193+ source: hosted
194+ version: "0.12.20"
195+ material_color_utilities:
196+ dependency: transitive
197+ description:
198+ name: material_color_utilities
199+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
200+ url: "https://pub.dev"
201+ source: hosted
202+ version: "0.13.0"
203+ meta:
204+ dependency: transitive
205+ description:
206+ name: meta
207+ sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
208+ url: "https://pub.dev"
209+ source: hosted
210+ version: "1.18.3"
211+ path:
212+ dependency: transitive
213+ description:
214+ name: path
215+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
216+ url: "https://pub.dev"
217+ source: hosted
218+ version: "1.9.1"
219+ path_provider_linux:
220+ dependency: transitive
221+ description:
222+ name: path_provider_linux
223+ sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
224+ url: "https://pub.dev"
225+ source: hosted
226+ version: "2.2.2"
227+ path_provider_platform_interface:
228+ dependency: transitive
229+ description:
230+ name: path_provider_platform_interface
231+ sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
232+ url: "https://pub.dev"
233+ source: hosted
234+ version: "2.1.3"
235+ path_provider_windows:
236+ dependency: transitive
237+ description:
238+ name: path_provider_windows
239+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
240+ url: "https://pub.dev"
241+ source: hosted
242+ version: "2.3.0"
243+ platform:
244+ dependency: transitive
245+ description:
246+ name: platform
247+ sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
248+ url: "https://pub.dev"
249+ source: hosted
250+ version: "3.2.0"
251+ plugin_platform_interface:
252+ dependency: transitive
253+ description:
254+ name: plugin_platform_interface
255+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
256+ url: "https://pub.dev"
257+ source: hosted
258+ version: "2.1.8"
259+ shared_preferences:
260+ dependency: "direct main"
261+ description:
262+ name: shared_preferences
263+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
264+ url: "https://pub.dev"
265+ source: hosted
266+ version: "2.5.5"
267+ shared_preferences_android:
268+ dependency: transitive
269+ description:
270+ name: shared_preferences_android
271+ sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
272+ url: "https://pub.dev"
273+ source: hosted
274+ version: "2.4.28"
275+ shared_preferences_foundation:
276+ dependency: transitive
277+ description:
278+ name: shared_preferences_foundation
279+ sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
280+ url: "https://pub.dev"
281+ source: hosted
282+ version: "2.5.7"
283+ shared_preferences_linux:
284+ dependency: transitive
285+ description:
286+ name: shared_preferences_linux
287+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
288+ url: "https://pub.dev"
289+ source: hosted
290+ version: "2.4.1"
291+ shared_preferences_platform_interface:
292+ dependency: transitive
293+ description:
294+ name: shared_preferences_platform_interface
295+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
296+ url: "https://pub.dev"
297+ source: hosted
298+ version: "2.4.2"
299+ shared_preferences_web:
300+ dependency: transitive
301+ description:
302+ name: shared_preferences_web
303+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
304+ url: "https://pub.dev"
305+ source: hosted
306+ version: "2.4.3"
307+ shared_preferences_windows:
308+ dependency: transitive
309+ description:
310+ name: shared_preferences_windows
311+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
312+ url: "https://pub.dev"
313+ source: hosted
314+ version: "2.4.1"
315+ sky_engine:
316+ dependency: transitive
317+ description: flutter
318+ source: sdk
319+ version: "0.0.0"
320+ source_span:
321+ dependency: transitive
322+ description:
323+ name: source_span
324+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
325+ url: "https://pub.dev"
326+ source: hosted
327+ version: "1.10.2"
328+ stack_trace:
329+ dependency: transitive
330+ description:
331+ name: stack_trace
332+ sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
333+ url: "https://pub.dev"
334+ source: hosted
335+ version: "1.12.2"
336+ stream_channel:
337+ dependency: transitive
338+ description:
339+ name: stream_channel
340+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
341+ url: "https://pub.dev"
342+ source: hosted
343+ version: "2.1.4"
344+ string_scanner:
345+ dependency: transitive
346+ description:
347+ name: string_scanner
348+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
349+ url: "https://pub.dev"
350+ source: hosted
351+ version: "1.4.1"
352+ term_glyph:
353+ dependency: transitive
354+ description:
355+ name: term_glyph
356+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
357+ url: "https://pub.dev"
358+ source: hosted
359+ version: "1.2.2"
360+ test_api:
361+ dependency: transitive
362+ description:
363+ name: test_api
364+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
365+ url: "https://pub.dev"
366+ source: hosted
367+ version: "0.7.12"
368+ typed_data:
369+ dependency: transitive
370+ description:
371+ name: typed_data
372+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
373+ url: "https://pub.dev"
374+ source: hosted
375+ version: "1.4.0"
376+ url_launcher:
377+ dependency: "direct main"
378+ description:
379+ name: url_launcher
380+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
381+ url: "https://pub.dev"
382+ source: hosted
383+ version: "6.3.2"
384+ url_launcher_android:
385+ dependency: transitive
386+ description:
387+ name: url_launcher_android
388+ sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
389+ url: "https://pub.dev"
390+ source: hosted
391+ version: "6.3.33"
392+ url_launcher_ios:
393+ dependency: transitive
394+ description:
395+ name: url_launcher_ios
396+ sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
397+ url: "https://pub.dev"
398+ source: hosted
399+ version: "6.4.2"
400+ url_launcher_linux:
401+ dependency: transitive
402+ description:
403+ name: url_launcher_linux
404+ sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
405+ url: "https://pub.dev"
406+ source: hosted
407+ version: "3.2.3"
408+ url_launcher_macos:
409+ dependency: transitive
410+ description:
411+ name: url_launcher_macos
412+ sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
413+ url: "https://pub.dev"
414+ source: hosted
415+ version: "3.2.6"
416+ url_launcher_platform_interface:
417+ dependency: transitive
418+ description:
419+ name: url_launcher_platform_interface
420+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
421+ url: "https://pub.dev"
422+ source: hosted
423+ version: "2.3.2"
424+ url_launcher_web:
425+ dependency: transitive
426+ description:
427+ name: url_launcher_web
428+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
429+ url: "https://pub.dev"
430+ source: hosted
431+ version: "2.4.3"
432+ url_launcher_windows:
433+ dependency: transitive
434+ description:
435+ name: url_launcher_windows
436+ sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
437+ url: "https://pub.dev"
438+ source: hosted
439+ version: "3.1.6"
440+ vector_math:
441+ dependency: transitive
442+ description:
443+ name: vector_math
444+ sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
445+ url: "https://pub.dev"
446+ source: hosted
447+ version: "2.4.0"
448+ vm_service:
449+ dependency: transitive
450+ description:
451+ name: vm_service
452+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
453+ url: "https://pub.dev"
454+ source: hosted
455+ version: "15.3.0"
456+ web:
457+ dependency: transitive
458+ description:
459+ name: web
460+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
461+ url: "https://pub.dev"
462+ source: hosted
463+ version: "1.1.1"
464+ webview_flutter:
465+ dependency: "direct main"
466+ description:
467+ name: webview_flutter
468+ sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "4.14.1"
472+ webview_flutter_android:
473+ dependency: transitive
474+ description:
475+ name: webview_flutter_android
476+ sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
477+ url: "https://pub.dev"
478+ source: hosted
479+ version: "4.14.1"
480+ webview_flutter_platform_interface:
481+ dependency: transitive
482+ description:
483+ name: webview_flutter_platform_interface
484+ sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
485+ url: "https://pub.dev"
486+ source: hosted
487+ version: "2.15.1"
488+ webview_flutter_wkwebview:
489+ dependency: transitive
490+ description:
491+ name: webview_flutter_wkwebview
492+ sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
493+ url: "https://pub.dev"
494+ source: hosted
495+ version: "3.26.1"
496+ xdg_directories:
497+ dependency: transitive
498+ description:
499+ name: xdg_directories
500+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
501+ url: "https://pub.dev"
502+ source: hosted
503+ version: "1.1.0"
504+sdks:
505+ dart: ">=3.13.4 <4.0.0"
506+ flutter: ">=3.44.0"
added app/pubspec.yaml +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+name: glean_app
2+description: "A new Flutter project."
3+# The following line prevents the package from being accidentally published to
4+# pub.dev using `flutter pub publish`. This is preferred for private packages.
5+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
6+
7+# The following defines the version and build number for your application.
8+# A version number is three numbers separated by dots, like 1.2.43
9+# followed by an optional build number separated by a +.
10+# Both the version and the builder number may be overridden in flutter
11+# build by specifying --build-name and --build-number, respectively.
12+# In Android, build-name is used as versionName while build-number used as versionCode.
13+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
14+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
15+# Read more about iOS versioning at
16+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17+# In Windows, build-name is used as the major, minor, and patch parts
18+# of the product and file versions while build-number is used as the build suffix.
19+version: 1.0.0+1
20+
21+environment:
22+ sdk: ^3.13.4
23+
24+# Dependencies specify other packages that your package needs in order to work.
25+# To automatically upgrade your package dependencies to the latest versions
26+# consider running `flutter pub upgrade --major-versions`. Alternatively,
27+# dependencies can be manually updated by changing the version numbers below to
28+# the latest version available on pub.dev. To see which dependencies have newer
29+# versions available, run `flutter pub outdated`.
30+dependencies:
31+ flutter:
32+ sdk: flutter
33+
34+ # The following adds the Cupertino Icons font to your application.
35+ # Use with the CupertinoIcons class for iOS style icons.
36+ cupertino_icons: ^1.0.8
37+ http: ^1.6.0
38+ shared_preferences: ^2.5.5
39+ webview_flutter: ^4.14.1
40+ url_launcher: ^6.3.2
41+ intl: ^0.20.3
42+ flutter_html: ^3.0.0
43+
44+dev_dependencies:
45+ flutter_test:
46+ sdk: flutter
47+
48+ # The "flutter_lints" package below contains a set of recommended lints to
49+ # encourage good coding practices. The lint set provided by the package is
50+ # activated in the `analysis_options.yaml` file located at the root of your
51+ # package. See that file for information about deactivating specific lint
52+ # rules and activating additional ones.
53+ flutter_lints: ^6.0.0
54+
55+# For information on the generic Dart part of this file, see the
56+# following page: https://dart.dev/tools/pub/pubspec
57+
58+# The following section is specific to Flutter packages.
59+flutter:
60+
61+ # The following line ensures that the Material Icons font is
62+ # included with your application, so that you can use the icons in
63+ # the material Icons class.
64+ uses-material-design: true
65+
66+ # To add assets to your application, add an assets section, like this:
67+ # assets:
68+ # - images/a_dot_burr.jpeg
69+ # - images/a_dot_ham.jpeg
70+
71+ # An image asset can refer to one or more resolution-specific "variants", see
72+ # https://flutter.dev/to/resolution-aware-images
73+
74+ # For details regarding adding assets from package dependencies, see
75+ # https://flutter.dev/to/asset-from-package
76+
77+ # To add custom fonts to your application, add a fonts section here,
78+ # in this "flutter" section. Each entry in this list should have a
79+ # "family" key with the font family name, and a "fonts" key with a
80+ # list giving the asset and other descriptors for the font. For
81+ # example:
82+ # fonts:
83+ # - family: Schyler
84+ # fonts:
85+ # - asset: fonts/Schyler-Regular.ttf
86+ # - asset: fonts/Schyler-Italic.ttf
87+ # style: italic
88+ # - family: Trajan Pro
89+ # fonts:
90+ # - asset: fonts/TrajanPro.ttf
91+ # - asset: fonts/TrajanPro_Bold.ttf
92+ # weight: 700
93+ #
94+ # For details regarding fonts from package dependencies,
95+ # see https://flutter.dev/to/font-from-package
new file mode 100644
@@ -0,0 +1,95 @@
1+name: glean_app
2+description: "A new Flutter project."
3+# The following line prevents the package from being accidentally published to
4+# pub.dev using `flutter pub publish`. This is preferred for private packages.
5+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
6+
7+# The following defines the version and build number for your application.
8+# A version number is three numbers separated by dots, like 1.2.43
9+# followed by an optional build number separated by a +.
10+# Both the version and the builder number may be overridden in flutter
11+# build by specifying --build-name and --build-number, respectively.
12+# In Android, build-name is used as versionName while build-number used as versionCode.
13+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
14+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
15+# Read more about iOS versioning at
16+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17+# In Windows, build-name is used as the major, minor, and patch parts
18+# of the product and file versions while build-number is used as the build suffix.
19+version: 1.0.0+1
20+
21+environment:
22+ sdk: ^3.13.4
23+
24+# Dependencies specify other packages that your package needs in order to work.
25+# To automatically upgrade your package dependencies to the latest versions
26+# consider running `flutter pub upgrade --major-versions`. Alternatively,
27+# dependencies can be manually updated by changing the version numbers below to
28+# the latest version available on pub.dev. To see which dependencies have newer
29+# versions available, run `flutter pub outdated`.
30+dependencies:
31+ flutter:
32+ sdk: flutter
33+
34+ # The following adds the Cupertino Icons font to your application.
35+ # Use with the CupertinoIcons class for iOS style icons.
36+ cupertino_icons: ^1.0.8
37+ http: ^1.6.0
38+ shared_preferences: ^2.5.5
39+ webview_flutter: ^4.14.1
40+ url_launcher: ^6.3.2
41+ intl: ^0.20.3
42+ flutter_html: ^3.0.0
43+
44+dev_dependencies:
45+ flutter_test:
46+ sdk: flutter
47+
48+ # The "flutter_lints" package below contains a set of recommended lints to
49+ # encourage good coding practices. The lint set provided by the package is
50+ # activated in the `analysis_options.yaml` file located at the root of your
51+ # package. See that file for information about deactivating specific lint
52+ # rules and activating additional ones.
53+ flutter_lints: ^6.0.0
54+
55+# For information on the generic Dart part of this file, see the
56+# following page: https://dart.dev/tools/pub/pubspec
57+
58+# The following section is specific to Flutter packages.
59+flutter:
60+
61+ # The following line ensures that the Material Icons font is
62+ # included with your application, so that you can use the icons in
63+ # the material Icons class.
64+ uses-material-design: true
65+
66+ # To add assets to your application, add an assets section, like this:
67+ # assets:
68+ # - images/a_dot_burr.jpeg
69+ # - images/a_dot_ham.jpeg
70+
71+ # An image asset can refer to one or more resolution-specific "variants", see
72+ # https://flutter.dev/to/resolution-aware-images
73+
74+ # For details regarding adding assets from package dependencies, see
75+ # https://flutter.dev/to/asset-from-package
76+
77+ # To add custom fonts to your application, add a fonts section here,
78+ # in this "flutter" section. Each entry in this list should have a
79+ # "family" key with the font family name, and a "fonts" key with a
80+ # list giving the asset and other descriptors for the font. For
81+ # example:
82+ # fonts:
83+ # - family: Schyler
84+ # fonts:
85+ # - asset: fonts/Schyler-Regular.ttf
86+ # - asset: fonts/Schyler-Italic.ttf
87+ # style: italic
88+ # - family: Trajan Pro
89+ # fonts:
90+ # - asset: fonts/TrajanPro.ttf
91+ # - asset: fonts/TrajanPro_Bold.ttf
92+ # weight: 700
93+ #
94+ # For details regarding fonts from package dependencies,
95+ # see https://flutter.dev/to/font-from-package
added app/test/live_api_test.dart +64 -0
new file mode 100644
@@ -0,0 +1,64 @@
1+@Tags(['live'])
2+library;
3+
4+import 'dart:io';
5+
6+import 'package:flutter_test/flutter_test.dart';
7+import 'package:shared_preferences/shared_preferences.dart';
8+import 'package:glean_app/src/api/client.dart';
9+import 'package:glean_app/src/api/session.dart';
10+
11+/// Integration checks against a real Glean server. These assert that the Dart
12+/// wire models still line up with what the Go API actually emits -- the thing
13+/// fixture-based unit tests cannot catch when the server changes.
14+///
15+/// flutter test --tags live
16+void main() {
17+ // flutter_test installs an HttpOverrides whose client answers every request
18+ // with a bodiless 400, so that tests never accidentally hit the network.
19+ // These tests are the deliberate exception: clear it to restore real sockets.
20+ setUpAll(() {
21+ TestWidgetsFlutterBinding.ensureInitialized();
22+ HttpOverrides.global = null;
23+ SharedPreferences.setMockInitialValues({});
24+ });
25+
26+ const base = String.fromEnvironment(
27+ 'GLEAN_BASE_URL',
28+ defaultValue: 'https://codegod100--glean-serve.modal.run',
29+ );
30+
31+ late GleanSession session;
32+ late GleanClient client;
33+
34+ setUp(() {
35+ session = GleanSession(baseUrl: base);
36+ client = GleanClient(session);
37+ });
38+
39+ tearDown(() => session.close());
40+
41+ test('me() parses for a signed-out visitor', () async {
42+ final me = await client.me();
43+ expect(me.signedIn, isFalse);
44+ expect(me.user, isNull);
45+ // The server issues the CSRF cookie on this call; the session must keep it
46+ // or every subsequent write would be rejected by the CSRF middleware.
47+ expect(session.csrfToken, isNotNull);
48+ });
49+
50+ test('trending() parses the paginated envelope', () async {
51+ final t = await client.trending();
52+ expect(t.scope, isNotEmpty);
53+ expect(t.pagination.page, greaterThanOrEqualTo(1));
54+ });
55+
56+ test('authenticated routes reject a signed-out caller', () async {
57+ // Proves ApiException carries the server's own message and flags auth
58+ // failures, which is what the UI keys off to send the user to login.
59+ await expectLater(
60+ client.dashboard(),
61+ throwsA(isA<ApiException>().having((e) => e.isUnauthorized, 'isUnauthorized', isTrue)),
62+ );
63+ });
64+}
new file mode 100644
@@ -0,0 +1,64 @@
1+@Tags(['live'])
2+library;
3+
4+import 'dart:io';
5+
6+import 'package:flutter_test/flutter_test.dart';
7+import 'package:shared_preferences/shared_preferences.dart';
8+import 'package:glean_app/src/api/client.dart';
9+import 'package:glean_app/src/api/session.dart';
10+
11+/// Integration checks against a real Glean server. These assert that the Dart
12+/// wire models still line up with what the Go API actually emits -- the thing
13+/// fixture-based unit tests cannot catch when the server changes.
14+///
15+/// flutter test --tags live
16+void main() {
17+ // flutter_test installs an HttpOverrides whose client answers every request
18+ // with a bodiless 400, so that tests never accidentally hit the network.
19+ // These tests are the deliberate exception: clear it to restore real sockets.
20+ setUpAll(() {
21+ TestWidgetsFlutterBinding.ensureInitialized();
22+ HttpOverrides.global = null;
23+ SharedPreferences.setMockInitialValues({});
24+ });
25+
26+ const base = String.fromEnvironment(
27+ 'GLEAN_BASE_URL',
28+ defaultValue: 'https://codegod100--glean-serve.modal.run',
29+ );
30+
31+ late GleanSession session;
32+ late GleanClient client;
33+
34+ setUp(() {
35+ session = GleanSession(baseUrl: base);
36+ client = GleanClient(session);
37+ });
38+
39+ tearDown(() => session.close());
40+
41+ test('me() parses for a signed-out visitor', () async {
42+ final me = await client.me();
43+ expect(me.signedIn, isFalse);
44+ expect(me.user, isNull);
45+ // The server issues the CSRF cookie on this call; the session must keep it
46+ // or every subsequent write would be rejected by the CSRF middleware.
47+ expect(session.csrfToken, isNotNull);
48+ });
49+
50+ test('trending() parses the paginated envelope', () async {
51+ final t = await client.trending();
52+ expect(t.scope, isNotEmpty);
53+ expect(t.pagination.page, greaterThanOrEqualTo(1));
54+ });
55+
56+ test('authenticated routes reject a signed-out caller', () async {
57+ // Proves ApiException carries the server's own message and flags auth
58+ // failures, which is what the UI keys off to send the user to login.
59+ await expectLater(
60+ client.dashboard(),
61+ throwsA(isA<ApiException>().having((e) => e.isUnauthorized, 'isUnauthorized', isTrue)),
62+ );
63+ });
64+}
added app/test/widget_test.dart +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+// This is a basic Flutter widget test.
2+//
3+// To perform an interaction with a widget in your test, use the WidgetTester
4+// utility in the flutter_test package. For example, you can send tap and scroll
5+// gestures. You can also use WidgetTester to find child widgets in the widget
6+// tree, read text, and verify that the values of widget properties are correct.
7+
8+import 'package:flutter/material.dart';
9+import 'package:flutter_test/flutter_test.dart';
10+
11+import 'package:glean_app/main.dart';
12+
13+void main() {
14+ testWidgets('Counter increments smoke test', (WidgetTester tester) async {
15+ // Build our app and trigger a frame.
16+ await tester.pumpWidget(const MyApp());
17+
18+ // Verify that our counter starts at 0.
19+ expect(find.text('0'), findsOneWidget);
20+ expect(find.text('1'), findsNothing);
21+
22+ // Tap the '+' icon and trigger a frame.
23+ await tester.tap(find.byIcon(Icons.add));
24+ await tester.pump();
25+
26+ // Verify that our counter has incremented.
27+ expect(find.text('0'), findsNothing);
28+ expect(find.text('1'), findsOneWidget);
29+ });
30+}
new file mode 100644
@@ -0,0 +1,30 @@
1+// This is a basic Flutter widget test.
2+//
3+// To perform an interaction with a widget in your test, use the WidgetTester
4+// utility in the flutter_test package. For example, you can send tap and scroll
5+// gestures. You can also use WidgetTester to find child widgets in the widget
6+// tree, read text, and verify that the values of widget properties are correct.
7+
8+import 'package:flutter/material.dart';
9+import 'package:flutter_test/flutter_test.dart';
10+
11+import 'package:glean_app/main.dart';
12+
13+void main() {
14+ testWidgets('Counter increments smoke test', (WidgetTester tester) async {
15+ // Build our app and trigger a frame.
16+ await tester.pumpWidget(const MyApp());
17+
18+ // Verify that our counter starts at 0.
19+ expect(find.text('0'), findsOneWidget);
20+ expect(find.text('1'), findsNothing);
21+
22+ // Tap the '+' icon and trigger a frame.
23+ await tester.tap(find.byIcon(Icons.add));
24+ await tester.pump();
25+
26+ // Verify that our counter has incremented.
27+ expect(find.text('0'), findsNothing);
28+ expect(find.text('1'), findsOneWidget);
29+ });
30+}
added app/web/favicon.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/web/favicon.png differ
new file mode 100644
Binary files /dev/null and b/app/web/favicon.png differBinary files /dev/null and b/app/web/favicon.png differ
added app/web/icons/Icon-192.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-192.png differ
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-192.png differBinary files /dev/null and b/app/web/icons/Icon-192.png differ
added app/web/icons/Icon-512.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-512.png differ
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-512.png differBinary files /dev/null and b/app/web/icons/Icon-512.png differ
added app/web/icons/Icon-maskable-192.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differBinary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ
added app/web/icons/Icon-maskable-512.png +0 -0
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ
new file mode 100644
Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differBinary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ
added app/web/index.html +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+<!DOCTYPE html>
2+<html>
3+<head>
4+ <!--
5+ If you are serving your web app in a path other than the root, change the
6+ href value below to reflect the base path you are serving from.
7+
8+ The path provided below has to start and end with a slash "/" in order for
9+ it to work correctly.
10+
11+ For more details:
12+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
13+
14+ This is a placeholder for base href that will be replaced by the value of
15+ the `--base-href` argument provided to `flutter build`.
16+ -->
17+ <base href="$FLUTTER_BASE_HREF">
18+
19+ <meta charset="UTF-8">
20+ <meta content="IE=Edge" http-equiv="X-UA-Compatible">
21+ <meta name="description" content="A new Flutter project.">
22+
23+ <!-- iOS meta tags & icons -->
24+ <meta name="mobile-web-app-capable" content="yes">
25+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
26+ <meta name="apple-mobile-web-app-title" content="glean_app">
27+ <link rel="apple-touch-icon" href="icons/Icon-192.png">
28+
29+ <!-- Favicon -->
30+ <link rel="icon" type="image/png" href="favicon.png"/>
31+
32+ <title>glean_app</title>
33+ <link rel="manifest" href="manifest.json">
34+</head>
35+<body>
36+ <!--
37+ You can customize the "flutter_bootstrap.js" script.
38+ This is useful to provide a custom configuration to the Flutter loader
39+ or to give the user feedback during the initialization process.
40+
41+ For more details:
42+ * https://docs.flutter.dev/platform-integration/web/initialization
43+ -->
44+ <script src="flutter_bootstrap.js" async></script>
45+</body>
46+</html>
new file mode 100644
@@ -0,0 +1,46 @@
1+<!DOCTYPE html>
2+<html>
3+<head>
4+ <!--
5+ If you are serving your web app in a path other than the root, change the
6+ href value below to reflect the base path you are serving from.
7+
8+ The path provided below has to start and end with a slash "/" in order for
9+ it to work correctly.
10+
11+ For more details:
12+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
13+
14+ This is a placeholder for base href that will be replaced by the value of
15+ the `--base-href` argument provided to `flutter build`.
16+ -->
17+ <base href="$FLUTTER_BASE_HREF">
18+
19+ <meta charset="UTF-8">
20+ <meta content="IE=Edge" http-equiv="X-UA-Compatible">
21+ <meta name="description" content="A new Flutter project.">
22+
23+ <!-- iOS meta tags & icons -->
24+ <meta name="mobile-web-app-capable" content="yes">
25+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
26+ <meta name="apple-mobile-web-app-title" content="glean_app">
27+ <link rel="apple-touch-icon" href="icons/Icon-192.png">
28+
29+ <!-- Favicon -->
30+ <link rel="icon" type="image/png" href="favicon.png"/>
31+
32+ <title>glean_app</title>
33+ <link rel="manifest" href="manifest.json">
34+</head>
35+<body>
36+ <!--
37+ You can customize the "flutter_bootstrap.js" script.
38+ This is useful to provide a custom configuration to the Flutter loader
39+ or to give the user feedback during the initialization process.
40+
41+ For more details:
42+ * https://docs.flutter.dev/platform-integration/web/initialization
43+ -->
44+ <script src="flutter_bootstrap.js" async></script>
45+</body>
46+</html>
added app/web/manifest.json +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+{
2+ "name": "glean_app",
3+ "short_name": "glean_app",
4+ "start_url": ".",
5+ "display": "standalone",
6+ "background_color": "#0175C2",
7+ "theme_color": "#0175C2",
8+ "description": "A new Flutter project.",
9+ "orientation": "portrait-primary",
10+ "prefer_related_applications": false,
11+ "icons": [
12+ {
13+ "src": "icons/Icon-192.png",
14+ "sizes": "192x192",
15+ "type": "image/png"
16+ },
17+ {
18+ "src": "icons/Icon-512.png",
19+ "sizes": "512x512",
20+ "type": "image/png"
21+ },
22+ {
23+ "src": "icons/Icon-maskable-192.png",
24+ "sizes": "192x192",
25+ "type": "image/png",
26+ "purpose": "maskable"
27+ },
28+ {
29+ "src": "icons/Icon-maskable-512.png",
30+ "sizes": "512x512",
31+ "type": "image/png",
32+ "purpose": "maskable"
33+ }
34+ ]
35+}
new file mode 100644
@@ -0,0 +1,35 @@
1+{
2+ "name": "glean_app",
3+ "short_name": "glean_app",
4+ "start_url": ".",
5+ "display": "standalone",
6+ "background_color": "#0175C2",
7+ "theme_color": "#0175C2",
8+ "description": "A new Flutter project.",
9+ "orientation": "portrait-primary",
10+ "prefer_related_applications": false,
11+ "icons": [
12+ {
13+ "src": "icons/Icon-192.png",
14+ "sizes": "192x192",
15+ "type": "image/png"
16+ },
17+ {
18+ "src": "icons/Icon-512.png",
19+ "sizes": "512x512",
20+ "type": "image/png"
21+ },
22+ {
23+ "src": "icons/Icon-maskable-192.png",
24+ "sizes": "192x192",
25+ "type": "image/png",
26+ "purpose": "maskable"
27+ },
28+ {
29+ "src": "icons/Icon-maskable-512.png",
30+ "sizes": "512x512",
31+ "type": "image/png",
32+ "purpose": "maskable"
33+ }
34+ ]
35+}