commit f12fb1dabb1d8f921c64e73ba59d80ddde7efd43
Author: Cedric Hornberger
Date: Fri Oct 16 14:53:37 2026 +0200
Initial commit: Tagesbyte prototype
Flutter app that collects daily tech news from German and English RSS/Atom
feeds, with a reader view, AI article summaries and daily briefings (Claude
API or n8n webhook), paywall/sponsored filtering and ad/consent blocking.
Co-Authored-By: Claude Opus 5.5
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..73181c6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,65 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+# Windows
+Thumbs.db
+desktop.ini
+
+# Machine-specific editor settings (absolute paths)
+.vscode/settings.json
+
+# Test coverage
+coverage/
+
+# Secrets and signing
+.env
+.env.*
+*.keystore
+*.jks
+key.properties
+
+# Flutter version manager
+.fvm/
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..916bcca
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,39 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "edada7c56edf4a183c1735310e123c7f923584f1"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ base_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ - platform: android
+ create_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ base_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ - platform: ios
+ create_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ base_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ - platform: web
+ create_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ base_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ - platform: windows
+ create_revision: edada7c56edf4a183c1735310e123c7f923584f1
+ base_revision: edada7c56edf4a183c1735310e123c7f923584f1
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f18257d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,13 @@
+## Agent skills
+
+### Issue tracker
+
+Issues live in Gitea at tea.zendric.de (Cedric/TagesByte), managed with the `tea` CLI. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Default vocabulary: needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context: one `GLOSSARY.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..12765c8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,197 @@
+# Tagesbyte
+
+Your daily tech news in bytes. A Flutter app that collects the day's tech news
+from German and English RSS/Atom feeds and uses AI to summarize single articles
+or write a daily briefing. It's meant to replace the n8n → Discord workflow.
+
+## Features (prototype)
+
+- **Today**: all articles from the enabled sources in the last 12/24/48 h, newest
+ first. Filter by language or source, pull to refresh.
+- **Reading in the app**: tapping an article opens a clean **reader view** (text,
+ headings, lists, quotes, images) without ads or cookie banners. The globe icon
+ switches to the **original page** in an in-app WebView, for paywalls or sites
+ that block reader view. **Summarize with AI** uses the same downloaded text, so
+ the summary is based on the full article, not just the RSS teaser. Links in the
+ briefing open the same way.
+- **Briefing**: AI picks the 6–10 most important stories of the day, merges
+ coverage from different outlets, and links every topic to its sources. Every
+ briefing is saved on the device (`briefings.json` in the app's private
+ storage); the history button lists them by month, tap to read, swipe to delete
+ (with undo).
+- **Paid articles and ads hidden**: heise+ and Golem `(g+)` are recognized by
+ their title; SPIEGEL+ and t3n Pro by a marker in the article page, which the app
+ checks once per new article in the background (it reads only until the marker,
+ and remembers results for 14 days). Sponsored posts (`Anzeige:`) are hidden too.
+ The feed header shows how many were hidden; both filters can be turned off.
+- **Ad and cookie-banner blocking** in the original-page view: requests to ~80
+ ad/tracker hosts and the common consent managers (Sourcepoint, OneTrust,
+ consentmanager, Usercentrics, Didomi, Cookiebot, …) are blocked, and a
+ stylesheet hides leftover ad slots and banners. Nothing is accepted, so sites
+ treat it like a rejection. Golem redirects to a separate consent page instead
+ of showing a banner; with "Accept cookie walls" on (default), the app makes the
+ same request as Golem's own "Zustimmen" button (`/abo/setconsentcookie.php`,
+ valid for a day) and reuses the cookie for reader view and the in-app page.
+ With it off, the app shows the teaser and an "Open website anyway" option.
+- **Sources**: 21 curated feeds (12 German, 9 English), toggle each one, or add any
+ RSS/Atom feed (it's validated before being added).
+- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
+ original-page view follows it too: when the phone is dark, sites use their own
+ dark design or Android darkens them; when only the app is dark, the app inverts
+ the page colours and keeps photos and videos as they are.
+- **Settings**: AI provider (off / Claude API / n8n webhook), model, summary
+ language (German/English, whatever the source language), time window, paid/ad
+ filters, ad and cookie-banner blocking.
+
+## Running
+
+```bash
+flutter pub get
+flutter run # pick an Android emulator/device
+flutter test # unit tests (parser, Claude request/response handling)
+LIVE_FEEDS=1 flutter test test/live_feeds_test.dart # checks every real feed
+```
+
+Platform notes:
+
+- **Android / iOS**: work as-is (Android 6.0+).
+- **Windows desktop**: requires Visual Studio with the "Desktop development with
+ C++" workload. Reader view works; "original page" opens the browser because
+ the WebView plugin has no Windows support.
+- **Web**: browsers block cross-origin feed requests (CORS). Set a CORS proxy
+ prefix in Settings (only shown on web), or use the app on mobile/desktop.
+
+## AI setup
+
+### Option A: Claude API (direct)
+
+1. Create a key at .
+2. Settings → AI summaries → **Claude API** → paste the key → Save.
+
+The key is stored in the platform keystore (`flutter_secure_storage`) and only
+sent to `api.anthropic.com`. Model choices:
+
+| Model | Use it for |
+|---|---|
+| `claude-opus-5-5` (default) | Best briefing quality |
+| `claude-sonnet-5-5` | Balanced cost and quality |
+| `claude-haiku-5-5` | Cheapest; good enough for single-article summaries |
+
+Requests use low effort (summarizing is routine work) and, for the briefing,
+structured JSON output so the result maps reliably to topics and links. On Opus
+and Sonnet the app also enables Anthropic's server-side refusal fallback
+(`fallbacks: "default"`): if a safety classifier declines a request, the API
+retries it on a fallback model.
+
+> Shipping the app to other people with your key inside isn't safe. For
+> personal use a key you entered yourself is fine; for distribution, route
+> requests through your own backend (e.g. the n8n option below).
+
+### Option B: Your n8n workflow
+
+Settings → AI summaries → **n8n webhook** → enter the production URL of a
+*Webhook* trigger node (method POST, "Respond: Using 'Respond to Webhook' node").
+The app sends JSON and expects JSON back.
+
+**Daily briefing**
+
+```jsonc
+// Request
+{
+ "action": "daily_briefing",
+ "language": "de", // "de" | "en"
+ "articles": [
+ {
+ "id": "a1",
+ "title": "…",
+ "url": "https://…",
+ "source": "heise online",
+ "language": "de",
+ "published": "2026-10-11T07:30:00.000Z",
+ "excerpt": "…"
+ }
+ ]
+}
+
+// Response, option 1 (structured; topics link back to the articles)
+{
+ "intro": "One or two sentences on the day.",
+ "topics": [
+ { "title": "…", "summary": "…", "article_ids": ["a1", "a7"] }
+ ]
+}
+
+// Response, option 2 (plain text, e.g. your existing Discord message)
+{ "text": "…" }
+```
+
+**Single article**
+
+```jsonc
+// Request
+{
+ "action": "summarize_article",
+ "language": "de",
+ "article": { "title": "…", "url": "…", "source": "…", "published": "…", "text": "extracted article body" }
+}
+
+// Response
+{ "summary": "…" } // a plain-text body also works
+```
+
+Use a *Switch* node on `{{$json.body.action}}` to route the two cases to your
+existing AI nodes.
+
+## News sources
+
+Every feed URL below was checked to load and parse. The ★ sources are enabled by
+default.
+
+| | Source | Feed | Focus |
+|---|---|---|---|
+| 🇩🇪 ★ | heise online | `heise.de/rss/heise-atom.xml` | Broad IT news, high volume (~150/day) |
+| 🇩🇪 ★ | Golem.de | `rss.golem.de/rss.php?feed=ATOM1.0` | IT news for professionals |
+| 🇩🇪 ★ | t3n | `t3n.de/rss.xml` | Digital business, startups, AI |
+| 🇩🇪 ★ | ComputerBase | `computerbase.de/rss/news.xml` | Hardware, software, gaming |
+| 🇩🇪 | SPIEGEL Netzwelt | `spiegel.de/netzwelt/index.rss` | Mainstream news magazine view |
+| 🇩🇪 | ZEIT Digital | `newsfeed.zeit.de/digital/index` | Digital policy and society |
+| 🇩🇪 | netzpolitik.org | `netzpolitik.org/feed/` | Digital rights, privacy |
+| 🇩🇪 | heise Security | `heise.de/security/rss/news-atom.xml` | Security vulnerabilities |
+| 🇩🇪 | heise Developer | `heise.de/developer/rss/news-atom.xml` | Software development |
+| 🇩🇪 | Caschys Blog | `stadt-bremerhaven.de/feed/` | Consumer tech, smartphones |
+| 🇩🇪 | derStandard Web | `derstandard.at/rss/web` | Austrian daily's tech section |
+| 🇩🇪 | Borns IT- und Windows-Blog | `borncity.com/blog/feed/` | Windows, patches, admin topics |
+| 🇬🇧 ★ | The Verge | `theverge.com/rss/index.xml` | Consumer tech, policy |
+| 🇬🇧 ★ | Ars Technica | `feeds.arstechnica.com/arstechnica/index` | In-depth tech and science |
+| 🇬🇧 ★ | TechCrunch | `techcrunch.com/feed/` | Startups, funding |
+| 🇬🇧 ★ | Hacker News | `news.ycombinator.com/rss` | Front page links |
+| 🇬🇧 | MIT Technology Review | `technologyreview.com/feed/` | AI, biotech, climate |
+| 🇬🇧 | WIRED | `wired.com/feed/rss` | Tech and culture |
+| 🇬🇧 | Engadget | `engadget.com/rss.xml` | Gadgets |
+| 🇬🇧 | The Register | `theregister.com/headlines.atom` | Enterprise IT |
+| 🇬🇧 | BleepingComputer | `bleepingcomputer.com/feed/` | Security, malware |
+
+Reader view works for 20 of the 21 sources. ZEIT Digital blocks non-browser
+requests, so the app shows the teaser and offers the original page.
+
+## Project layout
+
+```
+lib/
+ main.dart App entry, theme
+ data/default_sources.dart Curated feed list
+ models/ Article, NewsSource, DailyBriefing, AppSettings
+ services/
+ feed_parser.dart RSS 2.0 / RSS 1.0 / Atom parser, date parsing
+ feed_service.dart Parallel fetching, dedupe, time window
+ article_extractor.dart Article page → reader blocks + text for the AI
+ summarizer.dart Summarizer interface and shared prompt input
+ claude_summarizer.dart Claude Messages API over HTTP
+ n8n_summarizer.dart n8n webhook bridge
+ storage.dart Settings, sources, briefing persistence
+ paywall_service.dart Paid/sponsored detection (titles + page marker)
+ content_blocking.dart Ad/tracker/consent blocklists for the WebView
+ consent_service.dart Accepts consent walls (Golem) and keeps the cookies
+ state/app_state.dart ChangeNotifier holding app state
+ ui/ Screens (Today, Article, Briefing, Sources, Settings)
+```
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..1ee244c
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "de.dailytechnews.daily_tech_news"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "de.dailytechnews.daily_tech_news"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = 23 // flutter_secure_storage needs Android 6.0+
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8669ffd
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/de/dailytechnews/daily_tech_news/MainActivity.kt b/android/app/src/main/kotlin/de/dailytechnews/daily_tech_news/MainActivity.kt
new file mode 100644
index 0000000..2e52779
--- /dev/null
+++ b/android/app/src/main/kotlin/de/dailytechnews/daily_tech_news/MainActivity.kt
@@ -0,0 +1,5 @@
+package de.dailytechnews.daily_tech_news
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..765d93a
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1bdb7eb
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..52d3262
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..8c28783
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d46fa96
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..77c491e
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..a912488
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..97f027b
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..5c4d1e0
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..4d65e8f
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..db05061
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..e6ea842
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..e86ab54
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1a7a9ba
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..8c67c56
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..407dab1
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..476fc6f
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..cf118f2
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..0afde87
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..db91090
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..81dc4e4
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..89176ef
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,21 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f018a61
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ac3b479
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..ab39a10
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ val flutterSdkPath = run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
+
+include(":app")
diff --git a/assets/icon/background.png b/assets/icon/background.png
new file mode 100644
index 0000000..3bbd175
Binary files /dev/null and b/assets/icon/background.png differ
diff --git a/assets/icon/background.svg b/assets/icon/background.svg
new file mode 100644
index 0000000..ef75c7c
--- /dev/null
+++ b/assets/icon/background.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/assets/icon/foreground.png b/assets/icon/foreground.png
new file mode 100644
index 0000000..90881b4
Binary files /dev/null and b/assets/icon/foreground.png differ
diff --git a/assets/icon/foreground.svg b/assets/icon/foreground.svg
new file mode 100644
index 0000000..defe8b5
--- /dev/null
+++ b/assets/icon/foreground.svg
@@ -0,0 +1,25 @@
+
\ No newline at end of file
diff --git a/assets/icon/icon.png b/assets/icon/icon.png
new file mode 100644
index 0000000..409eb9c
Binary files /dev/null and b/assets/icon/icon.png differ
diff --git a/assets/icon/icon.svg b/assets/icon/icon.svg
new file mode 100644
index 0000000..b6cc178
--- /dev/null
+++ b/assets/icon/icon.svg
@@ -0,0 +1,32 @@
+
\ No newline at end of file
diff --git a/assets/icon/make_icons.js b/assets/icon/make_icons.js
new file mode 100644
index 0000000..f07ce80
--- /dev/null
+++ b/assets/icon/make_icons.js
@@ -0,0 +1,123 @@
+// Generates the Tagesbyte app icon sources (SVG) and PNG renders.
+// Usage: npm install @resvg/resvg-js@2.6.2 && node make_icons.js assets/icon
+// then: dart run flutter_launcher_icons
+const fs = require('fs');
+const path = require('path');
+const { Resvg } = require('@resvg/resvg-js');
+
+const out = process.argv[2];
+fs.mkdirSync(out, { recursive: true });
+
+const BLUE = '#3355DD';
+const LINE = '#C9D3F7';
+const SPARK = '#FFB52E';
+
+// Design space 1000x1000. A news page with a bite out of its top-right corner.
+const page = { x: 290, y: 230, w: 420, h: 540, r: 52 };
+const corner = { x: page.x + page.w, y: page.y };
+// Two overlapping circles: together they remove the corner and leave a small
+// cusp between them, which reads as a bite mark.
+const biteCircles = [112, 158].map((deg) => {
+ const a = (deg * Math.PI) / 180;
+ return { cx: corner.x + 48 * Math.cos(a), cy: corner.y + 48 * Math.sin(a), r: 88 };
+});
+
+const sparkle = (cx, cy, s) =>
+ `M${cx},${cy - s} C${cx + s * 0.12},${cy - s * 0.12} ${cx + s * 0.12},${cy - s * 0.12} ${cx + s},${cy} ` +
+ `C${cx + s * 0.12},${cy + s * 0.12} ${cx + s * 0.12},${cy + s * 0.12} ${cx},${cy + s} ` +
+ `C${cx - s * 0.12},${cy + s * 0.12} ${cx - s * 0.12},${cy + s * 0.12} ${cx - s},${cy} ` +
+ `C${cx - s * 0.12},${cy - s * 0.12} ${cx - s * 0.12},${cy - s * 0.12} ${cx},${cy - s} Z`;
+
+const pageRect = (fill, extra = '') =>
+ ``;
+
+const biteMask = (id) => `
+
+
+ ${biteCircles.map((c) => ``).join('')}
+ `;
+
+// Headline bar, text lines and the AI sparkle on the page.
+const contentShapes = (headline, line, spark) => `
+
+
+
+
+
+ `;
+
+function foreground(scale) {
+ const t = `translate(500 500) scale(${scale}) translate(-500 -500)`;
+ return `
+
+ ${biteMask('bite')}
+
+
+
+
+
+
+
+ ${pageRect('#0B1460')}
+
+ ${pageRect('#FFFFFF')}
+ ${contentShapes(BLUE, LINE, SPARK)}
+ `;
+}
+
+const background = `
+
+
+
+
+
+
+ `;
+
+const svg = (body) =>
+ ``;
+
+// Monochrome (Android 13 themed icons): page silhouette, content cut out.
+const monochrome = svg(`
+
+ ${biteMask('bite')}
+
+
+ ${contentShapes('#000', '#000', '#000')}
+
+
+
+ ${pageRect('#FFFFFF')}
+ `);
+
+const files = {
+ // Full-bleed icon for iOS, web and Windows (the OS rounds the corners).
+ 'icon.svg': svg(background + foreground(1.1)),
+ // Android adaptive icon layers: content kept inside the 66dp safe zone.
+ 'foreground.svg': svg(foreground(0.9)),
+ 'background.svg': svg(background),
+ 'monochrome.svg': monochrome,
+};
+
+for (const [name, content] of Object.entries(files)) {
+ fs.writeFileSync(path.join(out, name), content);
+ const png = new Resvg(content, { fitTo: { mode: 'width', value: 1024 } }).render().asPng();
+ fs.writeFileSync(path.join(out, name.replace('.svg', '.png')), png);
+ console.log('wrote', name, 'and', name.replace('.svg', '.png'));
+}
+
+// Preview: the full icon at launcher size plus a circle-masked adaptive icon.
+const preview = svg(`
+
+ ${background}${foreground(0.9)}`);
+fs.writeFileSync(path.join(out, '_preview_adaptive.png'),
+ new Resvg(preview, { fitTo: { mode: 'width', value: 512 } }).render().asPng());
+
+// Monochrome preview on a dark background (the real file is transparent), and
+// the full icon at a real launcher size to check it still reads when small.
+const inner = (s) => s.replace(/^
]]>
+ Volltext]]>
+
+
+ HN style
+ https://example.com/b
+ Sat, 10 Oct 2026 22:50:10 GMT
+ Comments]]>
+
+
+ No link
+
+''';
+
+ final items = FeedParser.parse(rss, _source);
+ expect(items, hasLength(2));
+
+ final a = items[0];
+ expect(a.title, 'Neue GPU & mehr');
+ expect(a.link, 'https://example.com/a');
+ expect(a.excerpt, startsWith('Die neue Grafikkarte'));
+ expect(a.contentHtml, contains('
Volltext
'));
+ expect(a.imageUrl, 'https://example.com/img.jpg');
+ expect(a.author, 'Jane Doe');
+ expect(a.published, DateTime.utc(2026, 10, 11, 4, 18, 59));
+
+ final b = items[1];
+ expect(b.excerpt, isEmpty, reason: 'bare "Comments" links are dropped');
+ expect(b.imageUrl, 'https://example.com/thumb.jpg');
+ expect(b.published, DateTime.utc(2026, 10, 10, 22, 50, 10));
+ });
+
+ test('parses Atom entries', () {
+ const atom = '''
+
+ heise
+
+ Patchday: Microsoft schließt Lücken
+
+
+ 2026-10-11T09:30:00+02:00
+ 2026-10-11T10:00:00+02:00
+ <p>Microsoft hat zum Patchday zahlreiche Sicherheitslücken geschlossen.</p>
+ heise Security
+
+''';
+ final items = FeedParser.parse(atom, _source);
+ expect(items, hasLength(1));
+ expect(items.single.link, 'https://example.com/atom-1');
+ expect(items.single.excerpt, startsWith('Microsoft hat zum Patchday'));
+ expect(items.single.published, DateTime.utc(2026, 10, 11, 7, 30));
+ expect(items.single.author, 'heise Security');
+ });
+
+ test('ignores tracking pixels when picking the article image', () {
+ const atom = '''
+
+
+ Softwareentwicklung: KI-Flut
+
+ Teaser text that is long enough. <img src="https://cpx.golem.de/cpx.php?aid=1" width="1" height="1" />
+
+''';
+ expect(FeedParser.parse(atom, _source).single.imageUrl, isNull);
+ });
+
+ test('parseDate handles named zones, short years and garbage', () {
+ expect(FeedParser.parseDate('Mon, 05 Oct 2026 12:00:00 EST'),
+ DateTime.utc(2026, 10, 5, 17));
+ expect(FeedParser.parseDate('5 Oct 26 12:00 +0000'), DateTime.utc(2026, 10, 5, 12));
+ expect(FeedParser.parseDate('not a date'), isNull);
+ expect(FeedParser.parseDate(''), isNull);
+ });
+}
diff --git a/test/live_feeds_test.dart b/test/live_feeds_test.dart
new file mode 100644
index 0000000..d68d734
--- /dev/null
+++ b/test/live_feeds_test.dart
@@ -0,0 +1,56 @@
+// Hits the real feeds. Run with: LIVE_FEEDS=1 flutter test test/live_feeds_test.dart
+import 'dart:io';
+
+import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/services/article_extractor.dart';
+import 'package:daily_tech_news/services/consent_service.dart';
+import 'package:daily_tech_news/services/feed_service.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ final live = Platform.environment['LIVE_FEEDS'] == '1';
+
+ test('all default feeds load and parse', () async {
+ final service = FeedService();
+ final failures = [];
+ for (final source in defaultSources) {
+ try {
+ final items = await service.fetchSource(source);
+ final dated = items.where((a) => a.published != null).length;
+ final withImage = items.where((a) => a.imageUrl != null).length;
+ // ignore: avoid_print
+ print('${source.name.padRight(28)} ${items.length.toString().padLeft(3)} items, '
+ '$dated dated, $withImage with image');
+ if (items.isEmpty) failures.add('${source.name}: no items');
+ } catch (e) {
+ failures.add('${source.name}: $e');
+ }
+ }
+ expect(failures, isEmpty);
+ }, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 3)));
+
+ test('reader view extracts the article body', () async {
+ final service = FeedService();
+ final extractor = ArticleExtractor(consent: ConsentService());
+ final thin = [];
+ for (final source in defaultSources) {
+ final article = (await service.fetchSource(source)).first;
+ final content = await extractor.load(article, acceptConsentWalls: true);
+ final counts = {};
+ for (final b in content.blocks) {
+ counts[b.type] = (counts[b.type] ?? 0) + 1;
+ }
+ // ignore: avoid_print
+ print('${source.name.padRight(28)} teaser ${article.excerpt.length.toString().padLeft(4)} '
+ '-> ${content.plainText.length.toString().padLeft(5)} chars, '
+ '${counts[BlockType.paragraph] ?? 0} paragraphs, ${counts[BlockType.image] ?? 0} images');
+ if (content.consentWall) {
+ thin.add('${source.name} (consent wall)');
+ } else if (content.plainText.length < 400) {
+ thin.add(source.name);
+ }
+ }
+ // ignore: avoid_print
+ print('Teaser only (paywall / blocked / very short): $thin');
+ }, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 5)));
+}
diff --git a/test/paywall_service_test.dart b/test/paywall_service_test.dart
new file mode 100644
index 0000000..64142c1
--- /dev/null
+++ b/test/paywall_service_test.dart
@@ -0,0 +1,85 @@
+import 'package:daily_tech_news/models/article.dart';
+import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/services/paywall_service.dart';
+import 'package:daily_tech_news/services/storage.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+NewsSource _source({bool checkPage = false}) => NewsSource(
+ id: 'src',
+ name: 'Source',
+ feedUrl: 'https://example.com/feed',
+ homepage: 'https://example.com',
+ language: SourceLanguage.de,
+ checkPaywallOnPage: checkPage,
+ );
+
+Article _article(String title, {String? link, bool checkPage = false}) => Article(
+ title: title, link: link ?? 'https://example.com/$title', source: _source(checkPage: checkPage));
+
+Future _storage() async {
+ SharedPreferences.setMockInitialValues({});
+ return Storage.open();
+}
+
+void main() {
+ test('recognizes paid and sponsored titles', () async {
+ final paywall = PaywallService(await _storage(), client: MockClient((_) async => http.Response('', 500)));
+ expect(paywall.isPaid(_article('heise+ | Essbare Batterie')), isTrue);
+ expect(paywall.isPaid(_article('(g+) Siri-Ersatz im Eigenbau')), isTrue);
+ expect(paywall.isPaid(_article('SPIEGEL+ Test')), isTrue);
+ expect(paywall.isPaid(_article('Apple reduziert iPhone-18-Pro-Bestellungen')), isFalse);
+ expect(paywall.isPaid(_article('OnePlus 16 vorgestellt')), isFalse);
+
+ expect(paywall.isSponsored(_article('Anzeige: Logitech-Set zum Sparpreis')), isTrue);
+ expect(paywall.isSponsored(_article('Werbung | Neues Angebot')), isTrue);
+ expect(paywall.isSponsored(_article('Anzeigenmarkt bricht ein')), isFalse);
+ });
+
+ test('checks pages only for flagged sources and remembers results', () async {
+ final storage = await _storage();
+ final requested = [];
+ final client = MockClient((request) async {
+ requested.add(request.url.path);
+ final free = request.url.path.contains('free');
+ return http.Response(
+ '',
+ 200);
+ });
+ final paywall = PaywallService(storage, client: client);
+ final paid = _article('Paid story', link: 'https://example.com/paid', checkPage: true);
+ final free = _article('Free story', link: 'https://example.com/free', checkPage: true);
+ final unflagged = _article('Other', link: 'https://example.com/other');
+ final titled = _article('heise+ | Known', link: 'https://example.com/titled', checkPage: true);
+
+ expect(await paywall.checkPages([paid, free, unflagged, titled]), isTrue);
+ expect(requested..sort(), ['/free', '/paid'], reason: 'unflagged and title-marked skipped');
+ expect(paywall.isPaid(paid), isTrue);
+ expect(paywall.isPaid(free), isFalse);
+
+ // A fresh service reads the saved results instead of fetching again.
+ requested.clear();
+ final again = PaywallService(storage, client: client);
+ expect(again.isPaid(paid), isTrue);
+ expect(await again.checkPages([paid, free]), isFalse);
+ expect(requested, isEmpty);
+ });
+
+ test('network errors are retried on the next refresh', () async {
+ final storage = await _storage();
+ var calls = 0;
+ final paywall = PaywallService(storage, client: MockClient((_) async {
+ calls++;
+ return http.Response('', 503);
+ }));
+ final a = _article('Story', link: 'https://example.com/a', checkPage: true);
+ await paywall.checkPages([a]);
+ await paywall.checkPages([a]);
+ expect(calls, 2);
+ expect(paywall.isPaid(a), isFalse);
+ });
+}
diff --git a/web/favicon.png b/web/favicon.png
new file mode 100644
index 0000000..210e293
Binary files /dev/null and b/web/favicon.png differ
diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png
new file mode 100644
index 0000000..81dc4e4
Binary files /dev/null and b/web/icons/Icon-192.png differ
diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png
new file mode 100644
index 0000000..99967e0
Binary files /dev/null and b/web/icons/Icon-512.png differ
diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png
new file mode 100644
index 0000000..81dc4e4
Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ
diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png
new file mode 100644
index 0000000..99967e0
Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..cb9404c
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tagesbyte
+
+
+
+
+
+
diff --git a/web/manifest.json b/web/manifest.json
new file mode 100644
index 0000000..d9875c6
--- /dev/null
+++ b/web/manifest.json
@@ -0,0 +1,35 @@
+{
+ "name": "Tagesbyte",
+ "short_name": "Tagesbyte",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#2231A6",
+ "theme_color": "#3355DD",
+ "description": "Daily tech news with AI summaries",
+ "orientation": "portrait-primary",
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "icons/Icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "icons/Icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/windows/.gitignore b/windows/.gitignore
new file mode 100644
index 0000000..d492d0d
--- /dev/null
+++ b/windows/.gitignore
@@ -0,0 +1,17 @@
+flutter/ephemeral/
+
+# Visual Studio user-specific files.
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Visual Studio build-related files.
+x64/
+x86/
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!*.[Cc]ache/
diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt
new file mode 100644
index 0000000..0a08c7e
--- /dev/null
+++ b/windows/CMakeLists.txt
@@ -0,0 +1,108 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.14)
+project(daily_tech_news LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "daily_tech_news")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(VERSION 3.14...3.25)
+
+# Define build configuration option.
+get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
+if(IS_MULTICONFIG)
+ set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
+ CACHE STRING "" FORCE)
+else()
+ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+ endif()
+endif()
+# Define settings for the Profile build mode.
+set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
+set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
+set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
+set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
+
+# Use Unicode for all projects.
+add_definitions(-DUNICODE -D_UNICODE)
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_17)
+ target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
+ target_compile_options(${TARGET} PRIVATE /EHsc)
+ target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
+ target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# Support files are copied into place next to the executable, so that it can
+# run in place. This is done instead of making a separate bundle (as on Linux)
+# so that building and running from within Visual Studio will work.
+set(BUILD_BUNDLE_DIR "$")
+# Make the "install" step default, as it's required to run.
+set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+if(PLUGIN_BUNDLED_LIBRARIES)
+ install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ CONFIGURATIONS Profile;Release
+ COMPONENT Runtime)
diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt
new file mode 100644
index 0000000..903f489
--- /dev/null
+++ b/windows/flutter/CMakeLists.txt
@@ -0,0 +1,109 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.14)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
+
+# Set fallback configurations for older versions of the flutter tool.
+if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
+ set(FLUTTER_TARGET_PLATFORM "windows-x64")
+endif()
+
+# === Flutter Library ===
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "flutter_export.h"
+ "flutter_windows.h"
+ "flutter_messenger.h"
+ "flutter_plugin_registrar.h"
+ "flutter_texture_registrar.h"
+)
+list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
+add_dependencies(flutter flutter_assemble)
+
+# === Wrapper ===
+list(APPEND CPP_WRAPPER_SOURCES_CORE
+ "core_implementations.cc"
+ "standard_codec.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
+ "plugin_registrar.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_APP
+ "flutter_engine.cc"
+ "flutter_view_controller.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
+
+# Wrapper sources needed for a plugin.
+add_library(flutter_wrapper_plugin STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+)
+apply_standard_settings(flutter_wrapper_plugin)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ POSITION_INDEPENDENT_CODE ON)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ CXX_VISIBILITY_PRESET hidden)
+target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
+target_include_directories(flutter_wrapper_plugin PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_plugin flutter_assemble)
+
+# Wrapper sources needed for the runner.
+add_library(flutter_wrapper_app STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
+apply_standard_settings(flutter_wrapper_app)
+target_link_libraries(flutter_wrapper_app PUBLIC flutter)
+target_include_directories(flutter_wrapper_app PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_app flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
+set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+ ${PHONY_OUTPUT}
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
+ ${FLUTTER_TARGET_PLATFORM} $
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..6dfc5b4
--- /dev/null
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,20 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+#include
+#include
+#include
+
+void RegisterPlugins(flutter::PluginRegistry* registry) {
+ FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
+ FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
+ UrlLauncherWindowsRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("UrlLauncherWindows"));
+}
diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..dc139d8
--- /dev/null
+++ b/windows/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void RegisterPlugins(flutter::PluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..eb1d58b
--- /dev/null
+++ b/windows/flutter/generated_plugins.cmake
@@ -0,0 +1,26 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+ flutter_inappwebview_windows
+ flutter_secure_storage_windows
+ url_launcher_windows
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt
new file mode 100644
index 0000000..394917c
--- /dev/null
+++ b/windows/runner/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.14)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME} WIN32
+ "flutter_window.cpp"
+ "main.cpp"
+ "utils.cpp"
+ "win32_window.cpp"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+ "Runner.rc"
+ "runner.exe.manifest"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the build version.
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
+
+# Disable Windows macros that collide with C++ standard library functions.
+target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
+
+# Add dependency libraries and include directories. Add any application-specific
+# dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
+target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc
new file mode 100644
index 0000000..152751b
--- /dev/null
+++ b/windows/runner/Runner.rc
@@ -0,0 +1,121 @@
+// Microsoft Visual C++ generated resource script.
+//
+#pragma code_page(65001)
+#include "resource.h"
+
+#define APSTUDIO_READONLY_SYMBOLS
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 2 resource.
+//
+#include "winres.h"
+
+/////////////////////////////////////////////////////////////////////////////
+#undef APSTUDIO_READONLY_SYMBOLS
+
+/////////////////////////////////////////////////////////////////////////////
+// English (United States) resources
+
+#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
+LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
+
+#ifdef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// TEXTINCLUDE
+//
+
+1 TEXTINCLUDE
+BEGIN
+ "resource.h\0"
+END
+
+2 TEXTINCLUDE
+BEGIN
+ "#include ""winres.h""\r\n"
+ "\0"
+END
+
+3 TEXTINCLUDE
+BEGIN
+ "\r\n"
+ "\0"
+END
+
+#endif // APSTUDIO_INVOKED
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Icon
+//
+
+// Icon with lowest ID value placed first to ensure application icon
+// remains consistent on all systems.
+IDI_APP_ICON ICON "resources\\app_icon.ico"
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Version
+//
+
+#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
+#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
+#else
+#define VERSION_AS_NUMBER 1,0,0,0
+#endif
+
+#if defined(FLUTTER_VERSION)
+#define VERSION_AS_STRING FLUTTER_VERSION
+#else
+#define VERSION_AS_STRING "1.0.0"
+#endif
+
+VS_VERSION_INFO VERSIONINFO
+ FILEVERSION VERSION_AS_NUMBER
+ PRODUCTVERSION VERSION_AS_NUMBER
+ FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+#ifdef _DEBUG
+ FILEFLAGS VS_FF_DEBUG
+#else
+ FILEFLAGS 0x0L
+#endif
+ FILEOS VOS__WINDOWS32
+ FILETYPE VFT_APP
+ FILESUBTYPE 0x0L
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904e4"
+ BEGIN
+ VALUE "CompanyName", "de.dailytechnews" "\0"
+ VALUE "FileDescription", "Tagesbyte" "\0"
+ VALUE "FileVersion", VERSION_AS_STRING "\0"
+ VALUE "InternalName", "daily_tech_news" "\0"
+ VALUE "LegalCopyright", "Copyright (C) 2026 de.dailytechnews. All rights reserved." "\0"
+ VALUE "OriginalFilename", "daily_tech_news.exe" "\0"
+ VALUE "ProductName", "Tagesbyte" "\0"
+ VALUE "ProductVersion", VERSION_AS_STRING "\0"
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1252
+ END
+END
+
+#endif // English (United States) resources
+/////////////////////////////////////////////////////////////////////////////
+
+
+
+#ifndef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 3 resource.
+//
+
+
+/////////////////////////////////////////////////////////////////////////////
+#endif // not APSTUDIO_INVOKED
diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp
new file mode 100644
index 0000000..955ee30
--- /dev/null
+++ b/windows/runner/flutter_window.cpp
@@ -0,0 +1,71 @@
+#include "flutter_window.h"
+
+#include
+
+#include "flutter/generated_plugin_registrant.h"
+
+FlutterWindow::FlutterWindow(const flutter::DartProject& project)
+ : project_(project) {}
+
+FlutterWindow::~FlutterWindow() {}
+
+bool FlutterWindow::OnCreate() {
+ if (!Win32Window::OnCreate()) {
+ return false;
+ }
+
+ RECT frame = GetClientArea();
+
+ // The size here must match the window dimensions to avoid unnecessary surface
+ // creation / destruction in the startup path.
+ flutter_controller_ = std::make_unique(
+ frame.right - frame.left, frame.bottom - frame.top, project_);
+ // Ensure that basic setup of the controller was successful.
+ if (!flutter_controller_->engine() || !flutter_controller_->view()) {
+ return false;
+ }
+ RegisterPlugins(flutter_controller_->engine());
+ SetChildContent(flutter_controller_->view()->GetNativeWindow());
+
+ flutter_controller_->engine()->SetNextFrameCallback([&]() {
+ this->Show();
+ });
+
+ // Flutter can complete the first frame before the "show window" callback is
+ // registered. The following call ensures a frame is pending to ensure the
+ // window is shown. It is a no-op if the first frame hasn't completed yet.
+ flutter_controller_->ForceRedraw();
+
+ return true;
+}
+
+void FlutterWindow::OnDestroy() {
+ if (flutter_controller_) {
+ flutter_controller_ = nullptr;
+ }
+
+ Win32Window::OnDestroy();
+}
+
+LRESULT
+FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ // Give Flutter, including plugins, an opportunity to handle window messages.
+ if (flutter_controller_) {
+ std::optional result =
+ flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
+ lparam);
+ if (result) {
+ return *result;
+ }
+ }
+
+ switch (message) {
+ case WM_FONTCHANGE:
+ flutter_controller_->engine()->ReloadSystemFonts();
+ break;
+ }
+
+ return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
+}
diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h
new file mode 100644
index 0000000..6da0652
--- /dev/null
+++ b/windows/runner/flutter_window.h
@@ -0,0 +1,33 @@
+#ifndef RUNNER_FLUTTER_WINDOW_H_
+#define RUNNER_FLUTTER_WINDOW_H_
+
+#include
+#include
+
+#include
+
+#include "win32_window.h"
+
+// A window that does nothing but host a Flutter view.
+class FlutterWindow : public Win32Window {
+ public:
+ // Creates a new FlutterWindow hosting a Flutter view running |project|.
+ explicit FlutterWindow(const flutter::DartProject& project);
+ virtual ~FlutterWindow();
+
+ protected:
+ // Win32Window:
+ bool OnCreate() override;
+ void OnDestroy() override;
+ LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
+ LPARAM const lparam) noexcept override;
+
+ private:
+ // The project to run.
+ flutter::DartProject project_;
+
+ // The Flutter instance hosted by this window.
+ std::unique_ptr flutter_controller_;
+};
+
+#endif // RUNNER_FLUTTER_WINDOW_H_
diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp
new file mode 100644
index 0000000..7f26240
--- /dev/null
+++ b/windows/runner/main.cpp
@@ -0,0 +1,43 @@
+#include
+#include
+#include
+
+#include "flutter_window.h"
+#include "utils.h"
+
+int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
+ _In_ wchar_t *command_line, _In_ int show_command) {
+ // Attach to console when present (e.g., 'flutter run') or create a
+ // new console when running with a debugger.
+ if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
+ CreateAndAttachConsole();
+ }
+
+ // Initialize COM, so that it is available for use in the library and/or
+ // plugins.
+ ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
+
+ flutter::DartProject project(L"data");
+
+ std::vector command_line_arguments =
+ GetCommandLineArguments();
+
+ project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
+
+ FlutterWindow window(project);
+ Win32Window::Point origin(10, 10);
+ Win32Window::Size size(1280, 720);
+ if (!window.Create(L"Tagesbyte", origin, size)) {
+ return EXIT_FAILURE;
+ }
+ window.SetQuitOnClose(true);
+
+ ::MSG msg;
+ while (::GetMessage(&msg, nullptr, 0, 0)) {
+ ::TranslateMessage(&msg);
+ ::DispatchMessage(&msg);
+ }
+
+ ::CoUninitialize();
+ return EXIT_SUCCESS;
+}
diff --git a/windows/runner/resource.h b/windows/runner/resource.h
new file mode 100644
index 0000000..66a65d1
--- /dev/null
+++ b/windows/runner/resource.h
@@ -0,0 +1,16 @@
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by Runner.rc
+//
+#define IDI_APP_ICON 101
+
+// Next default values for new objects
+//
+#ifdef APSTUDIO_INVOKED
+#ifndef APSTUDIO_READONLY_SYMBOLS
+#define _APS_NEXT_RESOURCE_VALUE 102
+#define _APS_NEXT_COMMAND_VALUE 40001
+#define _APS_NEXT_CONTROL_VALUE 1001
+#define _APS_NEXT_SYMED_VALUE 101
+#endif
+#endif
diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico
new file mode 100644
index 0000000..3647e72
Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ
diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest
new file mode 100644
index 0000000..153653e
--- /dev/null
+++ b/windows/runner/runner.exe.manifest
@@ -0,0 +1,14 @@
+
+
+
+
+ PerMonitorV2
+
+
+
+
+
+
+
+
+
diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp
new file mode 100644
index 0000000..3a0b465
--- /dev/null
+++ b/windows/runner/utils.cpp
@@ -0,0 +1,65 @@
+#include "utils.h"
+
+#include
+#include
+#include
+#include
+
+#include
+
+void CreateAndAttachConsole() {
+ if (::AllocConsole()) {
+ FILE *unused;
+ if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
+ _dup2(_fileno(stdout), 1);
+ }
+ if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
+ _dup2(_fileno(stdout), 2);
+ }
+ std::ios::sync_with_stdio();
+ FlutterDesktopResyncOutputStreams();
+ }
+}
+
+std::vector GetCommandLineArguments() {
+ // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
+ int argc;
+ wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
+ if (argv == nullptr) {
+ return std::vector();
+ }
+
+ std::vector command_line_arguments;
+
+ // Skip the first argument as it's the binary name.
+ for (int i = 1; i < argc; i++) {
+ command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
+ }
+
+ ::LocalFree(argv);
+
+ return command_line_arguments;
+}
+
+std::string Utf8FromUtf16(const wchar_t* utf16_string) {
+ if (utf16_string == nullptr) {
+ return std::string();
+ }
+ unsigned int target_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ -1, nullptr, 0, nullptr, nullptr)
+ -1; // remove the trailing null character
+ int input_length = (int)wcslen(utf16_string);
+ std::string utf8_string;
+ if (target_length == 0 || target_length > utf8_string.max_size()) {
+ return utf8_string;
+ }
+ utf8_string.resize(target_length);
+ int converted_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ input_length, utf8_string.data(), target_length, nullptr, nullptr);
+ if (converted_length == 0) {
+ return std::string();
+ }
+ return utf8_string;
+}
diff --git a/windows/runner/utils.h b/windows/runner/utils.h
new file mode 100644
index 0000000..3879d54
--- /dev/null
+++ b/windows/runner/utils.h
@@ -0,0 +1,19 @@
+#ifndef RUNNER_UTILS_H_
+#define RUNNER_UTILS_H_
+
+#include
+#include
+
+// Creates a console for the process, and redirects stdout and stderr to
+// it for both the runner and the Flutter library.
+void CreateAndAttachConsole();
+
+// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
+// encoded in UTF-8. Returns an empty std::string on failure.
+std::string Utf8FromUtf16(const wchar_t* utf16_string);
+
+// Gets the command line arguments passed in as a std::vector,
+// encoded in UTF-8. Returns an empty std::vector on failure.
+std::vector GetCommandLineArguments();
+
+#endif // RUNNER_UTILS_H_
diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp
new file mode 100644
index 0000000..60608d0
--- /dev/null
+++ b/windows/runner/win32_window.cpp
@@ -0,0 +1,288 @@
+#include "win32_window.h"
+
+#include
+#include
+
+#include "resource.h"
+
+namespace {
+
+/// Window attribute that enables dark mode window decorations.
+///
+/// Redefined in case the developer's machine has a Windows SDK older than
+/// version 10.0.22000.0.
+/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
+#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
+#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
+#endif
+
+constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
+
+/// Registry key for app theme preference.
+///
+/// A value of 0 indicates apps should use dark mode. A non-zero or missing
+/// value indicates apps should use light mode.
+constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
+ L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
+constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
+
+// The number of Win32Window objects that currently exist.
+static int g_active_window_count = 0;
+
+using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
+
+// Scale helper to convert logical scaler values to physical using passed in
+// scale factor
+int Scale(int source, double scale_factor) {
+ return static_cast(source * scale_factor);
+}
+
+// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
+// This API is only needed for PerMonitor V1 awareness mode.
+void EnableFullDpiSupportIfAvailable(HWND hwnd) {
+ HMODULE user32_module = LoadLibraryA("User32.dll");
+ if (!user32_module) {
+ return;
+ }
+ auto enable_non_client_dpi_scaling =
+ reinterpret_cast(
+ GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
+ if (enable_non_client_dpi_scaling != nullptr) {
+ enable_non_client_dpi_scaling(hwnd);
+ }
+ FreeLibrary(user32_module);
+}
+
+} // namespace
+
+// Manages the Win32Window's window class registration.
+class WindowClassRegistrar {
+ public:
+ ~WindowClassRegistrar() = default;
+
+ // Returns the singleton registrar instance.
+ static WindowClassRegistrar* GetInstance() {
+ if (!instance_) {
+ instance_ = new WindowClassRegistrar();
+ }
+ return instance_;
+ }
+
+ // Returns the name of the window class, registering the class if it hasn't
+ // previously been registered.
+ const wchar_t* GetWindowClass();
+
+ // Unregisters the window class. Should only be called if there are no
+ // instances of the window.
+ void UnregisterWindowClass();
+
+ private:
+ WindowClassRegistrar() = default;
+
+ static WindowClassRegistrar* instance_;
+
+ bool class_registered_ = false;
+};
+
+WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
+
+const wchar_t* WindowClassRegistrar::GetWindowClass() {
+ if (!class_registered_) {
+ WNDCLASS window_class{};
+ window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
+ window_class.lpszClassName = kWindowClassName;
+ window_class.style = CS_HREDRAW | CS_VREDRAW;
+ window_class.cbClsExtra = 0;
+ window_class.cbWndExtra = 0;
+ window_class.hInstance = GetModuleHandle(nullptr);
+ window_class.hIcon =
+ LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
+ window_class.hbrBackground = 0;
+ window_class.lpszMenuName = nullptr;
+ window_class.lpfnWndProc = Win32Window::WndProc;
+ RegisterClass(&window_class);
+ class_registered_ = true;
+ }
+ return kWindowClassName;
+}
+
+void WindowClassRegistrar::UnregisterWindowClass() {
+ UnregisterClass(kWindowClassName, nullptr);
+ class_registered_ = false;
+}
+
+Win32Window::Win32Window() {
+ ++g_active_window_count;
+}
+
+Win32Window::~Win32Window() {
+ --g_active_window_count;
+ Destroy();
+}
+
+bool Win32Window::Create(const std::wstring& title,
+ const Point& origin,
+ const Size& size) {
+ Destroy();
+
+ const wchar_t* window_class =
+ WindowClassRegistrar::GetInstance()->GetWindowClass();
+
+ const POINT target_point = {static_cast(origin.x),
+ static_cast(origin.y)};
+ HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
+ UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
+ double scale_factor = dpi / 96.0;
+
+ HWND window = CreateWindow(
+ window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
+ Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
+ Scale(size.width, scale_factor), Scale(size.height, scale_factor),
+ nullptr, nullptr, GetModuleHandle(nullptr), this);
+
+ if (!window) {
+ return false;
+ }
+
+ UpdateTheme(window);
+
+ return OnCreate();
+}
+
+bool Win32Window::Show() {
+ return ShowWindow(window_handle_, SW_SHOWNORMAL);
+}
+
+// static
+LRESULT CALLBACK Win32Window::WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ if (message == WM_NCCREATE) {
+ auto window_struct = reinterpret_cast(lparam);
+ SetWindowLongPtr(window, GWLP_USERDATA,
+ reinterpret_cast(window_struct->lpCreateParams));
+
+ auto that = static_cast(window_struct->lpCreateParams);
+ EnableFullDpiSupportIfAvailable(window);
+ that->window_handle_ = window;
+ } else if (Win32Window* that = GetThisFromHandle(window)) {
+ return that->MessageHandler(window, message, wparam, lparam);
+ }
+
+ return DefWindowProc(window, message, wparam, lparam);
+}
+
+LRESULT
+Win32Window::MessageHandler(HWND hwnd,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ switch (message) {
+ case WM_DESTROY:
+ window_handle_ = nullptr;
+ Destroy();
+ if (quit_on_close_) {
+ PostQuitMessage(0);
+ }
+ return 0;
+
+ case WM_DPICHANGED: {
+ auto newRectSize = reinterpret_cast(lparam);
+ LONG newWidth = newRectSize->right - newRectSize->left;
+ LONG newHeight = newRectSize->bottom - newRectSize->top;
+
+ SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
+ newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
+
+ return 0;
+ }
+ case WM_SIZE: {
+ RECT rect = GetClientArea();
+ if (child_content_ != nullptr) {
+ // Size and position the child window.
+ MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
+ rect.bottom - rect.top, TRUE);
+ }
+ return 0;
+ }
+
+ case WM_ACTIVATE:
+ if (child_content_ != nullptr) {
+ SetFocus(child_content_);
+ }
+ return 0;
+
+ case WM_DWMCOLORIZATIONCOLORCHANGED:
+ UpdateTheme(hwnd);
+ return 0;
+ }
+
+ return DefWindowProc(window_handle_, message, wparam, lparam);
+}
+
+void Win32Window::Destroy() {
+ OnDestroy();
+
+ if (window_handle_) {
+ DestroyWindow(window_handle_);
+ window_handle_ = nullptr;
+ }
+ if (g_active_window_count == 0) {
+ WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
+ }
+}
+
+Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
+ return reinterpret_cast(
+ GetWindowLongPtr(window, GWLP_USERDATA));
+}
+
+void Win32Window::SetChildContent(HWND content) {
+ child_content_ = content;
+ SetParent(content, window_handle_);
+ RECT frame = GetClientArea();
+
+ MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
+ frame.bottom - frame.top, true);
+
+ SetFocus(child_content_);
+}
+
+RECT Win32Window::GetClientArea() {
+ RECT frame;
+ GetClientRect(window_handle_, &frame);
+ return frame;
+}
+
+HWND Win32Window::GetHandle() {
+ return window_handle_;
+}
+
+void Win32Window::SetQuitOnClose(bool quit_on_close) {
+ quit_on_close_ = quit_on_close;
+}
+
+bool Win32Window::OnCreate() {
+ // No-op; provided for subclasses.
+ return true;
+}
+
+void Win32Window::OnDestroy() {
+ // No-op; provided for subclasses.
+}
+
+void Win32Window::UpdateTheme(HWND const window) {
+ DWORD light_mode;
+ DWORD light_mode_size = sizeof(light_mode);
+ LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
+ kGetPreferredBrightnessRegValue,
+ RRF_RT_REG_DWORD, nullptr, &light_mode,
+ &light_mode_size);
+
+ if (result == ERROR_SUCCESS) {
+ BOOL enable_dark_mode = light_mode == 0;
+ DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
+ &enable_dark_mode, sizeof(enable_dark_mode));
+ }
+}
diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h
new file mode 100644
index 0000000..e901dde
--- /dev/null
+++ b/windows/runner/win32_window.h
@@ -0,0 +1,102 @@
+#ifndef RUNNER_WIN32_WINDOW_H_
+#define RUNNER_WIN32_WINDOW_H_
+
+#include
+
+#include
+#include
+#include
+
+// A class abstraction for a high DPI-aware Win32 Window. Intended to be
+// inherited from by classes that wish to specialize with custom
+// rendering and input handling
+class Win32Window {
+ public:
+ struct Point {
+ unsigned int x;
+ unsigned int y;
+ Point(unsigned int x, unsigned int y) : x(x), y(y) {}
+ };
+
+ struct Size {
+ unsigned int width;
+ unsigned int height;
+ Size(unsigned int width, unsigned int height)
+ : width(width), height(height) {}
+ };
+
+ Win32Window();
+ virtual ~Win32Window();
+
+ // Creates a win32 window with |title| that is positioned and sized using
+ // |origin| and |size|. New windows are created on the default monitor. Window
+ // sizes are specified to the OS in physical pixels, hence to ensure a
+ // consistent size this function will scale the inputted width and height as
+ // as appropriate for the default monitor. The window is invisible until
+ // |Show| is called. Returns true if the window was created successfully.
+ bool Create(const std::wstring& title, const Point& origin, const Size& size);
+
+ // Show the current window. Returns true if the window was successfully shown.
+ bool Show();
+
+ // Release OS resources associated with window.
+ void Destroy();
+
+ // Inserts |content| into the window tree.
+ void SetChildContent(HWND content);
+
+ // Returns the backing Window handle to enable clients to set icon and other
+ // window properties. Returns nullptr if the window has been destroyed.
+ HWND GetHandle();
+
+ // If true, closing this window will quit the application.
+ void SetQuitOnClose(bool quit_on_close);
+
+ // Return a RECT representing the bounds of the current client area.
+ RECT GetClientArea();
+
+ protected:
+ // Processes and route salient window messages for mouse handling,
+ // size change and DPI. Delegates handling of these to member overloads that
+ // inheriting classes can handle.
+ virtual LRESULT MessageHandler(HWND window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Called when CreateAndShow is called, allowing subclass window-related
+ // setup. Subclasses should return false if setup fails.
+ virtual bool OnCreate();
+
+ // Called when Destroy is called.
+ virtual void OnDestroy();
+
+ private:
+ friend class WindowClassRegistrar;
+
+ // OS callback called by message pump. Handles the WM_NCCREATE message which
+ // is passed when the non-client area is being created and enables automatic
+ // non-client DPI scaling so that the non-client area automatically
+ // responds to changes in DPI. All other messages are handled by
+ // MessageHandler.
+ static LRESULT CALLBACK WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Retrieves a class instance pointer for |window|
+ static Win32Window* GetThisFromHandle(HWND const window) noexcept;
+
+ // Update the window frame's theme to match the system theme.
+ static void UpdateTheme(HWND const window);
+
+ bool quit_on_close_ = false;
+
+ // window handle for top level window.
+ HWND window_handle_ = nullptr;
+
+ // window handle for hosted content.
+ HWND child_content_ = nullptr;
+};
+
+#endif // RUNNER_WIN32_WINDOW_H_