diff --git a/.metadata b/.metadata
index 98d77df..7a36f9b 100644
--- a/.metadata
+++ b/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
+ revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
channel: "stable"
project_type: app
@@ -13,17 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
- create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- - platform: android
- create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: linux
- create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- - platform: windows
- create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
# User provided section
diff --git a/README.md b/README.md
index edfbd46..15ff7cf 100644
--- a/README.md
+++ b/README.md
@@ -101,4 +101,4 @@ ISCC.exe installer\Lunaris.iss
- Приложение уважает API и rate limits сайтов.
- NSFW/blur/blacklist настраиваются локально.
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
-- Локальные пути скачанных файлов не попадают в JSON export/import.
+- Локальные пути скачанных файлов не попадают в JSON export/import.
diff --git a/android/.gitignore b/android/.gitignore
index be82512..a3124d9 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -1,11 +1,11 @@
/.gradle
/captures/
/local.properties
-GeneratedPluginRegistrant.java
-.cxx/
-
-# Remember to never publicly share your keystore.
-# See https://flutter.dev/to/reference-keystore
-key.properties
-**/*.keystore
-**/*.jks
+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
index 55a5b1f..9ae91d0 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,44 +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 = "com.example.gel_rule_app"
- compileSdk = flutter.compileSdkVersion
- ndkVersion = flutter.ndkVersion
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
-
- defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.example.gel_rule_app"
- // You can update the following values to match your application needs.
- // For more information, see: https://flutter.dev/to/review-gradle-config.
- minSdk = flutter.minSdkVersion
- targetSdk = flutter.targetSdkVersion
- versionCode = flutter.versionCode
- versionName = flutter.versionName
- }
-
- buildTypes {
- release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
- }
- }
-}
-
+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 = "com.example.gel_rule_app"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.gel_rule_app"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
flutter {
source = "../.."
}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f698..8ffe024 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -1,7 +1,7 @@
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index daf8498..956e680 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,32 +4,32 @@
-
-
-
-
-
-
-
-
-
+ android:name="${applicationName}"
+ android:icon="@mipmap/ic_launcher">
+
+
+
+
+
+
+
+
+
@@ -43,15 +43,15 @@
android:resource="@xml/file_paths" />
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index d3f4435..2a144ea 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,12 @@
-
-
-
+
+
+
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index d3f4435..2a144ea 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,12 @@
-
-
-
+
+
+
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 06952be..360a160 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -1,18 +1,18 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 77c9133..503d363 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,17 +1,17 @@
-
-
-
+
+
+
-
+
+ - @drawable/launch_background
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
index 399f698..8ffe024 100644
--- a/android/app/src/profile/AndroidManifest.xml
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -1,7 +1,7 @@
-
-
-
-
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 6141170..7a89f0b 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -4,19 +4,19 @@ 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)
-}
+ }
+}
+
+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")
}
diff --git a/android/gradle.properties b/android/gradle.properties
index fbee1d8..d5da727 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index e4ef43f..db3f453 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/android/gradlew.bat b/android/gradlew.bat
index 8a0b282..aec9973 100644
--- a/android/gradlew.bat
+++ b/android/gradlew.bat
@@ -1,90 +1,90 @@
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto init
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windowz variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-if "%@eval[2+2]" == "4" goto 4NT_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-goto execute
-
-:4NT_args
-@rem Get arguments from the 4NT Shell from JP Software
-set CMD_LINE_ARGS=%$
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index ca7fe06..4dcef4b 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -1,26 +1,26 @@
-pluginManagement {
- val flutterSdkPath =
- run {
- val properties = java.util.Properties()
- file("local.properties").inputStream().use { properties.load(it) }
- val flutterSdkPath = properties.getProperty("flutter.sdk")
- require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
- flutterSdkPath
- }
-
- includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
-
- repositories {
- google()
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-plugins {
- id("dev.flutter.flutter-plugin-loader") version "1.0.0"
- id("com.android.application") version "8.11.1" apply false
- id("org.jetbrains.kotlin.android") version "2.2.20" apply false
-}
-
-include(":app")
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 361f0f7..62404ad 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.1';
-const appBuildNumber = 21;
+const appBuildNumber = 22;
const appReleaseChannel = 'Stable';
diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart
index a9c53df..cf8165d 100644
--- a/lib/features/feed/presentation/feed_screen.dart
+++ b/lib/features/feed/presentation/feed_screen.dart
@@ -32,7 +32,7 @@ class _FeedScreenState extends ConsumerState {
final _scrollController = ScrollController();
final Set _selectedKeys = {};
bool _selectionMode = false;
- bool _usedInitialQuery = false;
+ String? _appliedInitialQuery;
Timer? _scrollSaveDebounce;
@override
@@ -70,10 +70,10 @@ class _FeedScreenState extends ConsumerState {
if (nextQuery != null &&
nextQuery.isNotEmpty &&
nextQuery != oldWidget.initialQuery) {
- _usedInitialQuery = true;
+ _appliedInitialQuery = nextQuery;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
- ref.read(feedControllerProvider.notifier).search(nextQuery);
+ _applySearchQuery(nextQuery);
});
}
}
@@ -93,10 +93,16 @@ class _FeedScreenState extends ConsumerState {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? {};
- if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
- _usedInitialQuery = true;
+ final initialQuery = widget.initialQuery?.trim();
+ final currentTags = feed.value?.selectedTags.join(' ');
+ if (initialQuery != null &&
+ initialQuery.isNotEmpty &&
+ _appliedInitialQuery != initialQuery &&
+ currentTags != initialQuery) {
+ _appliedInitialQuery = initialQuery;
WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(feedControllerProvider.notifier).search(widget.initialQuery!);
+ if (!mounted) return;
+ _applySearchQuery(initialQuery);
});
}
@@ -159,17 +165,14 @@ class _FeedScreenState extends ConsumerState {
onSearchChanged: (query) => ref
.read(feedControllerProvider.notifier)
.updateTagSuggestions(query),
- onSuggestionTap: (query) =>
- ref.read(feedControllerProvider.notifier).search(query),
+ onSuggestionTap: _submitSearch,
onTopPeriodChanged: (period) => ref
.read(feedControllerProvider.notifier)
.setTopPeriod(period),
- onSearch: (query) =>
- ref.read(feedControllerProvider.notifier).search(query),
+ onSearch: _submitSearch,
onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(),
- onClearFilters: () =>
- ref.read(feedControllerProvider.notifier).clearFilters(),
+ onClearFilters: _clearFilters,
selectionMode: _selectionMode,
onToggleSelectionMode: () {
setState(() {
@@ -279,6 +282,35 @@ class _FeedScreenState extends ConsumerState {
);
}
+ void _submitSearch(String query) {
+ final trimmed = query.trim();
+ if (trimmed.isEmpty) {
+ _clearFilters();
+ return;
+ }
+ final location = '/?q=${Uri.encodeQueryComponent(trimmed)}';
+ if (widget.initialQuery?.trim() == trimmed) {
+ _applySearchQuery(trimmed);
+ return;
+ }
+ context.go(location);
+ }
+
+ Future _applySearchQuery(String query) async {
+ await ref.read(feedControllerProvider.notifier).search(query);
+ if (_scrollController.hasClients) {
+ _scrollController.jumpTo(0);
+ }
+ }
+
+ Future _clearFilters() async {
+ _appliedInitialQuery = null;
+ await ref.read(feedControllerProvider.notifier).clearFilters();
+ if (mounted && widget.initialQuery != null) {
+ context.go('/');
+ }
+ }
+
void _toggleSelected(Post post) {
setState(() {
_selectionMode = true;
diff --git a/lib/features/post/presentation/post_details_controller.dart b/lib/features/post/presentation/post_details_controller.dart
index 4b5768f..bc65ba5 100644
--- a/lib/features/post/presentation/post_details_controller.dart
+++ b/lib/features/post/presentation/post_details_controller.dart
@@ -3,33 +3,57 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
+// Меняем FutureProvider на StreamProvider
final postDetailsControllerProvider =
- FutureProvider.family((ref, args) async {
+ StreamProvider.family((ref, args) async* {
+ // Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
+ final providerManager = ref.watch(providerManagerProvider);
+ final cacheService = ref.watch(cacheServiceProvider);
+ final postRepository = ref.watch(postRepositoryProvider);
+
+ // Вспомогательная функция для загрузки тегов
Future enrich(Post? post) async {
if (post == null) return null;
final shouldEnrich = post.tagGroups.isEmpty ||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
if (!shouldEnrich) return post;
- final enriched =
- await ref.watch(providerManagerProvider).enrichPostTags(post);
+
+ final enriched = await providerManager.enrichPostTags(post);
if (enriched is Success) {
- await ref.watch(cacheServiceProvider).cachePosts([enriched.data]);
+ await cacheService.cachePosts([enriched.data]);
return enriched.data;
}
return post;
}
- if (args.initialPost != null) return enrich(args.initialPost);
- final cached = await ref
- .watch(postRepositoryProvider)
- .getCachedPost(args.postId, args.providerId);
+ // СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай)
+ if (args.initialPost != null) {
+ yield args
+ .initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится!
+ final enriched = await enrich(args.initialPost);
+ yield enriched; // Чуть позже отдаем версию с загруженными тегами
+ return;
+ }
+
+ // СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш
+ final cached =
+ await postRepository.getCachedPost(args.postId, args.providerId);
if (cached is Success && cached.data != null) {
- return enrich(cached.data);
+ yield cached.data; // Нашли в кэше — сразу показываем контент
+ final enriched = await enrich(cached.data);
+ yield enriched; // Догружаем теги на фоне
+ return;
+ }
+
+ // СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом
+ final remote = await providerManager.getPost(args.providerId, args.postId);
+ if (remote is Success && remote.data != null) {
+ yield remote.data;
+ final enriched = await enrich(remote.data);
+ yield enriched;
+ } else {
+ yield null;
}
- final remote = await ref
- .watch(providerManagerProvider)
- .getPost(args.providerId, args.postId);
- return remote is Success ? enrich(remote.data) : null;
});
class PostDetailsArgs {
diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart
index 9b7b56e..3bc2e5f 100644
--- a/lib/features/post/presentation/post_details_screen.dart
+++ b/lib/features/post/presentation/post_details_screen.dart
@@ -1,872 +1,866 @@
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:go_router/go_router.dart';
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:url_launcher/url_launcher.dart';
-
-import '../../../app/app.dart';
-import '../../../app/app_strings.dart';
-import '../../../app/motion.dart';
-import '../../../app/responsive.dart';
-import '../../../backend/backend.dart';
-import '../../../core/utils/result.dart';
-import '../../../shared/widgets/adaptive_scaffold.dart';
-import '../../../shared/widgets/empty_view.dart';
-import '../../../shared/widgets/error_view.dart';
-import '../../../shared/widgets/post_card.dart';
-import '../../../shared/widgets/rating_badge.dart';
-import '../../collections/presentation/collection_form_dialog.dart';
-import '../../favorites/presentation/favorites_controller.dart';
-import '../../feed/presentation/feed_controller.dart';
-import '../../viewed/presentation/viewed_controller.dart';
-import 'post_details_controller.dart';
-import 'widgets/post_action_bar.dart';
-import 'widgets/post_media_viewer.dart';
-import 'widgets/post_tags_panel.dart';
-
-final postCommentsProvider =
- FutureProvider.family, PostDetailsArgs>(
- (ref, args) async {
- final result = await ref
- .watch(providerManagerProvider)
- .getComments(args.providerId, args.postId);
- return result is Success> ? result.data : const [];
-});
-
-class PostDetailsScreen extends ConsumerWidget {
- const PostDetailsScreen({
- required this.providerId,
- required this.postId,
- this.initialPost,
- super.key,
- });
-
- final String providerId;
- final String postId;
- final Post? initialPost;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final args = PostDetailsArgs(
- providerId: providerId,
- postId: postId,
- initialPost: initialPost,
- );
- final post = ref.watch(postDetailsControllerProvider(args));
- final settings =
- ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
- final strings = ref.watch(appStringsProvider);
- final feedPosts =
- ref.watch(feedControllerProvider).value?.posts ?? const [];
- final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
- return AdaptiveScaffold(
- title: strings.post,
- actions: [
- IconButton(
- tooltip: strings.close,
- onPressed: () => _close(context),
- icon: const Icon(Icons.close_rounded),
- ),
- ],
- body: post.when(
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => ErrorView(message: error.toString()),
- data: (post) {
- if (post == null) return const EmptyView(title: 'Post not found');
- WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(viewedHistoryServiceProvider).markViewed(post);
- ref.invalidate(viewedKeysProvider);
- ref.invalidate(viewedControllerProvider);
- });
- final currentIndex = feedPosts.indexWhere(
- (item) => item.providerId == post.providerId && item.id == post.id,
- );
- final previous =
- currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
- final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
- ? feedPosts[currentIndex + 1]
- : null;
- final qualityMode =
- MediaQualityMode.fromName(settings.mediaQualityMode);
- if (Responsive.isMobile(context)) {
- if (currentIndex >= 0 && feedPosts.length > 1) {
- return _MobilePostPager(
- posts: feedPosts,
- initialIndex: currentIndex,
- buildDetails: (context, post) => _buildMobileDetails(
- context,
- ref,
- post,
- settings,
- favoriteKeys,
- qualityMode,
- strings,
- ),
- );
- }
- return _buildMobileDetails(
- context,
- ref,
- post,
- settings,
- favoriteKeys,
- qualityMode,
- strings,
- );
- }
- return Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.arrowLeft):
- const _PreviousPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.arrowRight):
- const _NextPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyF):
- const _ToggleFavoriteIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyC):
- const _AddCollectionIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
- LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
- },
- child: Actions(
- actions: {
- _PreviousPostIntent: CallbackAction<_PreviousPostIntent>(
- onInvoke: (_) {
- if (previous != null) _openPost(context, previous);
- return null;
- },
- ),
- _NextPostIntent: CallbackAction<_NextPostIntent>(
- onInvoke: (_) {
- if (next != null) _openPost(context, next);
- return null;
- },
- ),
- _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>(
- onInvoke: (_) {
- _toggleFavorite(ref, post, favoriteKeys);
- return null;
- },
- ),
- _AddCollectionIntent: CallbackAction<_AddCollectionIntent>(
- onInvoke: (_) {
- _addToCollection(context, ref, post);
- return null;
- },
- ),
- _CloseIntent: CallbackAction<_CloseIntent>(
- onInvoke: (_) {
- _close(context);
- return null;
- },
- ),
- _DownloadIntent: CallbackAction<_DownloadIntent>(
- onInvoke: (_) {
- if (settings.allowDownloads) {
- _download(context, ref, post);
- }
- return null;
- },
- ),
- },
- child: Focus(
- autofocus: true,
- child: ListView(
- padding: const EdgeInsets.all(16),
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- IconButton.filledTonal(
- tooltip:
- strings.ru ? 'Предыдущий' : 'Previous',
- onPressed: previous == null
- ? null
- : () => _openPost(context, previous),
- icon: const Icon(Icons.chevron_left_rounded),
- ),
- Expanded(
- child: Center(
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxHeight: 760),
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- qualityMode: qualityMode,
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume:
- settings.videoPlayerHalfVolume,
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- ),
- ),
- ),
- ),
- IconButton.filledTonal(
- tooltip: strings.ru ? 'Следующий' : 'Next',
- onPressed: next == null
- ? null
- : () => _openPost(context, next),
- icon: const Icon(Icons.chevron_right_rounded),
- ),
- ],
- ),
- if (currentIndex >= 0) ...[
- const SizedBox(height: 10),
- _NeighborStrip(
- posts: feedPosts,
- currentIndex: currentIndex,
- onOpen: (post) => _openPost(context, post),
- ),
- ],
- const SizedBox(height: 16),
- PostActionBar(
- isFavorite: favoriteKeys.contains(post.cacheKey),
- labels: _postActionLabels(strings),
- downloaded: ref
- .watch(
- downloadedMediaByKeyProvider(post.cacheKey))
- .value !=
- null,
- onFavorite: () =>
- _toggleFavorite(ref, post, favoriteKeys),
- onCollection: () => _addToCollection(context, ref, post),
- onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
- onCopy: () =>
- Clipboard.setData(ClipboardData(text: post.fileUrl)),
- onSimilar: () => _openSimilar(context, ref, post),
- onHide: () => _hidePost(context, ref, post),
- onDownload: settings.allowDownloads
- ? () => _download(context, ref, post)
- : null,
- onDeleteLocalFile: () =>
- _deleteLocalFile(context, ref, post),
- ),
- const SizedBox(height: 16),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- Chip(label: Text(post.providerName)),
- RatingBadge(rating: post.rating),
- Chip(label: Text('${post.width} x ${post.height}')),
- if (post.source != null && post.source!.isNotEmpty)
- ActionChip(
- label: Text(strings.source),
- onPressed: () => launchUrl(Uri.parse(post.source!)),
- ),
- ],
- ),
- const SizedBox(height: 16),
- Text(strings.tags,
- style: Theme.of(context).textTheme.titleLarge),
- const SizedBox(height: 8),
- PostTagsPanel(post: post),
- const SizedBox(height: 16),
- _CommentsSection(post: post),
- ],
- ),
- ),
- ),
- );
- },
- ),
- );
- }
-
- Widget _buildMobileDetails(
- BuildContext context,
- WidgetRef ref,
- Post post,
- AppSettings settings,
- Set favoriteKeys,
- MediaQualityMode qualityMode,
- AppStrings strings,
- ) {
- final isVideo = MediaUrlSelector.isVideo(post);
- return GestureDetector(
- behavior: HitTestBehavior.translucent,
- onVerticalDragEnd: (details) {
- if ((details.primaryVelocity ?? 0) > 900) _close(context);
- },
- child: ListView(
- padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
- children: [
- GestureDetector(
- behavior: HitTestBehavior.deferToChild,
- onDoubleTap:
- isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
- onLongPress: isVideo
- ? null
- : () =>
- _showMobileQuickActions(context, ref, post, favoriteKeys),
- child: ConstrainedBox(
- constraints: BoxConstraints(
- maxHeight: MediaQuery.sizeOf(context).height * 0.62,
- ),
- child: Center(
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- qualityMode: qualityMode,
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume: settings.videoPlayerHalfVolume,
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- ),
- ),
- ),
- ),
- const SizedBox(height: 12),
- PostActionBar(
- isFavorite: favoriteKeys.contains(post.cacheKey),
- labels: _postActionLabels(strings),
- downloaded:
- ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
- null,
- onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
- onCollection: () => _addToCollection(context, ref, post),
- onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
- onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
- onSimilar: () => _openSimilar(context, ref, post),
- onHide: () => _hidePost(context, ref, post),
- onDownload: settings.allowDownloads
- ? () => _download(context, ref, post)
- : null,
- onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
- ),
- const SizedBox(height: 12),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- Chip(label: Text(post.providerName)),
- RatingBadge(rating: post.rating),
- Chip(label: Text('${post.width} x ${post.height}')),
- if (post.source != null && post.source!.isNotEmpty)
- ActionChip(
- label: Text(strings.source),
- onPressed: () => launchUrl(Uri.parse(post.source!)),
- ),
- ],
- ),
- const SizedBox(height: 8),
- ExpansionTile(
- tilePadding: EdgeInsets.zero,
- initiallyExpanded: false,
- title: Text(strings.tags,
- style: Theme.of(context).textTheme.titleMedium),
- children: [
- Align(
- alignment: Alignment.centerLeft,
- child: PostTagsPanel(post: post),
- ),
- ],
- ),
- _CommentsSection(post: post),
- ],
- ),
- );
- }
-
- Future _addToCollection(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- final result = await ref.read(collectionServiceProvider).getCollections();
- final collections =
- result is Success> ? result.data : [];
- if (!context.mounted) return;
- await showAddToCollectionPicker(
- context,
- collections: collections,
- onSelected: (collection) {
- ref
- .read(collectionServiceProvider)
- .addPostToCollection(collection.id, post);
- },
- onCreate: () => showCollectionFormDialog(context, ref),
- );
- }
-
- Future _download(BuildContext context, WidgetRef ref, Post post) async {
- if (!context.mounted) return;
- await ref.read(downloadManagerServiceProvider).start(post);
- if (!context.mounted) return;
- final strings = ref.read(appStringsProvider);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(strings.ru
- ? 'Скачивание началось'
- : 'Download started'),
- ),
- );
- Future.delayed(const Duration(seconds: 2), () {
- ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
- });
- }
-
- Future _showMobileQuickActions(
- BuildContext context,
- WidgetRef ref,
- Post post,
- Set favoriteKeys,
- ) async {
- await showModalBottomSheet(
- context: context,
- showDragHandle: true,
- builder: (sheetContext) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- leading: Icon(
- favoriteKeys.contains(post.cacheKey)
- ? Icons.favorite_rounded
- : Icons.favorite_border_rounded,
- ),
- title: Text(
- favoriteKeys.contains(post.cacheKey)
- ? 'Remove favorite'
- : 'Favorite',
- ),
- onTap: () async {
- Navigator.pop(sheetContext);
- await _toggleFavorite(ref, post, favoriteKeys);
- },
- ),
- ListTile(
- leading: const Icon(Icons.collections_bookmark_rounded),
- title: const Text('Add to collection'),
- onTap: () {
- Navigator.pop(sheetContext);
- _addToCollection(context, ref, post);
- },
- ),
- ListTile(
- leading: const Icon(Icons.copy_rounded),
- title: const Text('Copy link'),
- onTap: () {
- Clipboard.setData(ClipboardData(text: post.fileUrl));
- Navigator.pop(sheetContext);
- },
- ),
- ListTile(
- leading: const Icon(Icons.download_rounded),
- title: const Text('Download'),
- onTap: () {
- Navigator.pop(sheetContext);
- _download(context, ref, post);
- },
- ),
- ],
- ),
- ),
- );
- }
-
- Future _toggleFavorite(
- WidgetRef ref,
- Post post,
- Set favoriteKeys,
- ) async {
- if (favoriteKeys.contains(post.cacheKey)) {
- await ref
- .read(favoriteServiceProvider)
- .removeFavorite(post.id, post.providerId);
- } else {
- await ref.read(favoriteServiceProvider).addFavorite(post);
- await _maybeAutoDownloadFavorite(ref, post);
- }
- ref.invalidate(favoriteKeysProvider);
- ref.invalidate(favoritesControllerProvider);
- }
-
- Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
- final settings =
- ref.read(appSettingsProvider).value ?? AppSettings.defaults;
- if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
- await ref.read(downloadManagerServiceProvider).start(post);
- }
-
- Future _deleteLocalFile(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- final strings = ref.read(appStringsProvider);
- await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
- post.cacheKey,
- );
- ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
- if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(strings.deleteLocalFile)),
- );
- }
-
- PostActionLabels _postActionLabels(AppStrings strings) {
- return PostActionLabels(
- favorite: strings.favorite,
- unfavorite: strings.removeFavorite,
- collection: strings.collection,
- similar: strings.similar,
- openOriginal: strings.open,
- copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
- download: strings.download,
- deleteLocalFile: strings.deleteLocalFile,
- hidePost: strings.hidePost,
- );
- }
-
- Future _hidePost(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
- ref.invalidate(appSettingsProvider);
- ref.read(feedControllerProvider.notifier).refresh();
- if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Post hidden locally')),
- );
- _close(context);
- }
-
- void _openPost(BuildContext context, Post post) {
- _replacePost(context, post);
- }
-
- void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
- context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
- }
-
- void _replacePost(BuildContext context, Post post) {
- context.replace('/post/${post.providerId}/${post.id}', extra: post);
- }
-
- void _close(BuildContext context) {
- if (Navigator.of(context).canPop()) {
- context.pop();
- return;
- }
- context.go('/');
- }
-
- Future _saveVideoPreferences(
- WidgetRef ref,
- VideoPlaybackSnapshot snapshot,
- ) async {
- final result = await ref.read(settingsServiceProvider).getSettings();
- if (result is! Success) return;
- final settings = result.data;
- if (settings.videoPlayerMuted == snapshot.muted &&
- settings.videoPlayerHalfVolume == snapshot.halfVolume &&
- settings.videoPlayerLoop == snapshot.loopVideo &&
- settings.videoPlayerCover == snapshot.coverVideo) {
- return;
- }
- await ref.read(settingsServiceProvider).updateSettings(
- settings.copyWith(
- videoPlayerMuted: snapshot.muted,
- videoPlayerHalfVolume: snapshot.halfVolume,
- videoPlayerLoop: snapshot.loopVideo,
- videoPlayerCover: snapshot.coverVideo,
- ),
- );
- ref.invalidate(appSettingsProvider);
- }
-}
-
-class _MobilePostPager extends StatefulWidget {
- const _MobilePostPager({
- required this.posts,
- required this.initialIndex,
- required this.buildDetails,
- });
-
- final List posts;
- final int initialIndex;
- final Widget Function(BuildContext context, Post post) buildDetails;
-
- @override
- State<_MobilePostPager> createState() => _MobilePostPagerState();
-}
-
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../app/app.dart';
+import '../../../app/app_strings.dart';
+import '../../../app/motion.dart';
+import '../../../app/responsive.dart';
+import '../../../backend/backend.dart';
+import '../../../core/utils/result.dart';
+import '../../../shared/widgets/adaptive_scaffold.dart';
+import '../../../shared/widgets/empty_view.dart';
+import '../../../shared/widgets/error_view.dart';
+import '../../../shared/widgets/post_card.dart';
+import '../../../shared/widgets/rating_badge.dart';
+import '../../collections/presentation/collection_form_dialog.dart';
+import '../../favorites/presentation/favorites_controller.dart';
+import '../../feed/presentation/feed_controller.dart';
+import '../../viewed/presentation/viewed_controller.dart';
+import 'post_details_controller.dart';
+import 'widgets/post_action_bar.dart';
+import 'widgets/post_media_viewer.dart';
+import 'widgets/post_tags_panel.dart';
+
+final postCommentsProvider =
+ FutureProvider.family, PostDetailsArgs>(
+ (ref, args) async {
+ final result = await ref
+ .watch(providerManagerProvider)
+ .getComments(args.providerId, args.postId);
+ return result is Success> ? result.data : const [];
+});
+
+class PostDetailsScreen extends ConsumerWidget {
+ const PostDetailsScreen({
+ required this.providerId,
+ required this.postId,
+ this.initialPost,
+ super.key,
+ });
+
+ final String providerId;
+ final String postId;
+ final Post? initialPost;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final args = PostDetailsArgs(
+ providerId: providerId,
+ postId: postId,
+ initialPost: initialPost,
+ );
+ final post = ref.watch(postDetailsControllerProvider(args));
+ final settings =
+ ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
+ final strings = ref.watch(appStringsProvider);
+ final feedPosts =
+ ref.watch(feedControllerProvider).value?.posts ?? const [];
+ final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
+ return AdaptiveScaffold(
+ title: strings.post,
+ actions: [
+ IconButton(
+ tooltip: strings.close,
+ onPressed: () => _close(context),
+ icon: const Icon(Icons.close_rounded),
+ ),
+ ],
+ body: post.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) => ErrorView(message: error.toString()),
+ data: (post) {
+ if (post == null) return const EmptyView(title: 'Post not found');
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(viewedHistoryServiceProvider).markViewed(post);
+ ref.invalidate(viewedKeysProvider);
+ ref.invalidate(viewedControllerProvider);
+ });
+ final currentIndex = feedPosts.indexWhere(
+ (item) => item.providerId == post.providerId && item.id == post.id,
+ );
+ final previous =
+ currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
+ final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
+ ? feedPosts[currentIndex + 1]
+ : null;
+ final qualityMode =
+ MediaQualityMode.fromName(settings.mediaQualityMode);
+ if (Responsive.isMobile(context)) {
+ if (currentIndex >= 0 && feedPosts.length > 1) {
+ return _MobilePostPager(
+ posts: feedPosts,
+ initialIndex: currentIndex,
+ buildDetails: (context, post) => _buildMobileDetails(
+ context,
+ ref,
+ post,
+ settings,
+ favoriteKeys,
+ qualityMode,
+ strings,
+ ),
+ );
+ }
+ return _buildMobileDetails(
+ context,
+ ref,
+ post,
+ settings,
+ favoriteKeys,
+ qualityMode,
+ strings,
+ );
+ }
+ return Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.arrowLeft):
+ const _PreviousPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.arrowRight):
+ const _NextPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyF):
+ const _ToggleFavoriteIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyC):
+ const _AddCollectionIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
+ LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
+ },
+ child: Actions(
+ actions: {
+ _PreviousPostIntent: CallbackAction<_PreviousPostIntent>(
+ onInvoke: (_) {
+ if (previous != null) _openPost(context, previous);
+ return null;
+ },
+ ),
+ _NextPostIntent: CallbackAction<_NextPostIntent>(
+ onInvoke: (_) {
+ if (next != null) _openPost(context, next);
+ return null;
+ },
+ ),
+ _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>(
+ onInvoke: (_) {
+ _toggleFavorite(ref, post, favoriteKeys);
+ return null;
+ },
+ ),
+ _AddCollectionIntent: CallbackAction<_AddCollectionIntent>(
+ onInvoke: (_) {
+ _addToCollection(context, ref, post);
+ return null;
+ },
+ ),
+ _CloseIntent: CallbackAction<_CloseIntent>(
+ onInvoke: (_) {
+ _close(context);
+ return null;
+ },
+ ),
+ _DownloadIntent: CallbackAction<_DownloadIntent>(
+ onInvoke: (_) {
+ if (settings.allowDownloads) {
+ _download(context, ref, post);
+ }
+ return null;
+ },
+ ),
+ },
+ child: Focus(
+ autofocus: true,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ IconButton.filledTonal(
+ tooltip: strings.ru ? 'Предыдущий' : 'Previous',
+ onPressed: previous == null
+ ? null
+ : () => _openPost(context, previous),
+ icon: const Icon(Icons.chevron_left_rounded),
+ ),
+ Expanded(
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxHeight: 760),
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ qualityMode: qualityMode,
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume:
+ settings.videoPlayerHalfVolume,
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ ),
+ ),
+ ),
+ ),
+ IconButton.filledTonal(
+ tooltip: strings.ru ? 'Следующий' : 'Next',
+ onPressed: next == null
+ ? null
+ : () => _openPost(context, next),
+ icon: const Icon(Icons.chevron_right_rounded),
+ ),
+ ],
+ ),
+ if (currentIndex >= 0) ...[
+ const SizedBox(height: 10),
+ _NeighborStrip(
+ posts: feedPosts,
+ currentIndex: currentIndex,
+ onOpen: (post) => _openPost(context, post),
+ ),
+ ],
+ const SizedBox(height: 16),
+ PostActionBar(
+ isFavorite: favoriteKeys.contains(post.cacheKey),
+ labels: _postActionLabels(strings),
+ downloaded: ref
+ .watch(
+ downloadedMediaByKeyProvider(post.cacheKey))
+ .value !=
+ null,
+ onFavorite: () =>
+ _toggleFavorite(ref, post, favoriteKeys),
+ onCollection: () => _addToCollection(context, ref, post),
+ onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
+ onCopy: () =>
+ Clipboard.setData(ClipboardData(text: post.fileUrl)),
+ onSimilar: () => _openSimilar(context, ref, post),
+ onHide: () => _hidePost(context, ref, post),
+ onDownload: settings.allowDownloads
+ ? () => _download(context, ref, post)
+ : null,
+ onDeleteLocalFile: () =>
+ _deleteLocalFile(context, ref, post),
+ ),
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Chip(label: Text(post.providerName)),
+ RatingBadge(rating: post.rating),
+ Chip(label: Text('${post.width} x ${post.height}')),
+ if (post.source != null && post.source!.isNotEmpty)
+ ActionChip(
+ label: Text(strings.source),
+ onPressed: () => launchUrl(Uri.parse(post.source!)),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Text(strings.tags,
+ style: Theme.of(context).textTheme.titleLarge),
+ const SizedBox(height: 8),
+ PostTagsPanel(post: post),
+ const SizedBox(height: 16),
+ _CommentsSection(post: post),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildMobileDetails(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ AppSettings settings,
+ Set favoriteKeys,
+ MediaQualityMode qualityMode,
+ AppStrings strings,
+ ) {
+ final isVideo = MediaUrlSelector.isVideo(post);
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onVerticalDragEnd: (details) {
+ if ((details.primaryVelocity ?? 0) > 900) _close(context);
+ },
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
+ children: [
+ GestureDetector(
+ behavior: HitTestBehavior.deferToChild,
+ onDoubleTap:
+ isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
+ onLongPress: isVideo
+ ? null
+ : () =>
+ _showMobileQuickActions(context, ref, post, favoriteKeys),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.sizeOf(context).height * 0.62,
+ ),
+ child: Center(
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ qualityMode: qualityMode,
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume: settings.videoPlayerHalfVolume,
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ PostActionBar(
+ isFavorite: favoriteKeys.contains(post.cacheKey),
+ labels: _postActionLabels(strings),
+ downloaded:
+ ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
+ null,
+ onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
+ onCollection: () => _addToCollection(context, ref, post),
+ onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
+ onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
+ onSimilar: () => _openSimilar(context, ref, post),
+ onHide: () => _hidePost(context, ref, post),
+ onDownload: settings.allowDownloads
+ ? () => _download(context, ref, post)
+ : null,
+ onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ Chip(label: Text(post.providerName)),
+ RatingBadge(rating: post.rating),
+ Chip(label: Text('${post.width} x ${post.height}')),
+ if (post.source != null && post.source!.isNotEmpty)
+ ActionChip(
+ label: Text(strings.source),
+ onPressed: () => launchUrl(Uri.parse(post.source!)),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ ExpansionTile(
+ tilePadding: EdgeInsets.zero,
+ initiallyExpanded: false,
+ title: Text(strings.tags,
+ style: Theme.of(context).textTheme.titleMedium),
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: PostTagsPanel(post: post),
+ ),
+ ],
+ ),
+ _CommentsSection(post: post),
+ ],
+ ),
+ );
+ }
+
+ Future _addToCollection(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ final result = await ref.read(collectionServiceProvider).getCollections();
+ final collections =
+ result is Success> ? result.data : [];
+ if (!context.mounted) return;
+ await showAddToCollectionPicker(
+ context,
+ collections: collections,
+ onSelected: (collection) {
+ ref
+ .read(collectionServiceProvider)
+ .addPostToCollection(collection.id, post);
+ },
+ onCreate: () => showCollectionFormDialog(context, ref),
+ );
+ }
+
+ Future _download(BuildContext context, WidgetRef ref, Post post) async {
+ if (!context.mounted) return;
+ await ref.read(downloadManagerServiceProvider).start(post);
+ if (!context.mounted) return;
+ final strings = ref.read(appStringsProvider);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(strings.ru ? 'Скачивание началось' : 'Download started'),
+ ),
+ );
+ Future.delayed(const Duration(seconds: 2), () {
+ ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
+ });
+ }
+
+ Future _showMobileQuickActions(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ Set favoriteKeys,
+ ) async {
+ await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (sheetContext) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: Icon(
+ favoriteKeys.contains(post.cacheKey)
+ ? Icons.favorite_rounded
+ : Icons.favorite_border_rounded,
+ ),
+ title: Text(
+ favoriteKeys.contains(post.cacheKey)
+ ? 'Remove favorite'
+ : 'Favorite',
+ ),
+ onTap: () async {
+ Navigator.pop(sheetContext);
+ await _toggleFavorite(ref, post, favoriteKeys);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.collections_bookmark_rounded),
+ title: const Text('Add to collection'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _addToCollection(context, ref, post);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.copy_rounded),
+ title: const Text('Copy link'),
+ onTap: () {
+ Clipboard.setData(ClipboardData(text: post.fileUrl));
+ Navigator.pop(sheetContext);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.download_rounded),
+ title: const Text('Download'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _download(context, ref, post);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _toggleFavorite(
+ WidgetRef ref,
+ Post post,
+ Set favoriteKeys,
+ ) async {
+ if (favoriteKeys.contains(post.cacheKey)) {
+ await ref
+ .read(favoriteServiceProvider)
+ .removeFavorite(post.id, post.providerId);
+ } else {
+ await ref.read(favoriteServiceProvider).addFavorite(post);
+ await _maybeAutoDownloadFavorite(ref, post);
+ }
+ ref.invalidate(favoriteKeysProvider);
+ ref.invalidate(favoritesControllerProvider);
+ }
+
+ Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
+ final settings =
+ ref.read(appSettingsProvider).value ?? AppSettings.defaults;
+ if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
+ await ref.read(downloadManagerServiceProvider).start(post);
+ }
+
+ Future _deleteLocalFile(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ final strings = ref.read(appStringsProvider);
+ await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
+ post.cacheKey,
+ );
+ ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(strings.deleteLocalFile)),
+ );
+ }
+
+ PostActionLabels _postActionLabels(AppStrings strings) {
+ return PostActionLabels(
+ favorite: strings.favorite,
+ unfavorite: strings.removeFavorite,
+ collection: strings.collection,
+ similar: strings.similar,
+ openOriginal: strings.open,
+ copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
+ download: strings.download,
+ deleteLocalFile: strings.deleteLocalFile,
+ hidePost: strings.hidePost,
+ );
+ }
+
+ Future _hidePost(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
+ ref.invalidate(appSettingsProvider);
+ ref.read(feedControllerProvider.notifier).refresh();
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Post hidden locally')),
+ );
+ _close(context);
+ }
+
+ void _openPost(BuildContext context, Post post) {
+ _replacePost(context, post);
+ }
+
+ void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
+ context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
+ }
+
+ void _replacePost(BuildContext context, Post post) {
+ context.replace('/post/${post.providerId}/${post.id}', extra: post);
+ }
+
+ void _close(BuildContext context) {
+ if (Navigator.of(context).canPop()) {
+ context.pop();
+ return;
+ }
+ context.go('/');
+ }
+
+ Future _saveVideoPreferences(
+ WidgetRef ref,
+ VideoPlaybackSnapshot snapshot,
+ ) async {
+ final result = await ref.read(settingsServiceProvider).getSettings();
+ if (result is! Success) return;
+ final settings = result.data;
+ if (settings.videoPlayerMuted == snapshot.muted &&
+ settings.videoPlayerHalfVolume == snapshot.halfVolume &&
+ settings.videoPlayerLoop == snapshot.loopVideo &&
+ settings.videoPlayerCover == snapshot.coverVideo) {
+ return;
+ }
+ await ref.read(settingsServiceProvider).updateSettings(
+ settings.copyWith(
+ videoPlayerMuted: snapshot.muted,
+ videoPlayerHalfVolume: snapshot.halfVolume,
+ videoPlayerLoop: snapshot.loopVideo,
+ videoPlayerCover: snapshot.coverVideo,
+ ),
+ );
+ ref.invalidate(appSettingsProvider);
+ }
+}
+
+class _MobilePostPager extends StatefulWidget {
+ const _MobilePostPager({
+ required this.posts,
+ required this.initialIndex,
+ required this.buildDetails,
+ });
+
+ final List posts;
+ final int initialIndex;
+ final Widget Function(BuildContext context, Post post) buildDetails;
+
+ @override
+ State<_MobilePostPager> createState() => _MobilePostPagerState();
+}
+
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
- late int _currentIndex;
@override
void initState() {
super.initState();
- _currentIndex = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _prefetchAround(widget.initialIndex);
- });
- }
-
- @override
- void didUpdateWidget(covariant _MobilePostPager oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (widget.initialIndex != _currentIndex &&
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _prefetchAround(widget.initialIndex);
+ });
+ }
+
+ @override
+ void didUpdateWidget(covariant _MobilePostPager oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
+ if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
- _currentIndex = widget.initialIndex;
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
- }
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _prefetchAround(widget.initialIndex);
- });
- }
- }
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return PageView.builder(
+ }
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _prefetchAround(widget.initialIndex);
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PageView.builder(
controller: _controller,
itemCount: widget.posts.length,
onPageChanged: (index) {
- _currentIndex = index;
_prefetchAround(index);
},
- itemBuilder: (context, index) {
- return _KeepAlivePostPage(
- child: KeyedSubtree(
- key: ValueKey(widget.posts[index].cacheKey),
- child: widget.buildDetails(context, widget.posts[index]),
- ),
- );
- },
- );
- }
-
- void _prefetchAround(int index) {
- for (final offset in [-2, -1, 0, 1, 2]) {
- final target = index + offset;
- if (target < 0 || target >= widget.posts.length) continue;
- final post = widget.posts[target];
- final urls = [
- post.previewUrl,
- post.sampleUrl,
- if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
- ].where((url) => url.trim().isNotEmpty).toSet();
- for (final url in urls) {
- precacheImage(
- CachedNetworkImageProvider(url, headers: _headersFor(post)),
- context,
- );
- }
- }
- }
-
- Map _headersFor(Post post) {
- return {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
- 'Accept': '*/*',
- if (post.providerName.toLowerCase().contains('gelbooru') ||
- post.fileUrl.contains('gelbooru.com') ||
- post.sampleUrl.contains('gelbooru.com') ||
- post.previewUrl.contains('gelbooru.com'))
- 'Referer': 'https://gelbooru.com/',
- if (post.providerName.toLowerCase().contains('rule34') ||
- post.fileUrl.contains('rule34') ||
- post.sampleUrl.contains('rule34') ||
- post.previewUrl.contains('rule34'))
- 'Referer': 'https://rule34.xxx/',
- };
- }
-}
-
-class _KeepAlivePostPage extends StatefulWidget {
- const _KeepAlivePostPage({required this.child});
-
- final Widget child;
-
- @override
- State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState();
-}
-
-class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
- with AutomaticKeepAliveClientMixin {
- @override
- bool get wantKeepAlive => true;
-
- @override
- Widget build(BuildContext context) {
- super.build(context);
- return widget.child;
- }
-}
-
-class _NeighborStrip extends StatelessWidget {
- const _NeighborStrip({
- required this.posts,
- required this.currentIndex,
- required this.onOpen,
- });
-
- final List posts;
- final int currentIndex;
- final ValueChanged onOpen;
-
- @override
- Widget build(BuildContext context) {
- final start = (currentIndex - 3).clamp(0, posts.length);
- final end = (currentIndex + 4).clamp(0, posts.length);
- final visible = posts.sublist(start, end);
- return SizedBox(
- height: 72,
- child: ListView.separated(
- scrollDirection: Axis.horizontal,
- itemCount: visible.length,
- separatorBuilder: (_, __) => const SizedBox(width: 8),
- itemBuilder: (context, index) {
- final post = visible[index];
- final selected = post.cacheKey == posts[currentIndex].cacheKey;
- return InkWell(
- onTap: () => onOpen(post),
- borderRadius: BorderRadius.circular(8),
- child: AnimatedContainer(
- duration: AppMotion.duration(context, 140),
- width: 72,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: selected
- ? Theme.of(context).colorScheme.primary
- : Colors.transparent,
- width: 2,
- ),
- ),
- clipBehavior: Clip.antiAlias,
- child: CachedNetworkImage(
- imageUrl: post.previewUrl.isNotEmpty
- ? post.previewUrl
- : post.sampleUrl,
- fit: BoxFit.cover,
- errorWidget: (_, __, ___) =>
- const Center(child: Icon(Icons.broken_image_rounded)),
- ),
- ),
- );
- },
- ),
- );
- }
-}
-
-class _PreviousPostIntent extends Intent {
- const _PreviousPostIntent();
-}
-
-class _NextPostIntent extends Intent {
- const _NextPostIntent();
-}
-
-class _ToggleFavoriteIntent extends Intent {
- const _ToggleFavoriteIntent();
-}
-
-class _AddCollectionIntent extends Intent {
- const _AddCollectionIntent();
-}
-
-class _CloseIntent extends Intent {
- const _CloseIntent();
-}
-
-class _DownloadIntent extends Intent {
- const _DownloadIntent();
-}
-
-class _CommentsSection extends ConsumerStatefulWidget {
- const _CommentsSection({required this.post});
-
- final Post post;
-
- @override
- ConsumerState<_CommentsSection> createState() => _CommentsSectionState();
-}
-
-class _CommentsSectionState extends ConsumerState<_CommentsSection> {
- bool _expanded = false;
-
- @override
- Widget build(BuildContext context) {
- final strings = ref.watch(appStringsProvider);
- final comments = _expanded
- ? ref.watch(
- postCommentsProvider(
- PostDetailsArgs(
- providerId: widget.post.providerId,
- postId: widget.post.id,
- ),
- ),
- )
- : null;
- return ExpansionTile(
- tilePadding: EdgeInsets.zero,
- initiallyExpanded: false,
- onExpansionChanged: (value) => setState(() => _expanded = value),
- title: Text(strings.comments,
- style: Theme.of(context).textTheme.titleMedium),
- children: [
- (comments ?? const AsyncValue>.data([])).when(
- loading: () => const Padding(
- padding: EdgeInsets.all(12),
- child: CircularProgressIndicator(),
- ),
- error: (_, __) => Align(
- alignment: Alignment.centerLeft,
- child: Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Text(strings.commentsUnavailable),
- ),
- ),
- data: (items) {
- if (items.isEmpty) {
- return Align(
- alignment: Alignment.centerLeft,
- child: Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Text(strings.noComments),
- ),
- );
- }
- return Column(
- children: [
- for (final comment in items)
- ListTile(
- dense: true,
- contentPadding: EdgeInsets.zero,
- title: Text(
- comment.authorName.isEmpty
- ? strings.anonymous
- : comment.authorName,
- ),
- subtitle: Text(comment.body),
- ),
- ],
- );
- },
- ),
- ],
- );
- }
-}
+ itemBuilder: (context, index) {
+ return _KeepAlivePostPage(
+ child: KeyedSubtree(
+ key: ValueKey(widget.posts[index].cacheKey),
+ child: widget.buildDetails(context, widget.posts[index]),
+ ),
+ );
+ },
+ );
+ }
+
+ void _prefetchAround(int index) {
+ for (final offset in [-2, -1, 0, 1, 2]) {
+ final target = index + offset;
+ if (target < 0 || target >= widget.posts.length) continue;
+ final post = widget.posts[target];
+ final urls = [
+ post.previewUrl,
+ post.sampleUrl,
+ if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
+ ].where((url) => url.trim().isNotEmpty).toSet();
+ for (final url in urls) {
+ precacheImage(
+ CachedNetworkImageProvider(url, headers: _headersFor(post)),
+ context,
+ );
+ }
+ }
+ }
+
+ Map _headersFor(Post post) {
+ return {
+ 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'Accept': '*/*',
+ if (post.providerName.toLowerCase().contains('gelbooru') ||
+ post.fileUrl.contains('gelbooru.com') ||
+ post.sampleUrl.contains('gelbooru.com') ||
+ post.previewUrl.contains('gelbooru.com'))
+ 'Referer': 'https://gelbooru.com/',
+ if (post.providerName.toLowerCase().contains('rule34') ||
+ post.fileUrl.contains('rule34') ||
+ post.sampleUrl.contains('rule34') ||
+ post.previewUrl.contains('rule34'))
+ 'Referer': 'https://rule34.xxx/',
+ };
+ }
+}
+
+class _KeepAlivePostPage extends StatefulWidget {
+ const _KeepAlivePostPage({required this.child});
+
+ final Widget child;
+
+ @override
+ State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState();
+}
+
+class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
+ with AutomaticKeepAliveClientMixin {
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ return widget.child;
+ }
+}
+
+class _NeighborStrip extends StatelessWidget {
+ const _NeighborStrip({
+ required this.posts,
+ required this.currentIndex,
+ required this.onOpen,
+ });
+
+ final List posts;
+ final int currentIndex;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) {
+ final start = (currentIndex - 3).clamp(0, posts.length);
+ final end = (currentIndex + 4).clamp(0, posts.length);
+ final visible = posts.sublist(start, end);
+ return SizedBox(
+ height: 72,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: visible.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (context, index) {
+ final post = visible[index];
+ final selected = post.cacheKey == posts[currentIndex].cacheKey;
+ return InkWell(
+ onTap: () => onOpen(post),
+ borderRadius: BorderRadius.circular(8),
+ child: AnimatedContainer(
+ duration: AppMotion.duration(context, 140),
+ width: 72,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: selected
+ ? Theme.of(context).colorScheme.primary
+ : Colors.transparent,
+ width: 2,
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: CachedNetworkImage(
+ imageUrl: post.previewUrl.isNotEmpty
+ ? post.previewUrl
+ : post.sampleUrl,
+ fit: BoxFit.cover,
+ errorWidget: (_, __, ___) =>
+ const Center(child: Icon(Icons.broken_image_rounded)),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _PreviousPostIntent extends Intent {
+ const _PreviousPostIntent();
+}
+
+class _NextPostIntent extends Intent {
+ const _NextPostIntent();
+}
+
+class _ToggleFavoriteIntent extends Intent {
+ const _ToggleFavoriteIntent();
+}
+
+class _AddCollectionIntent extends Intent {
+ const _AddCollectionIntent();
+}
+
+class _CloseIntent extends Intent {
+ const _CloseIntent();
+}
+
+class _DownloadIntent extends Intent {
+ const _DownloadIntent();
+}
+
+class _CommentsSection extends ConsumerStatefulWidget {
+ const _CommentsSection({required this.post});
+
+ final Post post;
+
+ @override
+ ConsumerState<_CommentsSection> createState() => _CommentsSectionState();
+}
+
+class _CommentsSectionState extends ConsumerState<_CommentsSection> {
+ bool _expanded = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final strings = ref.watch(appStringsProvider);
+ final comments = _expanded
+ ? ref.watch(
+ postCommentsProvider(
+ PostDetailsArgs(
+ providerId: widget.post.providerId,
+ postId: widget.post.id,
+ ),
+ ),
+ )
+ : null;
+ return ExpansionTile(
+ tilePadding: EdgeInsets.zero,
+ initiallyExpanded: false,
+ onExpansionChanged: (value) => setState(() => _expanded = value),
+ title: Text(strings.comments,
+ style: Theme.of(context).textTheme.titleMedium),
+ children: [
+ (comments ?? const AsyncValue>.data([])).when(
+ loading: () => const Padding(
+ padding: EdgeInsets.all(12),
+ child: CircularProgressIndicator(),
+ ),
+ error: (_, __) => Align(
+ alignment: Alignment.centerLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(strings.commentsUnavailable),
+ ),
+ ),
+ data: (items) {
+ if (items.isEmpty) {
+ return Align(
+ alignment: Alignment.centerLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(strings.noComments),
+ ),
+ );
+ }
+ return Column(
+ children: [
+ for (final comment in items)
+ ListTile(
+ dense: true,
+ contentPadding: EdgeInsets.zero,
+ title: Text(
+ comment.authorName.isEmpty
+ ? strings.anonymous
+ : comment.authorName,
+ ),
+ subtitle: Text(comment.body),
+ ),
+ ],
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart
index 3d976e9..0812480 100644
--- a/lib/features/post/presentation/widgets/post_media_viewer.dart
+++ b/lib/features/post/presentation/widgets/post_media_viewer.dart
@@ -1,1504 +1,1504 @@
-import 'dart:async';
-import 'dart:io';
-
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:media_kit/media_kit.dart';
-import 'package:media_kit_video/media_kit_video.dart';
-import 'package:url_launcher/url_launcher.dart';
-import 'package:webview_windows/webview_windows.dart';
-
-import '../../../../app/motion.dart';
-import '../../../../backend/backend.dart';
-
-final Map _playbackMemory =
- {};
-
-class PostMediaViewer extends StatefulWidget {
- const PostMediaViewer({
- required this.post,
- this.fullscreen = false,
- this.initialPosition = Duration.zero,
- this.autoplay = false,
- this.initialLoop = false,
- this.initialMuted = false,
- this.initialCoverVideo = false,
- this.initialHalfVolume = false,
- this.qualityMode = MediaQualityMode.auto,
- this.onPlaybackSnapshot,
- this.onPlaybackPreferencesChanged,
- super.key,
- });
-
- final Post post;
- final bool fullscreen;
- final Duration initialPosition;
- final bool autoplay;
- final bool initialLoop;
- final bool initialMuted;
- final bool initialCoverVideo;
- final bool initialHalfVolume;
- final MediaQualityMode qualityMode;
- final ValueChanged? onPlaybackSnapshot;
- final ValueChanged? onPlaybackPreferencesChanged;
-
- @override
- State createState() => _PostMediaViewerState();
-}
-
-class _PostMediaViewerState extends State
- with AutomaticKeepAliveClientMixin {
- Player? _player;
- VideoController? _controller;
- late List _imageUrls;
- late List _videoUrls;
- int _imageIndex = 0;
- int _videoIndex = 0;
- bool _controlsVisible = true;
- bool _inFullscreen = false;
- late bool _coverVideo;
- late bool _muted;
- late bool _loopVideo;
- late bool _halfVolume;
- String? _videoError;
- Timer? _hideTimer;
- StreamSubscription? _errorSubscription;
- StreamSubscription? _positionSubscription;
- StreamSubscription? _playingSubscription;
-
- @override
- void initState() {
- super.initState();
- _coverVideo = widget.initialCoverVideo;
- _muted = widget.initialMuted;
- _loopVideo = widget.initialLoop;
- _halfVolume = widget.initialHalfVolume;
- _imageUrls = _buildImageUrls(widget.post);
- _videoUrls = _buildVideoUrls(widget.post);
- if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
- _initializeVideo();
- }
- }
-
- @override
- void didUpdateWidget(covariant PostMediaViewer oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.post.cacheKey != widget.post.cacheKey) {
- _disposeVideo();
- _imageIndex = 0;
- _videoIndex = 0;
- _videoError = null;
- _imageUrls = _buildImageUrls(widget.post);
- _videoUrls = _buildVideoUrls(widget.post);
- if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
- _initializeVideo();
- }
- }
- }
-
- @override
- void dispose() {
- _disposeVideo();
- super.dispose();
- }
-
- @override
- bool get wantKeepAlive => true;
-
- @override
- Widget build(BuildContext context) {
- super.build(context);
- if (_isSwf(widget.post)) {
- return _SwfMediaViewer(
- post: widget.post,
- url: _swfUrl(widget.post),
- fullscreen: widget.fullscreen,
- );
- }
-
- if (_controller != null) {
- if (_inFullscreen) {
- return ColoredBox(
- color: Colors.black,
- child: AspectRatio(
- aspectRatio: widget.post.width > 0 && widget.post.height > 0
- ? (widget.post.width / widget.post.height).clamp(0.35, 2.4)
- : 16 / 9,
- child: const Center(
- child: Icon(Icons.fullscreen_rounded, color: Colors.white38),
- ),
- ),
- );
- }
- return Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
- },
- child: Actions(
- actions: {
- _TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
- onInvoke: (_) {
- _togglePlay();
- return null;
- },
- ),
- },
- child: Focus(
- autofocus: true,
- child: _VideoSurface(
- player: _player!,
- controller: _controller!,
- aspectRatio: widget.post.width > 0 && widget.post.height > 0
- ? widget.post.width / widget.post.height
- : 16 / 9,
- controlsVisible: _controlsVisible,
- coverVideo: _coverVideo,
- muted: _muted,
- loopVideo: _loopVideo,
- halfVolume: _halfVolume,
- fullscreen: widget.fullscreen,
- errorMessage: _videoError,
- onInteract: _showControls,
- onRetry: _retryVideo,
- onToggleFit: () {
- setState(() => _coverVideo = !_coverVideo);
- _emitPlaybackPreferences();
- _showControls();
- },
- onToggleMute: () async {
- final nextMuted = !_muted;
- setState(() => _muted = nextMuted);
- await _applyVolume();
- _emitPlaybackPreferences();
- _showControls();
- },
- onToggleHalfVolume: () async {
- setState(() {
- _halfVolume = !_halfVolume;
- if (_halfVolume) _muted = false;
- });
- await _applyVolume();
- _emitPlaybackPreferences();
- _showControls();
- },
- onToggleLoop: () async {
- final nextLoop = !_loopVideo;
- setState(() => _loopVideo = nextLoop);
- await _player!.setPlaylistMode(
- nextLoop ? PlaylistMode.single : PlaylistMode.none,
- );
- _emitPlaybackPreferences();
- _showControls();
- },
- onFullscreen: widget.fullscreen
- ? () => Navigator.of(context, rootNavigator: true)
- .pop(_snapshot())
- : () => _openFullscreen(context),
- ),
- ),
- ),
- );
- }
-
- final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
- final image = CachedNetworkImage(
- key: ValueKey(url),
- imageUrl: url,
- httpHeaders: _headersFor(widget.post),
- fit: BoxFit.contain,
- placeholder: (context, url) =>
- const Center(child: CircularProgressIndicator()),
- errorWidget: (context, url, error) {
- if (_imageIndex < _imageUrls.length - 1) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) setState(() => _imageIndex++);
- });
- return const Center(child: CircularProgressIndicator());
- }
- return const Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.broken_image_rounded, size: 48),
- SizedBox(height: 8),
- Text('Could not load image'),
- ],
- ),
- );
- },
- );
- final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
- return allowPinchZoom ? _ZoomableImage(child: image) : image;
- }
-
- void _initializeVideo() {
- MediaKit.ensureInitialized();
- _player = Player();
- _controller = VideoController(_player!);
- _applyVolume();
- _player!.setPlaylistMode(
- _loopVideo ? PlaylistMode.single : PlaylistMode.none,
- );
- _errorSubscription = _player!.stream.error.listen((message) {
- if (!mounted) return;
- if (_videoIndex < _videoUrls.length - 1) {
- _videoIndex++;
- _openVideo(play: false);
- return;
- }
- setState(() => _videoError = message);
- _showControls();
- });
- _positionSubscription = _player!.stream.position.listen((_) {
- final snapshot = _snapshot();
- _playbackMemory[widget.post.cacheKey] = snapshot;
- widget.onPlaybackSnapshot?.call(snapshot);
- });
- _playingSubscription = _player!.stream.playing.listen((_) {
- final snapshot = _snapshot();
- _playbackMemory[widget.post.cacheKey] = snapshot;
- widget.onPlaybackSnapshot?.call(snapshot);
- });
- _openVideo(play: widget.autoplay);
- _scheduleControlsHide();
- }
-
- Future _openVideo({required bool play}) async {
- final player = _player;
- if (player == null || _videoUrls.isEmpty) return;
- final remembered = _playbackMemory[widget.post.cacheKey];
- final initialPosition = widget.initialPosition > Duration.zero
- ? widget.initialPosition
- : remembered?.position ?? Duration.zero;
- setState(() => _videoError = null);
- await player.open(
- Media(
- _videoUrls[_videoIndex],
- httpHeaders: _headersFor(widget.post),
- ),
- play: false,
- );
- if (initialPosition > Duration.zero) {
- await player.seek(initialPosition);
- }
- if (play) await player.play();
- }
-
- Future _retryVideo() async {
- final player = _player;
- if (player == null) return;
- setState(() {
- _videoError = null;
- _videoIndex = 0;
- _controlsVisible = true;
- });
- await player.stop();
- await _openVideo(play: true);
- _showControls();
- }
-
- void _disposeVideo() {
- _hideTimer?.cancel();
- _hideTimer = null;
- _errorSubscription?.cancel();
- _errorSubscription = null;
- _positionSubscription?.cancel();
- _positionSubscription = null;
- _playingSubscription?.cancel();
- _playingSubscription = null;
- final snapshot = _snapshot();
- _playbackMemory[widget.post.cacheKey] = snapshot;
- widget.onPlaybackSnapshot?.call(snapshot);
- _player?.dispose();
- _player = null;
- _controller = null;
- }
-
- List _buildImageUrls(Post post) {
- return MediaUrlSelector.details(post, mode: widget.qualityMode);
- }
-
- List _buildVideoUrls(Post post) {
- return MediaUrlSelector.video(post);
- }
-
- bool _isVideo(Post post) {
- final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
- return value.contains('video') ||
- value.contains('.webm') ||
- value.contains('.mp4') ||
- value.contains('.mov');
- }
-
- bool _isSwf(Post post) {
- final value =
- '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}'
- .toLowerCase();
- return value.contains('swf') || value.contains('.swf');
- }
-
- String _swfUrl(Post post) {
- for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) {
- final value = url.trim();
- if (value.toLowerCase().contains('.swf')) return value;
- }
- return post.fileUrl.trim();
- }
-
- Map _headersFor(Post post) {
- return {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
- 'Accept': '*/*',
- if (post.providerName.toLowerCase().contains('gelbooru') ||
- post.fileUrl.contains('gelbooru.com') ||
- post.sampleUrl.contains('gelbooru.com') ||
- post.previewUrl.contains('gelbooru.com'))
- 'Referer': 'https://gelbooru.com/',
- if (post.providerName.toLowerCase().contains('rule34') ||
- post.fileUrl.contains('rule34') ||
- post.sampleUrl.contains('rule34') ||
- post.previewUrl.contains('rule34'))
- 'Referer': 'https://rule34.xxx/',
- };
- }
-
- void _showControls() {
- if (!mounted) return;
- setState(() => _controlsVisible = true);
- _scheduleControlsHide();
- }
-
- void _scheduleControlsHide() {
- _hideTimer?.cancel();
- _hideTimer = Timer(const Duration(seconds: 3), () {
- if (mounted && _videoError == null) {
- setState(() => _controlsVisible = false);
- }
- });
- }
-
- Future _openFullscreen(BuildContext context) async {
- final player = _player;
- final controller = _controller;
- if (player == null || controller == null) return;
- setState(() => _inFullscreen = true);
- if (!context.mounted) return;
- final result = await Navigator.of(context, rootNavigator: true)
- .push(
- PageRouteBuilder(
- opaque: true,
- pageBuilder: (_, __, ___) => _FullscreenVideoPage(
- player: player,
- controller: controller,
- aspectRatio: widget.post.width > 0 && widget.post.height > 0
- ? widget.post.width / widget.post.height
- : 16 / 9,
- loopVideo: _loopVideo,
- muted: _muted,
- halfVolume: _halfVolume,
- coverVideo: _coverVideo,
- errorMessage: _videoError,
- onRetry: _retryVideo,
- onChanged: (snapshot) {
- unawaited(_applyPlaybackSnapshot(snapshot));
- },
- ),
- transitionsBuilder: (_, animation, __, child) => FadeTransition(
- opacity: animation,
- child: child,
- ),
- ),
- );
- if (mounted) setState(() => _inFullscreen = false);
- if (result != null) {
- await _applyPlaybackSnapshot(result);
- return;
- }
- }
-
- Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async {
- if (!mounted) return;
- setState(() {
- _muted = snapshot.muted;
- _loopVideo = snapshot.loopVideo;
- _coverVideo = snapshot.coverVideo;
- _halfVolume = snapshot.halfVolume;
- });
- await _applyVolume();
- await _player?.setPlaylistMode(
- _loopVideo ? PlaylistMode.single : PlaylistMode.none,
- );
- _emitPlaybackPreferences();
- }
-
- VideoPlaybackSnapshot _snapshot() {
- final player = _player;
- return VideoPlaybackSnapshot(
- position: player?.state.position ?? Duration.zero,
- playing: player?.state.playing ?? false,
- muted: _muted,
- halfVolume: _halfVolume,
- loopVideo: _loopVideo,
- coverVideo: _coverVideo,
- );
- }
-
- Future _applyVolume() async {
- await _player?.setVolume(_muted
- ? 0
- : _halfVolume
- ? 50
- : 100);
- }
-
- void _emitPlaybackPreferences() {
- final snapshot = _snapshot();
- _playbackMemory[widget.post.cacheKey] = snapshot;
- widget.onPlaybackSnapshot?.call(snapshot);
- widget.onPlaybackPreferencesChanged?.call(snapshot);
- }
-
- Future _togglePlay() async {
- final player = _player;
- if (player == null) return;
- if (player.state.playing) {
- await player.pause();
- } else {
- await player.play();
- }
- _showControls();
- }
-}
-
-class _TogglePlayIntent extends Intent {
- const _TogglePlayIntent();
-}
-
-class _SwfMediaViewer extends StatelessWidget {
- const _SwfMediaViewer({
- required this.post,
- required this.url,
- required this.fullscreen,
- });
-
- final Post post;
- final String url;
- final bool fullscreen;
-
- @override
- Widget build(BuildContext context) {
- if (url.isEmpty) {
- return const Center(child: Text('SWF URL is empty'));
- }
- if (Platform.isWindows) {
- return _WindowsRuffleViewer(url: url, fullscreen: fullscreen);
- }
- return _SwfUnsupportedPanel(url: url);
- }
-}
-
-class _WindowsRuffleViewer extends StatefulWidget {
- const _WindowsRuffleViewer({
- required this.url,
- required this.fullscreen,
- });
-
- final String url;
- final bool fullscreen;
-
- @override
- State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState();
-}
-
-class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> {
- final _controller = WebviewController();
- bool _loading = true;
- String? _error;
-
- @override
- void initState() {
- super.initState();
- _initialize();
- }
-
- @override
- void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.url != widget.url) {
- _load();
- }
- }
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final child = ClipRRect(
- borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
- child: Stack(
- fit: StackFit.expand,
- children: [
- if (_controller.value.isInitialized) Webview(_controller),
- if (_loading)
- const ColoredBox(
- color: Colors.black,
- child: Center(child: CircularProgressIndicator()),
- ),
- if (_error != null)
- _SwfErrorPanel(
- message: _error!,
- url: widget.url,
- onRetry: _load,
- ),
- ],
- ),
- );
- if (widget.fullscreen) return child;
- return AspectRatio(aspectRatio: 4 / 3, child: child);
- }
-
- Future _initialize() async {
- try {
- await _controller.initialize();
- _controller.loadingState.listen((state) {
- if (!mounted) return;
- setState(() => _loading = state == LoadingState.loading);
- });
- _controller.onLoadError.listen((error) {
- if (!mounted) return;
- setState(() => _error = 'Ruffle WebView error: $error');
- });
- await _load();
- } catch (error) {
- if (!mounted) return;
- setState(() {
- _loading = false;
- _error =
- 'Could not start Windows WebView2. Install WebView2 Runtime or open original.';
- });
- }
- }
-
- Future _load() async {
- if (!_controller.value.isInitialized) return;
- setState(() {
- _loading = true;
- _error = null;
- });
- try {
- await _controller.loadStringContent(_ruffleHtml(widget.url));
- } catch (error) {
- if (!mounted) return;
- setState(() {
- _loading = false;
- _error = 'Could not load Ruffle viewer: $error';
- });
- }
- }
-
- String _ruffleHtml(String swfUrl) {
- final escapedUrl = _htmlEscape(swfUrl);
- return '''
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-''';
- }
-
- String _htmlEscape(String value) {
- return value
- .replaceAll('&', '&')
- .replaceAll('"', '"')
- .replaceAll("'", ''')
- .replaceAll('<', '<')
- .replaceAll('>', '>');
- }
-}
-
-class _SwfUnsupportedPanel extends StatelessWidget {
- const _SwfUnsupportedPanel({required this.url});
-
- final String url;
-
- @override
- Widget build(BuildContext context) {
- return _SwfErrorPanel(
- message: Platform.isAndroid
- ? 'Flash/SWF playback is desktop only.'
- : 'SWF playback is available on Windows only in this build.',
- url: url,
- );
- }
-}
-
-class _SwfErrorPanel extends StatelessWidget {
- const _SwfErrorPanel({
- required this.message,
- required this.url,
- this.onRetry,
- });
-
- final String message;
- final String url;
- final VoidCallback? onRetry;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- return ColoredBox(
- color: Colors.black,
- child: Center(
- child: Padding(
- padding: const EdgeInsets.all(22),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(Icons.extension_rounded,
- color: Colors.white, size: 48),
- const SizedBox(height: 12),
- Text(
- 'SWF / Flash',
- style: Theme.of(context)
- .textTheme
- .titleLarge
- ?.copyWith(color: Colors.white),
- ),
- const SizedBox(height: 8),
- Text(
- message,
- textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.white70),
- ),
- const SizedBox(height: 16),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- alignment: WrapAlignment.center,
- children: [
- if (onRetry != null)
- FilledButton.icon(
- onPressed: onRetry,
- icon: const Icon(Icons.refresh_rounded),
- label: const Text('Retry'),
- ),
- FilledButton.tonalIcon(
- onPressed: () => launchUrl(
- Uri.parse(url),
- mode: LaunchMode.externalApplication,
- ),
- icon: const Icon(Icons.open_in_new_rounded),
- label: const Text('Open original'),
- ),
- FilledButton.tonalIcon(
- style: FilledButton.styleFrom(
- backgroundColor:
- scheme.primaryContainer.withValues(alpha: 0.82),
- ),
- onPressed: () => launchUrl(
- Uri.parse(
- 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}',
- ),
- mode: LaunchMode.externalApplication,
- ),
- icon: const Icon(Icons.play_circle_outline_rounded),
- label: const Text('Open in Ruffle'),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
-
-class VideoPlaybackSnapshot {
- const VideoPlaybackSnapshot({
- required this.position,
- required this.playing,
- required this.muted,
- required this.halfVolume,
- required this.loopVideo,
- required this.coverVideo,
- });
-
- final Duration position;
- final bool playing;
- final bool muted;
- final bool halfVolume;
- final bool loopVideo;
- final bool coverVideo;
-}
-
-class _FullscreenVideoPage extends StatefulWidget {
- const _FullscreenVideoPage({
- required this.player,
- required this.controller,
- required this.aspectRatio,
- required this.loopVideo,
- required this.muted,
- required this.halfVolume,
- required this.coverVideo,
- required this.errorMessage,
- required this.onRetry,
- required this.onChanged,
- });
-
- final Player player;
- final VideoController controller;
- final double aspectRatio;
- final bool loopVideo;
- final bool muted;
- final bool halfVolume;
- final bool coverVideo;
- final String? errorMessage;
- final VoidCallback onRetry;
- final ValueChanged onChanged;
-
- @override
- State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
-}
-
-class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
- bool _controlsVisible = true;
- late bool _coverVideo;
- late bool _muted;
- late bool _loopVideo;
- late bool _halfVolume;
- Timer? _hideTimer;
-
- @override
- void initState() {
- super.initState();
- _coverVideo = widget.coverVideo;
- _muted = widget.muted;
- _loopVideo = widget.loopVideo;
- _halfVolume = widget.halfVolume;
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
- SystemChrome.setPreferredOrientations([
- DeviceOrientation.landscapeLeft,
- DeviceOrientation.landscapeRight,
- DeviceOrientation.portraitUp,
- ]);
- _scheduleControlsHide();
- }
-
- @override
- void dispose() {
- _hideTimer?.cancel();
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
- SystemChrome.setPreferredOrientations(DeviceOrientation.values);
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return PopScope(
- canPop: false,
- onPopInvokedWithResult: (didPop, _) {
- if (didPop) return;
- Navigator.of(context, rootNavigator: true).pop(_snapshot());
- },
- child: Scaffold(
- backgroundColor: Colors.black,
- body: Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
- LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
- },
- child: Actions(
- actions: {
- _CloseVideoIntent: CallbackAction<_CloseVideoIntent>(
- onInvoke: (_) {
- Navigator.of(context, rootNavigator: true).pop(_snapshot());
- return null;
- },
- ),
- _TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
- onInvoke: (_) {
- widget.player.playOrPause();
- _showControls();
- return null;
- },
- ),
- },
- child: Focus(
- autofocus: true,
- child: Stack(
- children: [
- Positioned.fill(
- child: _VideoSurface(
- player: widget.player,
- controller: widget.controller,
- aspectRatio: widget.aspectRatio,
- controlsVisible: _controlsVisible,
- coverVideo: _coverVideo,
- muted: _muted,
- loopVideo: _loopVideo,
- halfVolume: _halfVolume,
- fullscreen: true,
- errorMessage: widget.errorMessage,
- onInteract: _showControls,
- onRetry: widget.onRetry,
- onToggleFit: () {
- setState(() => _coverVideo = !_coverVideo);
- widget.onChanged(_snapshot());
- _showControls();
- },
- onToggleMute: () async {
- setState(() => _muted = !_muted);
- await _applyVolume();
- widget.onChanged(_snapshot());
- _showControls();
- },
- onToggleHalfVolume: () async {
- setState(() {
- _halfVolume = !_halfVolume;
- if (_halfVolume) _muted = false;
- });
- await _applyVolume();
- widget.onChanged(_snapshot());
- _showControls();
- },
- onToggleLoop: () async {
- setState(() => _loopVideo = !_loopVideo);
- await widget.player.setPlaylistMode(
- _loopVideo ? PlaylistMode.single : PlaylistMode.none,
- );
- widget.onChanged(_snapshot());
- _showControls();
- },
- onFullscreen: () => Navigator.of(
- context,
- rootNavigator: true,
- ).pop(_snapshot()),
- ),
- ),
- Positioned(
- top: 12,
- right: 12,
- child: SafeArea(
- child: IconButton.filledTonal(
- tooltip: 'Close fullscreen',
- onPressed: () => Navigator.of(
- context,
- rootNavigator: true,
- ).pop(_snapshot()),
- icon: const Icon(Icons.close_rounded),
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- );
- }
-
- void _showControls() {
- if (!mounted) return;
- setState(() => _controlsVisible = true);
- _scheduleControlsHide();
- }
-
- void _scheduleControlsHide() {
- _hideTimer?.cancel();
- _hideTimer = Timer(const Duration(seconds: 3), () {
- if (mounted && widget.errorMessage == null) {
- setState(() => _controlsVisible = false);
- }
- });
- }
-
- Future _applyVolume() async {
- await widget.player.setVolume(_muted
- ? 0
- : _halfVolume
- ? 50
- : 100);
- }
-
- VideoPlaybackSnapshot _snapshot() {
- return VideoPlaybackSnapshot(
- position: widget.player.state.position,
- playing: widget.player.state.playing,
- muted: _muted,
- halfVolume: _halfVolume,
- loopVideo: _loopVideo,
- coverVideo: _coverVideo,
- );
- }
-}
-
-class _CloseVideoIntent extends Intent {
- const _CloseVideoIntent();
-}
-
-class _VideoSurface extends StatelessWidget {
- const _VideoSurface({
- required this.player,
- required this.controller,
- required this.aspectRatio,
- required this.controlsVisible,
- required this.coverVideo,
- required this.muted,
- required this.halfVolume,
- required this.loopVideo,
- required this.fullscreen,
- required this.errorMessage,
- required this.onInteract,
- required this.onRetry,
- required this.onToggleFit,
- required this.onToggleMute,
- required this.onToggleHalfVolume,
- required this.onToggleLoop,
- required this.onFullscreen,
- });
-
- final Player player;
- final VideoController controller;
- final double aspectRatio;
- final bool controlsVisible;
- final bool coverVideo;
- final bool muted;
- final bool halfVolume;
- final bool loopVideo;
- final bool fullscreen;
- final String? errorMessage;
- final VoidCallback onInteract;
- final VoidCallback onRetry;
- final VoidCallback onToggleFit;
- final VoidCallback onToggleMute;
- final VoidCallback onToggleHalfVolume;
- final VoidCallback onToggleLoop;
- final VoidCallback onFullscreen;
-
- @override
- Widget build(BuildContext context) {
- final child = Stack(
- fit: StackFit.expand,
- children: [
- ColoredBox(
- color: Colors.black,
- child: Video(
- controller: controller,
- fit: coverVideo ? BoxFit.cover : BoxFit.contain,
- controls: null,
- ),
- ),
- StreamBuilder(
- stream: player.stream.buffering,
- initialData: player.state.buffering,
- builder: (context, snapshot) {
- if (snapshot.data != true || errorMessage != null) {
- return const SizedBox.shrink();
- }
- return const Center(child: CircularProgressIndicator());
- },
- ),
- if (errorMessage != null)
- VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
- if (errorMessage == null)
- StreamBuilder(
- stream: player.stream.playing,
- initialData: player.state.playing,
- builder: (context, snapshot) {
- final playing = snapshot.data ?? false;
- return AnimatedScale(
- scale: controlsVisible || !playing ? 1 : 0.85,
- duration: AppMotion.duration(context, 160),
- child: AnimatedOpacity(
- opacity: controlsVisible || !playing ? 1 : 0,
- duration: AppMotion.duration(context, 160),
- child: Center(
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: Colors.black.withValues(alpha: 0.46),
- shape: BoxShape.circle,
- boxShadow: const [
- BoxShadow(
- color: Colors.black45,
- blurRadius: 24,
- offset: Offset(0, 10),
- ),
- ],
- ),
- child: IconButton(
- tooltip: playing ? 'Pause' : 'Play',
- iconSize: fullscreen ? 58 : 44,
- color: Colors.white,
- onPressed: () {
- player.playOrPause();
- onInteract();
- },
- icon: Icon(
- playing
- ? Icons.pause_rounded
- : Icons.play_arrow_rounded,
- ),
- ),
- ),
- ),
- ),
- );
- },
- ),
- IgnorePointer(
- ignoring: !controlsVisible || errorMessage != null,
- child: AnimatedOpacity(
- opacity: controlsVisible || errorMessage != null ? 1 : 0,
- duration: AppMotion.duration(context, 180),
- child: _VideoControls(
- player: player,
- muted: muted,
- halfVolume: halfVolume,
- loopVideo: loopVideo,
- coverVideo: coverVideo,
- fullscreen: fullscreen,
- onToggleFit: onToggleFit,
- onToggleMute: onToggleMute,
- onToggleHalfVolume: onToggleHalfVolume,
- onToggleLoop: onToggleLoop,
- onFullscreen: onFullscreen,
- ),
- ),
- ),
- ],
- );
-
- return MouseRegion(
- onHover: (_) => onInteract(),
- child: GestureDetector(
- onTap: onInteract,
- onLongPress: player.playOrPause,
- onDoubleTap: onFullscreen,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(fullscreen ? 0 : 10),
- child: fullscreen
- ? SizedBox.expand(child: child)
- : AspectRatio(
- aspectRatio: aspectRatio.clamp(0.35, 2.4),
- child: child,
- ),
- ),
- ),
- );
- }
-}
-
-class VideoErrorOverlay extends StatelessWidget {
- const VideoErrorOverlay({
- required this.message,
- required this.onRetry,
- super.key,
- });
-
- final String message;
- final VoidCallback onRetry;
-
- @override
- Widget build(BuildContext context) {
- return ColoredBox(
- color: Colors.black.withValues(alpha: 0.62),
- child: Center(
- child: Padding(
- padding: const EdgeInsets.all(20),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(Icons.error_outline_rounded,
- color: Colors.white, size: 42),
- const SizedBox(height: 10),
- Text(
- 'Could not load video',
- style: Theme.of(context)
- .textTheme
- .titleMedium
- ?.copyWith(color: Colors.white),
- ),
- const SizedBox(height: 6),
- Text(
- message,
- maxLines: 3,
- overflow: TextOverflow.ellipsis,
- textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.white70),
- ),
- const SizedBox(height: 14),
- FilledButton.icon(
- onPressed: onRetry,
- icon: const Icon(Icons.refresh_rounded),
- label: const Text('Retry'),
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
-
-class _ZoomableImage extends StatefulWidget {
- const _ZoomableImage({required this.child});
-
- final Widget child;
-
- @override
- State<_ZoomableImage> createState() => _ZoomableImageState();
-}
-
-class _ZoomableImageState extends State<_ZoomableImage> {
- final _controller = TransformationController();
- TapDownDetails? _doubleTapDetails;
- bool _zoomed = false;
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return GestureDetector(
- onDoubleTapDown: (details) => _doubleTapDetails = details,
- onDoubleTap: _toggleZoom,
- child: InteractiveViewer(
- transformationController: _controller,
- minScale: 1,
- maxScale: 5,
- panEnabled: _zoomed,
- scaleEnabled: true,
- clipBehavior: Clip.none,
- onInteractionEnd: (_) {
- final scale = _controller.value.getMaxScaleOnAxis();
- if (mounted) setState(() => _zoomed = scale > 1.03);
- },
- child: widget.child,
- ),
- );
- }
-
- void _toggleZoom() {
- final tap = _doubleTapDetails?.localPosition ?? Offset.zero;
- if (_zoomed) {
- _controller.value = Matrix4.identity();
- setState(() => _zoomed = false);
- return;
- }
- _controller.value = Matrix4.identity()
- ..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1)
- ..scaleByDouble(2.3, 2.3, 1, 1);
- setState(() => _zoomed = true);
- }
-}
-
-class _RoundControlButton extends StatelessWidget {
- const _RoundControlButton({
- required this.tooltip,
- required this.icon,
- required this.onPressed,
- this.emphasized = false,
- this.selected = false,
- });
-
- final String tooltip;
- final IconData icon;
- final VoidCallback onPressed;
- final bool emphasized;
- final bool selected;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- final background = emphasized
- ? scheme.primary
- : selected
- ? scheme.primaryContainer.withValues(alpha: 0.96)
- : Colors.white.withValues(alpha: 0.13);
- final foreground = emphasized
- ? scheme.onPrimary
- : selected
- ? scheme.onPrimaryContainer
- : Colors.white;
- return Tooltip(
- message: tooltip,
- child: InkResponse(
- onTap: onPressed,
- radius: emphasized ? 23 : 21,
- child: AnimatedContainer(
- duration: AppMotion.duration(context, 140),
- width: emphasized ? 38 : 34,
- height: emphasized ? 38 : 34,
- decoration: BoxDecoration(
- color: background,
- shape: BoxShape.circle,
- border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
- ),
- child: Icon(icon, color: foreground, size: emphasized ? 24 : 20),
- ),
- ),
- );
- }
-}
-
-class _VideoControls extends StatelessWidget {
- const _VideoControls({
- required this.player,
- required this.muted,
- required this.halfVolume,
- required this.loopVideo,
- required this.coverVideo,
- required this.fullscreen,
- required this.onToggleFit,
- required this.onToggleMute,
- required this.onToggleHalfVolume,
- required this.onToggleLoop,
- required this.onFullscreen,
- });
-
- final Player player;
- final bool muted;
- final bool halfVolume;
- final bool loopVideo;
- final bool coverVideo;
- final bool fullscreen;
- final VoidCallback onToggleFit;
- final VoidCallback onToggleMute;
- final VoidCallback onToggleHalfVolume;
- final VoidCallback onToggleLoop;
- final VoidCallback onFullscreen;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- return Align(
- alignment: Alignment.bottomCenter,
- child: SafeArea(
- minimum: EdgeInsets.fromLTRB(
- fullscreen ? 14 : 8,
- 0,
- fullscreen ? 14 : 8,
- fullscreen ? 10 : 8,
- ),
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: Colors.black.withValues(alpha: 0.58),
- borderRadius: BorderRadius.circular(fullscreen ? 18 : 14),
- border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
- ),
- child: Padding(
- padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
- child: StreamBuilder(
- stream: player.stream.playing,
- initialData: player.state.playing,
- builder: (context, playingSnapshot) {
- final playing = playingSnapshot.data ?? false;
- return StreamBuilder(
- stream: player.stream.duration,
- initialData: player.state.duration,
- builder: (context, durationSnapshot) {
- final duration = durationSnapshot.data ?? Duration.zero;
- return StreamBuilder(
- stream: player.stream.position,
- initialData: player.state.position,
- builder: (context, positionSnapshot) {
- final position = positionSnapshot.data ?? Duration.zero;
- final maxMs = duration.inMilliseconds
- .clamp(1, 1 << 31)
- .toDouble();
- final valueMs = position.inMilliseconds
- .clamp(0, maxMs.toInt())
- .toDouble();
- return LayoutBuilder(
- builder: (context, constraints) {
- final compact = constraints.maxWidth < 560;
- final tiny = constraints.maxWidth < 390;
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- SliderTheme(
- data: SliderTheme.of(context).copyWith(
- activeTrackColor: scheme.primary,
- inactiveTrackColor:
- Colors.white.withValues(alpha: 0.24),
- trackHeight: 3,
- thumbColor: scheme.primary,
- overlayColor:
- scheme.primary.withValues(alpha: 0.18),
- thumbShape: const RoundSliderThumbShape(
- enabledThumbRadius: 5,
- ),
- ),
- child: Slider(
- value: valueMs,
- max: maxMs,
- onChanged: duration == Duration.zero
- ? null
- : (value) => player.seek(
- Duration(
- milliseconds: value.round(),
- ),
- ),
- ),
- ),
- Row(
- children: [
- _RoundControlButton(
- tooltip: playing ? 'Pause' : 'Play',
- emphasized: true,
- onPressed: player.playOrPause,
- icon: playing
- ? Icons.pause_rounded
- : Icons.play_arrow_rounded,
- ),
- const SizedBox(width: 8),
- Text(
- tiny
- ? _format(position)
- : '${_format(position)} / ${_format(duration)}',
- style: const TextStyle(
- color: Colors.white,
- fontSize: 12,
- fontWeight: FontWeight.w700,
- ),
- ),
- const Spacer(),
- if (!compact) ...[
- _RoundControlButton(
- tooltip: 'Back 10s',
- onPressed: () => player.seek(
- _clampSeek(
- position -
- const Duration(seconds: 10),
- duration,
- ),
- ),
- icon: Icons.replay_10_rounded,
- ),
- const SizedBox(width: 5),
- _RoundControlButton(
- tooltip: 'Forward 10s',
- onPressed: () => player.seek(
- _clampSeek(
- position +
- const Duration(seconds: 10),
- duration,
- ),
- ),
- icon: Icons.forward_10_rounded,
- ),
- const SizedBox(width: 5),
- ],
- _RoundControlButton(
- tooltip: muted ? 'Unmute' : 'Mute',
- onPressed: onToggleMute,
- icon: muted
- ? Icons.volume_off_rounded
- : Icons.volume_up_rounded,
- ),
- const SizedBox(width: 5),
- _RoundControlButton(
- tooltip: halfVolume
- ? 'Normal app volume'
- : 'Half app volume',
- selected: halfVolume,
- onPressed: onToggleHalfVolume,
- icon: Icons.volume_down_rounded,
- ),
- const SizedBox(width: 5),
- _RoundControlButton(
- tooltip: loopVideo
- ? 'Disable repeat'
- : 'Repeat video',
- selected: loopVideo,
- onPressed: onToggleLoop,
- icon: loopVideo
- ? Icons.repeat_one_on_rounded
- : Icons.repeat_one_rounded,
- ),
- const SizedBox(width: 5),
- if (!compact) ...[
- _RoundControlButton(
- tooltip: coverVideo ? 'Fit' : 'Fill',
- selected: coverVideo,
- onPressed: onToggleFit,
- icon: coverVideo
- ? Icons.fit_screen_rounded
- : Icons.crop_free_rounded,
- ),
- const SizedBox(width: 5),
- ],
- _RoundControlButton(
- tooltip: fullscreen
- ? 'Exit fullscreen'
- : 'Fullscreen',
- onPressed: onFullscreen,
- icon: fullscreen
- ? Icons.fullscreen_exit_rounded
- : Icons.fullscreen_rounded,
- ),
- ],
- ),
- ],
- );
- },
- );
- },
- );
- },
- );
- },
- ),
- ),
- ),
- ),
- );
- }
-
- String _format(Duration duration) {
- final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
- final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
- final hours = duration.inHours;
- if (hours > 0) return '$hours:$minutes:$seconds';
- return '$minutes:$seconds';
- }
-
- Duration _clampSeek(Duration position, Duration duration) {
- if (position < Duration.zero) return Duration.zero;
- if (duration > Duration.zero && position > duration) return duration;
- return position;
- }
-}
+import 'dart:async';
+import 'dart:io';
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:media_kit/media_kit.dart';
+import 'package:media_kit_video/media_kit_video.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:webview_windows/webview_windows.dart';
+
+import '../../../../app/motion.dart';
+import '../../../../backend/backend.dart';
+
+final Map _playbackMemory =
+ {};
+
+class PostMediaViewer extends StatefulWidget {
+ const PostMediaViewer({
+ required this.post,
+ this.fullscreen = false,
+ this.initialPosition = Duration.zero,
+ this.autoplay = false,
+ this.initialLoop = false,
+ this.initialMuted = false,
+ this.initialCoverVideo = false,
+ this.initialHalfVolume = false,
+ this.qualityMode = MediaQualityMode.auto,
+ this.onPlaybackSnapshot,
+ this.onPlaybackPreferencesChanged,
+ super.key,
+ });
+
+ final Post post;
+ final bool fullscreen;
+ final Duration initialPosition;
+ final bool autoplay;
+ final bool initialLoop;
+ final bool initialMuted;
+ final bool initialCoverVideo;
+ final bool initialHalfVolume;
+ final MediaQualityMode qualityMode;
+ final ValueChanged? onPlaybackSnapshot;
+ final ValueChanged? onPlaybackPreferencesChanged;
+
+ @override
+ State createState() => _PostMediaViewerState();
+}
+
+class _PostMediaViewerState extends State
+ with AutomaticKeepAliveClientMixin {
+ Player? _player;
+ VideoController? _controller;
+ late List _imageUrls;
+ late List _videoUrls;
+ int _imageIndex = 0;
+ int _videoIndex = 0;
+ bool _controlsVisible = true;
+ bool _inFullscreen = false;
+ late bool _coverVideo;
+ late bool _muted;
+ late bool _loopVideo;
+ late bool _halfVolume;
+ String? _videoError;
+ Timer? _hideTimer;
+ StreamSubscription? _errorSubscription;
+ StreamSubscription? _positionSubscription;
+ StreamSubscription? _playingSubscription;
+
+ @override
+ void initState() {
+ super.initState();
+ _coverVideo = widget.initialCoverVideo;
+ _muted = widget.initialMuted;
+ _loopVideo = widget.initialLoop;
+ _halfVolume = widget.initialHalfVolume;
+ _imageUrls = _buildImageUrls(widget.post);
+ _videoUrls = _buildVideoUrls(widget.post);
+ if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
+ _initializeVideo();
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant PostMediaViewer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.post.cacheKey != widget.post.cacheKey) {
+ _disposeVideo();
+ _imageIndex = 0;
+ _videoIndex = 0;
+ _videoError = null;
+ _imageUrls = _buildImageUrls(widget.post);
+ _videoUrls = _buildVideoUrls(widget.post);
+ if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
+ _initializeVideo();
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ _disposeVideo();
+ super.dispose();
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ if (_isSwf(widget.post)) {
+ return _SwfMediaViewer(
+ post: widget.post,
+ url: _swfUrl(widget.post),
+ fullscreen: widget.fullscreen,
+ );
+ }
+
+ if (_controller != null) {
+ if (_inFullscreen) {
+ return ColoredBox(
+ color: Colors.black,
+ child: AspectRatio(
+ aspectRatio: widget.post.width > 0 && widget.post.height > 0
+ ? (widget.post.width / widget.post.height).clamp(0.35, 2.4)
+ : 16 / 9,
+ child: const Center(
+ child: Icon(Icons.fullscreen_rounded, color: Colors.white38),
+ ),
+ ),
+ );
+ }
+ return Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
+ },
+ child: Actions(
+ actions: {
+ _TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
+ onInvoke: (_) {
+ _togglePlay();
+ return null;
+ },
+ ),
+ },
+ child: Focus(
+ autofocus: true,
+ child: _VideoSurface(
+ player: _player!,
+ controller: _controller!,
+ aspectRatio: widget.post.width > 0 && widget.post.height > 0
+ ? widget.post.width / widget.post.height
+ : 16 / 9,
+ controlsVisible: _controlsVisible,
+ coverVideo: _coverVideo,
+ muted: _muted,
+ loopVideo: _loopVideo,
+ halfVolume: _halfVolume,
+ fullscreen: widget.fullscreen,
+ errorMessage: _videoError,
+ onInteract: _showControls,
+ onRetry: _retryVideo,
+ onToggleFit: () {
+ setState(() => _coverVideo = !_coverVideo);
+ _emitPlaybackPreferences();
+ _showControls();
+ },
+ onToggleMute: () async {
+ final nextMuted = !_muted;
+ setState(() => _muted = nextMuted);
+ await _applyVolume();
+ _emitPlaybackPreferences();
+ _showControls();
+ },
+ onToggleHalfVolume: () async {
+ setState(() {
+ _halfVolume = !_halfVolume;
+ if (_halfVolume) _muted = false;
+ });
+ await _applyVolume();
+ _emitPlaybackPreferences();
+ _showControls();
+ },
+ onToggleLoop: () async {
+ final nextLoop = !_loopVideo;
+ setState(() => _loopVideo = nextLoop);
+ await _player!.setPlaylistMode(
+ nextLoop ? PlaylistMode.single : PlaylistMode.none,
+ );
+ _emitPlaybackPreferences();
+ _showControls();
+ },
+ onFullscreen: widget.fullscreen
+ ? () => Navigator.of(context, rootNavigator: true)
+ .pop(_snapshot())
+ : () => _openFullscreen(context),
+ ),
+ ),
+ ),
+ );
+ }
+
+ final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
+ final image = CachedNetworkImage(
+ key: ValueKey(url),
+ imageUrl: url,
+ httpHeaders: _headersFor(widget.post),
+ fit: BoxFit.contain,
+ placeholder: (context, url) =>
+ const Center(child: CircularProgressIndicator()),
+ errorWidget: (context, url, error) {
+ if (_imageIndex < _imageUrls.length - 1) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) setState(() => _imageIndex++);
+ });
+ return const Center(child: CircularProgressIndicator());
+ }
+ return const Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.broken_image_rounded, size: 48),
+ SizedBox(height: 8),
+ Text('Could not load image'),
+ ],
+ ),
+ );
+ },
+ );
+ final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
+ return allowPinchZoom ? _ZoomableImage(child: image) : image;
+ }
+
+ void _initializeVideo() {
+ MediaKit.ensureInitialized();
+ _player = Player();
+ _controller = VideoController(_player!);
+ _applyVolume();
+ _player!.setPlaylistMode(
+ _loopVideo ? PlaylistMode.single : PlaylistMode.none,
+ );
+ _errorSubscription = _player!.stream.error.listen((message) {
+ if (!mounted) return;
+ if (_videoIndex < _videoUrls.length - 1) {
+ _videoIndex++;
+ _openVideo(play: false);
+ return;
+ }
+ setState(() => _videoError = message);
+ _showControls();
+ });
+ _positionSubscription = _player!.stream.position.listen((_) {
+ final snapshot = _snapshot();
+ _playbackMemory[widget.post.cacheKey] = snapshot;
+ widget.onPlaybackSnapshot?.call(snapshot);
+ });
+ _playingSubscription = _player!.stream.playing.listen((_) {
+ final snapshot = _snapshot();
+ _playbackMemory[widget.post.cacheKey] = snapshot;
+ widget.onPlaybackSnapshot?.call(snapshot);
+ });
+ _openVideo(play: widget.autoplay);
+ _scheduleControlsHide();
+ }
+
+ Future _openVideo({required bool play}) async {
+ final player = _player;
+ if (player == null || _videoUrls.isEmpty) return;
+ final remembered = _playbackMemory[widget.post.cacheKey];
+ final initialPosition = widget.initialPosition > Duration.zero
+ ? widget.initialPosition
+ : remembered?.position ?? Duration.zero;
+ setState(() => _videoError = null);
+ await player.open(
+ Media(
+ _videoUrls[_videoIndex],
+ httpHeaders: _headersFor(widget.post),
+ ),
+ play: false,
+ );
+ if (initialPosition > Duration.zero) {
+ await player.seek(initialPosition);
+ }
+ if (play) await player.play();
+ }
+
+ Future _retryVideo() async {
+ final player = _player;
+ if (player == null) return;
+ setState(() {
+ _videoError = null;
+ _videoIndex = 0;
+ _controlsVisible = true;
+ });
+ await player.stop();
+ await _openVideo(play: true);
+ _showControls();
+ }
+
+ void _disposeVideo() {
+ _hideTimer?.cancel();
+ _hideTimer = null;
+ _errorSubscription?.cancel();
+ _errorSubscription = null;
+ _positionSubscription?.cancel();
+ _positionSubscription = null;
+ _playingSubscription?.cancel();
+ _playingSubscription = null;
+ final snapshot = _snapshot();
+ _playbackMemory[widget.post.cacheKey] = snapshot;
+ widget.onPlaybackSnapshot?.call(snapshot);
+ _player?.dispose();
+ _player = null;
+ _controller = null;
+ }
+
+ List _buildImageUrls(Post post) {
+ return MediaUrlSelector.details(post, mode: widget.qualityMode);
+ }
+
+ List _buildVideoUrls(Post post) {
+ return MediaUrlSelector.video(post);
+ }
+
+ bool _isVideo(Post post) {
+ final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
+ return value.contains('video') ||
+ value.contains('.webm') ||
+ value.contains('.mp4') ||
+ value.contains('.mov');
+ }
+
+ bool _isSwf(Post post) {
+ final value =
+ '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}'
+ .toLowerCase();
+ return value.contains('swf') || value.contains('.swf');
+ }
+
+ String _swfUrl(Post post) {
+ for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) {
+ final value = url.trim();
+ if (value.toLowerCase().contains('.swf')) return value;
+ }
+ return post.fileUrl.trim();
+ }
+
+ Map _headersFor(Post post) {
+ return {
+ 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'Accept': '*/*',
+ if (post.providerName.toLowerCase().contains('gelbooru') ||
+ post.fileUrl.contains('gelbooru.com') ||
+ post.sampleUrl.contains('gelbooru.com') ||
+ post.previewUrl.contains('gelbooru.com'))
+ 'Referer': 'https://gelbooru.com/',
+ if (post.providerName.toLowerCase().contains('rule34') ||
+ post.fileUrl.contains('rule34') ||
+ post.sampleUrl.contains('rule34') ||
+ post.previewUrl.contains('rule34'))
+ 'Referer': 'https://rule34.xxx/',
+ };
+ }
+
+ void _showControls() {
+ if (!mounted) return;
+ setState(() => _controlsVisible = true);
+ _scheduleControlsHide();
+ }
+
+ void _scheduleControlsHide() {
+ _hideTimer?.cancel();
+ _hideTimer = Timer(const Duration(seconds: 3), () {
+ if (mounted && _videoError == null) {
+ setState(() => _controlsVisible = false);
+ }
+ });
+ }
+
+ Future _openFullscreen(BuildContext context) async {
+ final player = _player;
+ final controller = _controller;
+ if (player == null || controller == null) return;
+ setState(() => _inFullscreen = true);
+ if (!context.mounted) return;
+ final result = await Navigator.of(context, rootNavigator: true)
+ .push(
+ PageRouteBuilder(
+ opaque: true,
+ pageBuilder: (_, __, ___) => _FullscreenVideoPage(
+ player: player,
+ controller: controller,
+ aspectRatio: widget.post.width > 0 && widget.post.height > 0
+ ? widget.post.width / widget.post.height
+ : 16 / 9,
+ loopVideo: _loopVideo,
+ muted: _muted,
+ halfVolume: _halfVolume,
+ coverVideo: _coverVideo,
+ errorMessage: _videoError,
+ onRetry: _retryVideo,
+ onChanged: (snapshot) {
+ unawaited(_applyPlaybackSnapshot(snapshot));
+ },
+ ),
+ transitionsBuilder: (_, animation, __, child) => FadeTransition(
+ opacity: animation,
+ child: child,
+ ),
+ ),
+ );
+ if (mounted) setState(() => _inFullscreen = false);
+ if (result != null) {
+ await _applyPlaybackSnapshot(result);
+ return;
+ }
+ }
+
+ Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async {
+ if (!mounted) return;
+ setState(() {
+ _muted = snapshot.muted;
+ _loopVideo = snapshot.loopVideo;
+ _coverVideo = snapshot.coverVideo;
+ _halfVolume = snapshot.halfVolume;
+ });
+ await _applyVolume();
+ await _player?.setPlaylistMode(
+ _loopVideo ? PlaylistMode.single : PlaylistMode.none,
+ );
+ _emitPlaybackPreferences();
+ }
+
+ VideoPlaybackSnapshot _snapshot() {
+ final player = _player;
+ return VideoPlaybackSnapshot(
+ position: player?.state.position ?? Duration.zero,
+ playing: player?.state.playing ?? false,
+ muted: _muted,
+ halfVolume: _halfVolume,
+ loopVideo: _loopVideo,
+ coverVideo: _coverVideo,
+ );
+ }
+
+ Future _applyVolume() async {
+ await _player?.setVolume(_muted
+ ? 0
+ : _halfVolume
+ ? 50
+ : 100);
+ }
+
+ void _emitPlaybackPreferences() {
+ final snapshot = _snapshot();
+ _playbackMemory[widget.post.cacheKey] = snapshot;
+ widget.onPlaybackSnapshot?.call(snapshot);
+ widget.onPlaybackPreferencesChanged?.call(snapshot);
+ }
+
+ Future _togglePlay() async {
+ final player = _player;
+ if (player == null) return;
+ if (player.state.playing) {
+ await player.pause();
+ } else {
+ await player.play();
+ }
+ _showControls();
+ }
+}
+
+class _TogglePlayIntent extends Intent {
+ const _TogglePlayIntent();
+}
+
+class _SwfMediaViewer extends StatelessWidget {
+ const _SwfMediaViewer({
+ required this.post,
+ required this.url,
+ required this.fullscreen,
+ });
+
+ final Post post;
+ final String url;
+ final bool fullscreen;
+
+ @override
+ Widget build(BuildContext context) {
+ if (url.isEmpty) {
+ return const Center(child: Text('SWF URL is empty'));
+ }
+ if (Platform.isWindows) {
+ return _WindowsRuffleViewer(url: url, fullscreen: fullscreen);
+ }
+ return _SwfUnsupportedPanel(url: url);
+ }
+}
+
+class _WindowsRuffleViewer extends StatefulWidget {
+ const _WindowsRuffleViewer({
+ required this.url,
+ required this.fullscreen,
+ });
+
+ final String url;
+ final bool fullscreen;
+
+ @override
+ State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState();
+}
+
+class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> {
+ final _controller = WebviewController();
+ bool _loading = true;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _initialize();
+ }
+
+ @override
+ void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.url != widget.url) {
+ _load();
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final child = ClipRRect(
+ borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (_controller.value.isInitialized) Webview(_controller),
+ if (_loading)
+ const ColoredBox(
+ color: Colors.black,
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ if (_error != null)
+ _SwfErrorPanel(
+ message: _error!,
+ url: widget.url,
+ onRetry: _load,
+ ),
+ ],
+ ),
+ );
+ if (widget.fullscreen) return child;
+ return AspectRatio(aspectRatio: 4 / 3, child: child);
+ }
+
+ Future _initialize() async {
+ try {
+ await _controller.initialize();
+ _controller.loadingState.listen((state) {
+ if (!mounted) return;
+ setState(() => _loading = state == LoadingState.loading);
+ });
+ _controller.onLoadError.listen((error) {
+ if (!mounted) return;
+ setState(() => _error = 'Ruffle WebView error: $error');
+ });
+ await _load();
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _loading = false;
+ _error =
+ 'Could not start Windows WebView2. Install WebView2 Runtime or open original.';
+ });
+ }
+ }
+
+ Future _load() async {
+ if (!_controller.value.isInitialized) return;
+ setState(() {
+ _loading = true;
+ _error = null;
+ });
+ try {
+ await _controller.loadStringContent(_ruffleHtml(widget.url));
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _loading = false;
+ _error = 'Could not load Ruffle viewer: $error';
+ });
+ }
+ }
+
+ String _ruffleHtml(String swfUrl) {
+ final escapedUrl = _htmlEscape(swfUrl);
+ return '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+''';
+ }
+
+ String _htmlEscape(String value) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+ }
+}
+
+class _SwfUnsupportedPanel extends StatelessWidget {
+ const _SwfUnsupportedPanel({required this.url});
+
+ final String url;
+
+ @override
+ Widget build(BuildContext context) {
+ return _SwfErrorPanel(
+ message: Platform.isAndroid
+ ? 'Flash/SWF playback is desktop only.'
+ : 'SWF playback is available on Windows only in this build.',
+ url: url,
+ );
+ }
+}
+
+class _SwfErrorPanel extends StatelessWidget {
+ const _SwfErrorPanel({
+ required this.message,
+ required this.url,
+ this.onRetry,
+ });
+
+ final String message;
+ final String url;
+ final VoidCallback? onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return ColoredBox(
+ color: Colors.black,
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(22),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.extension_rounded,
+ color: Colors.white, size: 48),
+ const SizedBox(height: 12),
+ Text(
+ 'SWF / Flash',
+ style: Theme.of(context)
+ .textTheme
+ .titleLarge
+ ?.copyWith(color: Colors.white),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.white70),
+ ),
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ alignment: WrapAlignment.center,
+ children: [
+ if (onRetry != null)
+ FilledButton.icon(
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh_rounded),
+ label: const Text('Retry'),
+ ),
+ FilledButton.tonalIcon(
+ onPressed: () => launchUrl(
+ Uri.parse(url),
+ mode: LaunchMode.externalApplication,
+ ),
+ icon: const Icon(Icons.open_in_new_rounded),
+ label: const Text('Open original'),
+ ),
+ FilledButton.tonalIcon(
+ style: FilledButton.styleFrom(
+ backgroundColor:
+ scheme.primaryContainer.withValues(alpha: 0.82),
+ ),
+ onPressed: () => launchUrl(
+ Uri.parse(
+ 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}',
+ ),
+ mode: LaunchMode.externalApplication,
+ ),
+ icon: const Icon(Icons.play_circle_outline_rounded),
+ label: const Text('Open in Ruffle'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class VideoPlaybackSnapshot {
+ const VideoPlaybackSnapshot({
+ required this.position,
+ required this.playing,
+ required this.muted,
+ required this.halfVolume,
+ required this.loopVideo,
+ required this.coverVideo,
+ });
+
+ final Duration position;
+ final bool playing;
+ final bool muted;
+ final bool halfVolume;
+ final bool loopVideo;
+ final bool coverVideo;
+}
+
+class _FullscreenVideoPage extends StatefulWidget {
+ const _FullscreenVideoPage({
+ required this.player,
+ required this.controller,
+ required this.aspectRatio,
+ required this.loopVideo,
+ required this.muted,
+ required this.halfVolume,
+ required this.coverVideo,
+ required this.errorMessage,
+ required this.onRetry,
+ required this.onChanged,
+ });
+
+ final Player player;
+ final VideoController controller;
+ final double aspectRatio;
+ final bool loopVideo;
+ final bool muted;
+ final bool halfVolume;
+ final bool coverVideo;
+ final String? errorMessage;
+ final VoidCallback onRetry;
+ final ValueChanged onChanged;
+
+ @override
+ State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
+}
+
+class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
+ bool _controlsVisible = true;
+ late bool _coverVideo;
+ late bool _muted;
+ late bool _loopVideo;
+ late bool _halfVolume;
+ Timer? _hideTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ _coverVideo = widget.coverVideo;
+ _muted = widget.muted;
+ _loopVideo = widget.loopVideo;
+ _halfVolume = widget.halfVolume;
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ SystemChrome.setPreferredOrientations([
+ DeviceOrientation.landscapeLeft,
+ DeviceOrientation.landscapeRight,
+ DeviceOrientation.portraitUp,
+ ]);
+ _scheduleControlsHide();
+ }
+
+ @override
+ void dispose() {
+ _hideTimer?.cancel();
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+ SystemChrome.setPreferredOrientations(DeviceOrientation.values);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PopScope(
+ canPop: false,
+ onPopInvokedWithResult: (didPop, _) {
+ if (didPop) return;
+ Navigator.of(context, rootNavigator: true).pop(_snapshot());
+ },
+ child: Scaffold(
+ backgroundColor: Colors.black,
+ body: Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
+ LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
+ },
+ child: Actions(
+ actions: {
+ _CloseVideoIntent: CallbackAction<_CloseVideoIntent>(
+ onInvoke: (_) {
+ Navigator.of(context, rootNavigator: true).pop(_snapshot());
+ return null;
+ },
+ ),
+ _TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
+ onInvoke: (_) {
+ widget.player.playOrPause();
+ _showControls();
+ return null;
+ },
+ ),
+ },
+ child: Focus(
+ autofocus: true,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: _VideoSurface(
+ player: widget.player,
+ controller: widget.controller,
+ aspectRatio: widget.aspectRatio,
+ controlsVisible: _controlsVisible,
+ coverVideo: _coverVideo,
+ muted: _muted,
+ loopVideo: _loopVideo,
+ halfVolume: _halfVolume,
+ fullscreen: true,
+ errorMessage: widget.errorMessage,
+ onInteract: _showControls,
+ onRetry: widget.onRetry,
+ onToggleFit: () {
+ setState(() => _coverVideo = !_coverVideo);
+ widget.onChanged(_snapshot());
+ _showControls();
+ },
+ onToggleMute: () async {
+ setState(() => _muted = !_muted);
+ await _applyVolume();
+ widget.onChanged(_snapshot());
+ _showControls();
+ },
+ onToggleHalfVolume: () async {
+ setState(() {
+ _halfVolume = !_halfVolume;
+ if (_halfVolume) _muted = false;
+ });
+ await _applyVolume();
+ widget.onChanged(_snapshot());
+ _showControls();
+ },
+ onToggleLoop: () async {
+ setState(() => _loopVideo = !_loopVideo);
+ await widget.player.setPlaylistMode(
+ _loopVideo ? PlaylistMode.single : PlaylistMode.none,
+ );
+ widget.onChanged(_snapshot());
+ _showControls();
+ },
+ onFullscreen: () => Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop(_snapshot()),
+ ),
+ ),
+ Positioned(
+ top: 12,
+ right: 12,
+ child: SafeArea(
+ child: IconButton.filledTonal(
+ tooltip: 'Close fullscreen',
+ onPressed: () => Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop(_snapshot()),
+ icon: const Icon(Icons.close_rounded),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ void _showControls() {
+ if (!mounted) return;
+ setState(() => _controlsVisible = true);
+ _scheduleControlsHide();
+ }
+
+ void _scheduleControlsHide() {
+ _hideTimer?.cancel();
+ _hideTimer = Timer(const Duration(seconds: 3), () {
+ if (mounted && widget.errorMessage == null) {
+ setState(() => _controlsVisible = false);
+ }
+ });
+ }
+
+ Future _applyVolume() async {
+ await widget.player.setVolume(_muted
+ ? 0
+ : _halfVolume
+ ? 50
+ : 100);
+ }
+
+ VideoPlaybackSnapshot _snapshot() {
+ return VideoPlaybackSnapshot(
+ position: widget.player.state.position,
+ playing: widget.player.state.playing,
+ muted: _muted,
+ halfVolume: _halfVolume,
+ loopVideo: _loopVideo,
+ coverVideo: _coverVideo,
+ );
+ }
+}
+
+class _CloseVideoIntent extends Intent {
+ const _CloseVideoIntent();
+}
+
+class _VideoSurface extends StatelessWidget {
+ const _VideoSurface({
+ required this.player,
+ required this.controller,
+ required this.aspectRatio,
+ required this.controlsVisible,
+ required this.coverVideo,
+ required this.muted,
+ required this.halfVolume,
+ required this.loopVideo,
+ required this.fullscreen,
+ required this.errorMessage,
+ required this.onInteract,
+ required this.onRetry,
+ required this.onToggleFit,
+ required this.onToggleMute,
+ required this.onToggleHalfVolume,
+ required this.onToggleLoop,
+ required this.onFullscreen,
+ });
+
+ final Player player;
+ final VideoController controller;
+ final double aspectRatio;
+ final bool controlsVisible;
+ final bool coverVideo;
+ final bool muted;
+ final bool halfVolume;
+ final bool loopVideo;
+ final bool fullscreen;
+ final String? errorMessage;
+ final VoidCallback onInteract;
+ final VoidCallback onRetry;
+ final VoidCallback onToggleFit;
+ final VoidCallback onToggleMute;
+ final VoidCallback onToggleHalfVolume;
+ final VoidCallback onToggleLoop;
+ final VoidCallback onFullscreen;
+
+ @override
+ Widget build(BuildContext context) {
+ final child = Stack(
+ fit: StackFit.expand,
+ children: [
+ ColoredBox(
+ color: Colors.black,
+ child: Video(
+ controller: controller,
+ fit: coverVideo ? BoxFit.cover : BoxFit.contain,
+ controls: null,
+ ),
+ ),
+ StreamBuilder(
+ stream: player.stream.buffering,
+ initialData: player.state.buffering,
+ builder: (context, snapshot) {
+ if (snapshot.data != true || errorMessage != null) {
+ return const SizedBox.shrink();
+ }
+ return const Center(child: CircularProgressIndicator());
+ },
+ ),
+ if (errorMessage != null)
+ VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
+ if (errorMessage == null)
+ StreamBuilder(
+ stream: player.stream.playing,
+ initialData: player.state.playing,
+ builder: (context, snapshot) {
+ final playing = snapshot.data ?? false;
+ return AnimatedScale(
+ scale: controlsVisible || !playing ? 1 : 0.85,
+ duration: AppMotion.duration(context, 160),
+ child: AnimatedOpacity(
+ opacity: controlsVisible || !playing ? 1 : 0,
+ duration: AppMotion.duration(context, 160),
+ child: Center(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.46),
+ shape: BoxShape.circle,
+ boxShadow: const [
+ BoxShadow(
+ color: Colors.black45,
+ blurRadius: 24,
+ offset: Offset(0, 10),
+ ),
+ ],
+ ),
+ child: IconButton(
+ tooltip: playing ? 'Pause' : 'Play',
+ iconSize: fullscreen ? 58 : 44,
+ color: Colors.white,
+ onPressed: () {
+ player.playOrPause();
+ onInteract();
+ },
+ icon: Icon(
+ playing
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ IgnorePointer(
+ ignoring: !controlsVisible || errorMessage != null,
+ child: AnimatedOpacity(
+ opacity: controlsVisible || errorMessage != null ? 1 : 0,
+ duration: AppMotion.duration(context, 180),
+ child: _VideoControls(
+ player: player,
+ muted: muted,
+ halfVolume: halfVolume,
+ loopVideo: loopVideo,
+ coverVideo: coverVideo,
+ fullscreen: fullscreen,
+ onToggleFit: onToggleFit,
+ onToggleMute: onToggleMute,
+ onToggleHalfVolume: onToggleHalfVolume,
+ onToggleLoop: onToggleLoop,
+ onFullscreen: onFullscreen,
+ ),
+ ),
+ ),
+ ],
+ );
+
+ return MouseRegion(
+ onHover: (_) => onInteract(),
+ child: GestureDetector(
+ onTap: onInteract,
+ onLongPress: player.playOrPause,
+ onDoubleTap: onFullscreen,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(fullscreen ? 0 : 10),
+ child: fullscreen
+ ? SizedBox.expand(child: child)
+ : AspectRatio(
+ aspectRatio: aspectRatio.clamp(0.35, 2.4),
+ child: child,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class VideoErrorOverlay extends StatelessWidget {
+ const VideoErrorOverlay({
+ required this.message,
+ required this.onRetry,
+ super.key,
+ });
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: Colors.black.withValues(alpha: 0.62),
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error_outline_rounded,
+ color: Colors.white, size: 42),
+ const SizedBox(height: 10),
+ Text(
+ 'Could not load video',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(color: Colors.white),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ message,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.white70),
+ ),
+ const SizedBox(height: 14),
+ FilledButton.icon(
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh_rounded),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ZoomableImage extends StatefulWidget {
+ const _ZoomableImage({required this.child});
+
+ final Widget child;
+
+ @override
+ State<_ZoomableImage> createState() => _ZoomableImageState();
+}
+
+class _ZoomableImageState extends State<_ZoomableImage> {
+ final _controller = TransformationController();
+ TapDownDetails? _doubleTapDetails;
+ bool _zoomed = false;
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onDoubleTapDown: (details) => _doubleTapDetails = details,
+ onDoubleTap: _toggleZoom,
+ child: InteractiveViewer(
+ transformationController: _controller,
+ minScale: 1,
+ maxScale: 5,
+ panEnabled: _zoomed,
+ scaleEnabled: true,
+ clipBehavior: Clip.none,
+ onInteractionEnd: (_) {
+ final scale = _controller.value.getMaxScaleOnAxis();
+ if (mounted) setState(() => _zoomed = scale > 1.03);
+ },
+ child: widget.child,
+ ),
+ );
+ }
+
+ void _toggleZoom() {
+ final tap = _doubleTapDetails?.localPosition ?? Offset.zero;
+ if (_zoomed) {
+ _controller.value = Matrix4.identity();
+ setState(() => _zoomed = false);
+ return;
+ }
+ _controller.value = Matrix4.identity()
+ ..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1)
+ ..scaleByDouble(2.3, 2.3, 1, 1);
+ setState(() => _zoomed = true);
+ }
+}
+
+class _RoundControlButton extends StatelessWidget {
+ const _RoundControlButton({
+ required this.tooltip,
+ required this.icon,
+ required this.onPressed,
+ this.emphasized = false,
+ this.selected = false,
+ });
+
+ final String tooltip;
+ final IconData icon;
+ final VoidCallback onPressed;
+ final bool emphasized;
+ final bool selected;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ final background = emphasized
+ ? scheme.primary
+ : selected
+ ? scheme.primaryContainer.withValues(alpha: 0.96)
+ : Colors.white.withValues(alpha: 0.13);
+ final foreground = emphasized
+ ? scheme.onPrimary
+ : selected
+ ? scheme.onPrimaryContainer
+ : Colors.white;
+ return Tooltip(
+ message: tooltip,
+ child: InkResponse(
+ onTap: onPressed,
+ radius: emphasized ? 23 : 21,
+ child: AnimatedContainer(
+ duration: AppMotion.duration(context, 140),
+ width: emphasized ? 38 : 34,
+ height: emphasized ? 38 : 34,
+ decoration: BoxDecoration(
+ color: background,
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
+ ),
+ child: Icon(icon, color: foreground, size: emphasized ? 24 : 20),
+ ),
+ ),
+ );
+ }
+}
+
+class _VideoControls extends StatelessWidget {
+ const _VideoControls({
+ required this.player,
+ required this.muted,
+ required this.halfVolume,
+ required this.loopVideo,
+ required this.coverVideo,
+ required this.fullscreen,
+ required this.onToggleFit,
+ required this.onToggleMute,
+ required this.onToggleHalfVolume,
+ required this.onToggleLoop,
+ required this.onFullscreen,
+ });
+
+ final Player player;
+ final bool muted;
+ final bool halfVolume;
+ final bool loopVideo;
+ final bool coverVideo;
+ final bool fullscreen;
+ final VoidCallback onToggleFit;
+ final VoidCallback onToggleMute;
+ final VoidCallback onToggleHalfVolume;
+ final VoidCallback onToggleLoop;
+ final VoidCallback onFullscreen;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: SafeArea(
+ minimum: EdgeInsets.fromLTRB(
+ fullscreen ? 14 : 8,
+ 0,
+ fullscreen ? 14 : 8,
+ fullscreen ? 10 : 8,
+ ),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.58),
+ borderRadius: BorderRadius.circular(fullscreen ? 18 : 14),
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
+ child: StreamBuilder(
+ stream: player.stream.playing,
+ initialData: player.state.playing,
+ builder: (context, playingSnapshot) {
+ final playing = playingSnapshot.data ?? false;
+ return StreamBuilder(
+ stream: player.stream.duration,
+ initialData: player.state.duration,
+ builder: (context, durationSnapshot) {
+ final duration = durationSnapshot.data ?? Duration.zero;
+ return StreamBuilder(
+ stream: player.stream.position,
+ initialData: player.state.position,
+ builder: (context, positionSnapshot) {
+ final position = positionSnapshot.data ?? Duration.zero;
+ final maxMs = duration.inMilliseconds
+ .clamp(1, 1 << 31)
+ .toDouble();
+ final valueMs = position.inMilliseconds
+ .clamp(0, maxMs.toInt())
+ .toDouble();
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final compact = constraints.maxWidth < 560;
+ final tiny = constraints.maxWidth < 390;
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SliderTheme(
+ data: SliderTheme.of(context).copyWith(
+ activeTrackColor: scheme.primary,
+ inactiveTrackColor:
+ Colors.white.withValues(alpha: 0.24),
+ trackHeight: 3,
+ thumbColor: scheme.primary,
+ overlayColor:
+ scheme.primary.withValues(alpha: 0.18),
+ thumbShape: const RoundSliderThumbShape(
+ enabledThumbRadius: 5,
+ ),
+ ),
+ child: Slider(
+ value: valueMs,
+ max: maxMs,
+ onChanged: duration == Duration.zero
+ ? null
+ : (value) => player.seek(
+ Duration(
+ milliseconds: value.round(),
+ ),
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ _RoundControlButton(
+ tooltip: playing ? 'Pause' : 'Play',
+ emphasized: true,
+ onPressed: player.playOrPause,
+ icon: playing
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ tiny
+ ? _format(position)
+ : '${_format(position)} / ${_format(duration)}',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const Spacer(),
+ if (!compact) ...[
+ _RoundControlButton(
+ tooltip: 'Back 10s',
+ onPressed: () => player.seek(
+ _clampSeek(
+ position -
+ const Duration(seconds: 10),
+ duration,
+ ),
+ ),
+ icon: Icons.replay_10_rounded,
+ ),
+ const SizedBox(width: 5),
+ _RoundControlButton(
+ tooltip: 'Forward 10s',
+ onPressed: () => player.seek(
+ _clampSeek(
+ position +
+ const Duration(seconds: 10),
+ duration,
+ ),
+ ),
+ icon: Icons.forward_10_rounded,
+ ),
+ const SizedBox(width: 5),
+ ],
+ _RoundControlButton(
+ tooltip: muted ? 'Unmute' : 'Mute',
+ onPressed: onToggleMute,
+ icon: muted
+ ? Icons.volume_off_rounded
+ : Icons.volume_up_rounded,
+ ),
+ const SizedBox(width: 5),
+ _RoundControlButton(
+ tooltip: halfVolume
+ ? 'Normal app volume'
+ : 'Half app volume',
+ selected: halfVolume,
+ onPressed: onToggleHalfVolume,
+ icon: Icons.volume_down_rounded,
+ ),
+ const SizedBox(width: 5),
+ _RoundControlButton(
+ tooltip: loopVideo
+ ? 'Disable repeat'
+ : 'Repeat video',
+ selected: loopVideo,
+ onPressed: onToggleLoop,
+ icon: loopVideo
+ ? Icons.repeat_one_on_rounded
+ : Icons.repeat_one_rounded,
+ ),
+ const SizedBox(width: 5),
+ if (!compact) ...[
+ _RoundControlButton(
+ tooltip: coverVideo ? 'Fit' : 'Fill',
+ selected: coverVideo,
+ onPressed: onToggleFit,
+ icon: coverVideo
+ ? Icons.fit_screen_rounded
+ : Icons.crop_free_rounded,
+ ),
+ const SizedBox(width: 5),
+ ],
+ _RoundControlButton(
+ tooltip: fullscreen
+ ? 'Exit fullscreen'
+ : 'Fullscreen',
+ onPressed: onFullscreen,
+ icon: fullscreen
+ ? Icons.fullscreen_exit_rounded
+ : Icons.fullscreen_rounded,
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ String _format(Duration duration) {
+ final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
+ final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
+ final hours = duration.inHours;
+ if (hours > 0) return '$hours:$minutes:$seconds';
+ return '$minutes:$seconds';
+ }
+
+ Duration _clampSeek(Duration position, Duration duration) {
+ if (position < Duration.zero) return Duration.zero;
+ if (duration > Duration.zero && position > duration) return duration;
+ return position;
+ }
+}
diff --git a/linux/.gitignore b/linux/.gitignore
index d3896c9..c7ea17f 100644
--- a/linux/.gitignore
+++ b/linux/.gitignore
@@ -1 +1 @@
-flutter/ephemeral
+flutter/ephemeral
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
index a7f4283..49becf7 100644
--- a/linux/CMakeLists.txt
+++ b/linux/CMakeLists.txt
@@ -1,128 +1,128 @@
-# Project-level configuration.
-cmake_minimum_required(VERSION 3.13)
-project(runner 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 "gel_rule_app")
-# The unique GTK application identifier for this application. See:
-# https://wiki.gnome.org/HowDoI/ChooseApplicationID
-set(APPLICATION_ID "com.example.gel_rule_app")
-
-# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
-# versions of CMake.
-cmake_policy(SET CMP0063 NEW)
-
-# Load bundled libraries from the lib/ directory relative to the binary.
-set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
-
-# Root filesystem for cross-building.
-if(FLUTTER_TARGET_PLATFORM_SYSROOT)
- set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
- set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
- set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
- set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
- set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
-endif()
-
-# Define build configuration options.
-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()
-
-# 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_14)
- target_compile_options(${TARGET} PRIVATE -Wall -Werror)
- target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
- target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
-endfunction()
-
-# Flutter library and tool build rules.
-set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
-add_subdirectory(${FLUTTER_MANAGED_DIR})
-
-# System-level dependencies.
-find_package(PkgConfig REQUIRED)
-pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
-
-# Application build; see runner/CMakeLists.txt.
-add_subdirectory("runner")
-
-# Run the Flutter tool portions of the build. This must not be removed.
-add_dependencies(${BINARY_NAME} flutter_assemble)
-
-# Only the install-generated bundle's copy of the executable will launch
-# correctly, since the resources must in the right relative locations. To avoid
-# people trying to run the unbundled copy, put it in a subdirectory instead of
-# the default top-level location.
-set_target_properties(${BINARY_NAME}
- PROPERTIES
- RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
-)
-
-
-# Generated plugin build rules, which manage building the plugins and adding
-# them to the application.
-include(flutter/generated_plugins.cmake)
-
-
-# === Installation ===
-# By default, "installing" just makes a relocatable bundle in the build
-# directory.
-set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
-if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
- set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
-endif()
-
-# Start with a clean build bundle directory every time.
-install(CODE "
- file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
- " COMPONENT Runtime)
-
-set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
-set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
-
-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)
-
-foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
- install(FILES "${bundled_library}"
- DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-endforeach(bundled_library)
-
-# Copy the native assets provided by the build.dart from all packages.
-set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
-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.
-if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
- install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-endif()
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.13)
+project(runner 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 "gel_rule_app")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.example.gel_rule_app")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+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()
+
+# 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_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+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)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+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.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt
index d5bd016..27860e8 100644
--- a/linux/flutter/CMakeLists.txt
+++ b/linux/flutter/CMakeLists.txt
@@ -1,88 +1,88 @@
-# This file controls Flutter-level build steps. It should not be edited.
-cmake_minimum_required(VERSION 3.10)
-
-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.
-
-# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
-# which isn't available in 3.10.
-function(list_prepend LIST_NAME PREFIX)
- set(NEW_LIST "")
- foreach(element ${${LIST_NAME}})
- list(APPEND NEW_LIST "${PREFIX}${element}")
- endforeach(element)
- set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
-endfunction()
-
-# === Flutter Library ===
-# System-level dependencies.
-find_package(PkgConfig REQUIRED)
-pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
-pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
-pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
-
-set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
-
-# 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/lib/libapp.so" PARENT_SCOPE)
-
-list(APPEND FLUTTER_LIBRARY_HEADERS
- "fl_basic_message_channel.h"
- "fl_binary_codec.h"
- "fl_binary_messenger.h"
- "fl_dart_project.h"
- "fl_engine.h"
- "fl_json_message_codec.h"
- "fl_json_method_codec.h"
- "fl_message_codec.h"
- "fl_method_call.h"
- "fl_method_channel.h"
- "fl_method_codec.h"
- "fl_method_response.h"
- "fl_plugin_registrar.h"
- "fl_plugin_registry.h"
- "fl_standard_message_codec.h"
- "fl_standard_method_codec.h"
- "fl_string_codec.h"
- "fl_value.h"
- "fl_view.h"
- "flutter_linux.h"
-)
-list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
-add_library(flutter INTERFACE)
-target_include_directories(flutter INTERFACE
- "${EPHEMERAL_DIR}"
-)
-target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
-target_link_libraries(flutter INTERFACE
- PkgConfig::GTK
- PkgConfig::GLIB
- PkgConfig::GIO
-)
-add_dependencies(flutter 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.
-add_custom_command(
- OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
- ${CMAKE_CURRENT_BINARY_DIR}/_phony_
- COMMAND ${CMAKE_COMMAND} -E env
- ${FLUTTER_TOOL_ENVIRONMENT}
- "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
- ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
- VERBATIM
-)
-add_custom_target(flutter_assemble DEPENDS
- "${FLUTTER_LIBRARY}"
- ${FLUTTER_LIBRARY_HEADERS}
-)
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+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.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# 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/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter 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.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt
index e97dabc..7ed6f3e 100644
--- a/linux/runner/CMakeLists.txt
+++ b/linux/runner/CMakeLists.txt
@@ -1,26 +1,26 @@
-cmake_minimum_required(VERSION 3.13)
-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}
- "main.cc"
- "my_application.cc"
- "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
-)
-
-# 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 application ID.
-add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
-
-# Add dependency libraries. Add any application-specific dependencies here.
-target_link_libraries(${BINARY_NAME} PRIVATE flutter)
-target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
-
-target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+cmake_minimum_required(VERSION 3.13)
+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}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# 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 application ID.
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
diff --git a/linux/runner/main.cc b/linux/runner/main.cc
index e7c5c54..4340ffc 100644
--- a/linux/runner/main.cc
+++ b/linux/runner/main.cc
@@ -1,6 +1,6 @@
-#include "my_application.h"
-
-int main(int argc, char** argv) {
- g_autoptr(MyApplication) app = my_application_new();
- return g_application_run(G_APPLICATION(app), argc, argv);
-}
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc
index 172a86b..8332c30 100644
--- a/linux/runner/my_application.cc
+++ b/linux/runner/my_application.cc
@@ -1,148 +1,148 @@
-#include "my_application.h"
-
-#include
-#ifdef GDK_WINDOWING_X11
-#include
-#endif
-
-#include "flutter/generated_plugin_registrant.h"
-
-struct _MyApplication {
- GtkApplication parent_instance;
- char** dart_entrypoint_arguments;
-};
-
-G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
-
-// Called when first Flutter frame received.
-static void first_frame_cb(MyApplication* self, FlView* view) {
- gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
-}
-
-// Implements GApplication::activate.
-static void my_application_activate(GApplication* application) {
- MyApplication* self = MY_APPLICATION(application);
- GtkWindow* window =
- GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
-
- // Use a header bar when running in GNOME as this is the common style used
- // by applications and is the setup most users will be using (e.g. Ubuntu
- // desktop).
- // If running on X and not using GNOME then just use a traditional title bar
- // in case the window manager does more exotic layout, e.g. tiling.
- // If running on Wayland assume the header bar will work (may need changing
- // if future cases occur).
- gboolean use_header_bar = TRUE;
-#ifdef GDK_WINDOWING_X11
- GdkScreen* screen = gtk_window_get_screen(window);
- if (GDK_IS_X11_SCREEN(screen)) {
- const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
- if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
- use_header_bar = FALSE;
- }
- }
-#endif
- if (use_header_bar) {
- GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
- gtk_widget_show(GTK_WIDGET(header_bar));
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Called when first Flutter frame received.
+static void first_frame_cb(MyApplication* self, FlView* view) {
+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
+}
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "Lunaris");
- gtk_header_bar_set_show_close_button(header_bar, TRUE);
- gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
- } else {
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
gtk_window_set_title(window, "Lunaris");
- }
-
- gtk_window_set_default_size(window, 1280, 720);
-
- g_autoptr(FlDartProject) project = fl_dart_project_new();
- fl_dart_project_set_dart_entrypoint_arguments(
- project, self->dart_entrypoint_arguments);
-
- FlView* view = fl_view_new(project);
- GdkRGBA background_color;
- // Background defaults to black, override it here if necessary, e.g. #00000000
- // for transparent.
- gdk_rgba_parse(&background_color, "#000000");
- fl_view_set_background_color(view, &background_color);
- gtk_widget_show(GTK_WIDGET(view));
- gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
-
- // Show the window when Flutter renders.
- // Requires the view to be realized so we can start rendering.
- g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
- self);
- gtk_widget_realize(GTK_WIDGET(view));
-
- fl_register_plugins(FL_PLUGIN_REGISTRY(view));
-
- gtk_widget_grab_focus(GTK_WIDGET(view));
-}
-
-// Implements GApplication::local_command_line.
-static gboolean my_application_local_command_line(GApplication* application,
- gchar*** arguments,
- int* exit_status) {
- MyApplication* self = MY_APPLICATION(application);
- // Strip out the first argument as it is the binary name.
- self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
-
- g_autoptr(GError) error = nullptr;
- if (!g_application_register(application, nullptr, &error)) {
- g_warning("Failed to register: %s", error->message);
- *exit_status = 1;
- return TRUE;
- }
-
- g_application_activate(application);
- *exit_status = 0;
-
- return TRUE;
-}
-
-// Implements GApplication::startup.
-static void my_application_startup(GApplication* application) {
- // MyApplication* self = MY_APPLICATION(object);
-
- // Perform any actions required at application startup.
-
- G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
-}
-
-// Implements GApplication::shutdown.
-static void my_application_shutdown(GApplication* application) {
- // MyApplication* self = MY_APPLICATION(object);
-
- // Perform any actions required at application shutdown.
-
- G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
-}
-
-// Implements GObject::dispose.
-static void my_application_dispose(GObject* object) {
- MyApplication* self = MY_APPLICATION(object);
- g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
- G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
-}
-
-static void my_application_class_init(MyApplicationClass* klass) {
- G_APPLICATION_CLASS(klass)->activate = my_application_activate;
- G_APPLICATION_CLASS(klass)->local_command_line =
- my_application_local_command_line;
- G_APPLICATION_CLASS(klass)->startup = my_application_startup;
- G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
- G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
-}
-
-static void my_application_init(MyApplication* self) {}
-
-MyApplication* my_application_new() {
- // Set the program name to the application ID, which helps various systems
- // like GTK and desktop environments map this running application to its
- // corresponding .desktop file. This ensures better integration by allowing
- // the application to be recognized beyond its binary name.
- g_set_prgname(APPLICATION_ID);
-
- return MY_APPLICATION(g_object_new(my_application_get_type(),
- "application-id", APPLICATION_ID, "flags",
- G_APPLICATION_NON_UNIQUE, nullptr));
-}
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(
+ project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ GdkRGBA background_color;
+ // Background defaults to black, override it here if necessary, e.g. #00000000
+ // for transparent.
+ gdk_rgba_parse(&background_color, "#000000");
+ fl_view_set_background_color(view, &background_color);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ // Show the window when Flutter renders.
+ // Requires the view to be realized so we can start rendering.
+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
+ self);
+ gtk_widget_realize(GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application,
+ gchar*** arguments,
+ int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line =
+ my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ // Set the program name to the application ID, which helps various systems
+ // like GTK and desktop environments map this running application to its
+ // corresponding .desktop file. This ensures better integration by allowing
+ // the application to be recognized beyond its binary name.
+ g_set_prgname(APPLICATION_ID);
+
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID, "flags",
+ G_APPLICATION_NON_UNIQUE, nullptr));
+}
diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h
index db16367..c4c4a71 100644
--- a/linux/runner/my_application.h
+++ b/linux/runner/my_application.h
@@ -1,21 +1,21 @@
-#ifndef FLUTTER_MY_APPLICATION_H_
-#define FLUTTER_MY_APPLICATION_H_
-
-#include
-
-G_DECLARE_FINAL_TYPE(MyApplication,
- my_application,
- MY,
- APPLICATION,
- GtkApplication)
-
-/**
- * my_application_new:
- *
- * Creates a new Flutter-based application.
- *
- * Returns: a new #MyApplication.
- */
-MyApplication* my_application_new();
-
-#endif // FLUTTER_MY_APPLICATION_H_
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication,
+ my_application,
+ MY,
+ APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/pubspec.lock b/pubspec.lock
index 19c6cc0..895bf85 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: code_assets
- sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f
+ sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
url: "https://pub.dev"
source: hosted
- version: "1.1.0"
+ version: "1.2.0"
code_builder:
dependency: transitive
description:
@@ -289,6 +289,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ ffi_leak_tracker:
+ dependency: transitive
+ description:
+ name: ffi_leak_tracker
+ sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.2"
file:
dependency: transitive
description:
@@ -675,10 +683,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.18.0"
mime:
dependency: transitive
description:
@@ -731,18 +739,18 @@ packages:
dependency: transitive
description:
name: package_info_plus
- sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
+ sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
url: "https://pub.dev"
source: hosted
- version: "9.0.1"
+ version: "10.1.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
- sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
+ sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
- version: "3.2.1"
+ version: "4.1.0"
path:
dependency: "direct main"
description:
@@ -1056,10 +1064,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.11"
time:
dependency: transitive
description:
@@ -1200,10 +1208,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus
- sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
+ sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53"
url: "https://pub.dev"
source: hosted
- version: "1.5.2"
+ version: "1.6.1"
wakelock_plus_platform_interface:
dependency: transitive
description:
@@ -1256,10 +1264,10 @@ packages:
dependency: transitive
description:
name: win32
- sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+ sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
- version: "5.15.0"
+ version: "6.3.0"
xdg_directories:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index a62c54d..24ecb3d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 2.0.1+21
+version: 2.0.1+22
environment:
sdk: ">=3.3.0 <4.0.0"
diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart
index dee3cfe..ea749cf 100644
--- a/test/backend/isar_integration_test.dart
+++ b/test/backend/isar_integration_test.dart
@@ -1,3 +1,4 @@
+import 'dart:ffi' show Abi;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@@ -15,6 +16,7 @@ import 'package:gel_rule_app/core/cache/cache_service.dart';
import 'package:gel_rule_app/core/database/app_database.dart';
import 'package:gel_rule_app/core/database/database_service.dart';
import 'package:gel_rule_app/core/utils/result.dart';
+import 'package:isar/isar.dart';
void main() {
late Directory directory;
@@ -24,6 +26,14 @@ void main() {
late File copiedIsarDll;
setUpAll(() async {
+ if (Platform.isLinux) {
+ await Isar.initializeIsarCore(
+ libraries: {
+ Abi.linuxX64:
+ '${Directory.current.path}/third_party/isar_flutter_libs/linux/libisar.so',
+ },
+ );
+ }
copiedIsarDll =
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart
index 6dd16fc..f75d7bb 100644
--- a/test/shared/tag_input_search_bar_test.dart
+++ b/test/shared/tag_input_search_bar_test.dart
@@ -57,6 +57,48 @@ void main() {
expect(changed, 'video');
});
+ testWidgets('clear button removes all committed tags', (tester) async {
+ String? changed;
+ await tester.pumpWidget(_Harness(
+ child: TagInputSearchBar(
+ initialValue: 'touhou video',
+ onSubmitted: (_) {},
+ onChanged: (value) {
+ changed = value;
+ },
+ ),
+ ));
+
+ await tester.tap(find.byTooltip('Clear'));
+ await tester.pump();
+
+ expect(find.text('touhou'), findsNothing);
+ expect(find.text('video'), findsNothing);
+ expect(changed, '');
+ });
+
+ testWidgets('external initial value replaces committed tags', (tester) async {
+ await tester.pumpWidget(_Harness(
+ child: TagInputSearchBar(
+ initialValue: 'old_tag',
+ onSubmitted: (_) {},
+ ),
+ ));
+
+ expect(find.text('old_tag'), findsOneWidget);
+
+ await tester.pumpWidget(_Harness(
+ child: TagInputSearchBar(
+ initialValue: 'new_tag',
+ onSubmitted: (_) {},
+ ),
+ ));
+ await tester.pump();
+
+ expect(find.text('old_tag'), findsNothing);
+ expect(find.text('new_tag'), findsOneWidget);
+ });
+
testWidgets('suggestion appends active tag as a chip', (tester) async {
String? applied;
await tester.pumpWidget(_Harness(
diff --git a/windows/.gitignore b/windows/.gitignore
index d492d0d..ec4098a 100644
--- a/windows/.gitignore
+++ b/windows/.gitignore
@@ -1,17 +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/
+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
index 3351b18..efb3540 100644
--- a/windows/CMakeLists.txt
+++ b/windows/CMakeLists.txt
@@ -1,109 +1,109 @@
-# Project-level configuration.
-cmake_minimum_required(VERSION 3.14)
-project(gel_rule_app 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 "gel_rule_app")
-
-# 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}")
-
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.14)
+project(gel_rule_app 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 "gel_rule_app")
+
+# 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)
add_definitions(-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
-
-# 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)
+
+# 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 OR CMAKE_INSTALL_PREFIX MATCHES "Program Files")
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)
+
+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
index 903f489..efb62eb 100644
--- a/windows/flutter/CMakeLists.txt
+++ b/windows/flutter/CMakeLists.txt
@@ -1,109 +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}
-)
+# 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/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt
index 394917c..2041a04 100644
--- a/windows/runner/CMakeLists.txt
+++ b/windows/runner/CMakeLists.txt
@@ -1,40 +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)
+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
index d1062c9..cb7dab0 100644
--- a/windows/runner/Runner.rc
+++ b/windows/runner/Runner.rc
@@ -1,121 +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", "com.example" "\0"
+// 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", "com.example" "\0"
VALUE "FileDescription", "Lunaris" "\0"
- VALUE "FileVersion", VERSION_AS_STRING "\0"
+ VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "Lunaris" "\0"
- VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
- VALUE "OriginalFilename", "gel_rule_app.exe" "\0"
+ VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
+ VALUE "OriginalFilename", "gel_rule_app.exe" "\0"
VALUE "ProductName", "Lunaris" "\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
+ 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
index 955ee30..c819cb0 100644
--- a/windows/runner/flutter_window.cpp
+++ b/windows/runner/flutter_window.cpp
@@ -1,71 +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);
-}
+#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
index 6da0652..28c2383 100644
--- a/windows/runner/flutter_window.h
+++ b/windows/runner/flutter_window.h
@@ -1,33 +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_
+#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
index 1d7c3dc..438bc9b 100644
--- a/windows/runner/main.cpp
+++ b/windows/runner/main.cpp
@@ -1,43 +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);
+#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"Lunaris", origin, size)) {
- return EXIT_FAILURE;
- }
- window.SetQuitOnClose(true);
-
- ::MSG msg;
- while (::GetMessage(&msg, nullptr, 0, 0)) {
- ::TranslateMessage(&msg);
- ::DispatchMessage(&msg);
- }
-
- ::CoUninitialize();
- return EXIT_SUCCESS;
-}
+ 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
index 66a65d1..ddc7f3e 100644
--- a/windows/runner/resource.h
+++ b/windows/runner/resource.h
@@ -1,16 +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
+//{{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/runner.exe.manifest b/windows/runner/runner.exe.manifest
index 153653e..4b962bb 100644
--- a/windows/runner/runner.exe.manifest
+++ b/windows/runner/runner.exe.manifest
@@ -1,14 +1,14 @@
-
-
-
-
- PerMonitorV2
-
-
-
-
-
-
-
-
-
+
+
+
+
+ PerMonitorV2
+
+
+
+
+
+
+
+
+
diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp
index 3a0b465..259d85b 100644
--- a/windows/runner/utils.cpp
+++ b/windows/runner/utils.cpp
@@ -1,65 +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;
-}
+#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
index 3879d54..3f0e05c 100644
--- a/windows/runner/utils.h
+++ b/windows/runner/utils.h
@@ -1,19 +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_
+#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
index 60608d0..b5ba2a0 100644
--- a/windows/runner/win32_window.cpp
+++ b/windows/runner/win32_window.cpp
@@ -1,288 +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));
- }
-}
+#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
index e901dde..49b847f 100644
--- a/windows/runner/win32_window.h
+++ b/windows/runner/win32_window.h
@@ -1,102 +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_
+#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_