commit 65f1175ba7e8bda1caf3d7d0e9b5efd86c57f04d Author: Herwig Birke Date: Thu Oct 30 10:29:33 2025 +0100 chore: init repository with project sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13c57fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# General +.DS_Store +*.log +*.orig + +# IDE +.vscode/ +.idea/ +*.iml + +# Dart / Pub +.dart_tool/ +.packages +.pub-cache/ +.flutter-plugins +.flutter-plugins-dependencies + +# Build / Coverage +build/ +coverage/ + +# Android +android/.gradle/ +android/local.properties +android/app/release/ +android/app/profile/ +android/app/debug/ + +# iOS +ios/Pods/ +ios/.symlinks/ +ios/Flutter/ephemeral/ +ios/Flutter/Flutter.framework +ios/Flutter/App.framework +ios/Flutter/Flutter.podspec +ios/Flutter/Generated.xcconfig +ios/ServiceDefinitions.json +ios/Runner/GeneratedPluginRegistrant.* +ios/Runner.xcodeproj/project.xcworkspace/xcuserdata/ +ios/Runner.xcworkspace/xcuserdata/ +DerivedData/ + +# Web +web/build/ + +# Desktop +windows/runner/Debug/ +windows/runner/Release/ +macos/Flutter/ephemeral/ +linux/flutter/ephemeral/ + diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..b93b3bc --- /dev/null +++ b/.metadata @@ -0,0 +1,36 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: android + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: ios + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: web + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..be1904d --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# multimediaFlutter + +A new Flutter project created with FlutLab - https://flutlab.io + +## Getting Started + +A few resources to get you started if this is your first Flutter project: +- https://flutter.dev/docs/get-started/codelab +- https://flutter.dev/docs/cookbook + +For help getting started with Flutter, view https://flutter.dev/docs which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Getting Started: FlutLab - Flutter Online IDE +- How to use FlutLab? Please, view https://flutlab.io/docs +- Join the discussion and conversation on https://flutlab.io/residents + +## Upload to ChatGPT Project (ohne Git) +1. Optional: `flutter clean` im Repo ausführen. +2. Paket bauen: `pwsh tool/export.ps1` (oder `pwsh tool/export.ps1 -Clean`). +3. In ChatGPT Project "Multimedia in Flutter/Dart" → Add files → die erzeugte ZIP aus `dist/` hochladen. +4. Bei Änderungen Schritt 2 wiederholen und erneut hochladen. + +Ausgeschlossene Verzeichnisse beim Export: `build/`, `.dart_tool/`, `ios/Pods/`, `android/build/`, `.idea/`, `.vscode/`, `web/build/`, `android/.gradle`, `dist/`, `tool/`, `.git/`, `.github/`. + +## Windows Schnellstart +- Doppelklick: `tool/export.bat` erstellt ein ZIP unter `dist/`. +- Optional sauber bauen: `tool/export.bat -Clean` +- Danach im ChatGPT Project "Multimedia in Flutter/Dart" → Add files → ZIP hochladen. + +Hinweis +- Eine Flutter-`.gitignore` ist enthalten und kann später für dein eigenes Git-Repo verwendet werden. + +### Tipps bei "File content may not be accessible" +- Lade statt ZIP einen Ordner hoch: `pwsh tool/export.ps1 -AsFolder` und den erzeugten Ordner aus `dist/` per Drag & Drop ins Project ziehen. +- Minimalen Quellcode exportieren: `pwsh tool/export.ps1 -Minimal` (oder kombiniert: `-Minimal -AsFolder`). +- Große/binary Dateien (z. B. Bilder, .jar) können im Project nicht angezeigt werden – das ist normal. Für Code-Kontext reicht der Minimal-Export. + +### Upload-Limit (10 Dateien) +- Ein-Datei-Bundle: `pwsh tool/export.ps1 -Minimal -Bundle` (empfohlen für ChatGPT Anzeige). Upload nur der erzeugten `*-bundle.txt`. +- Alternativ, in 10er-Pakete splitten: `pwsh tool/export.ps1 -Minimal -AsFolder -Chunk 10` und die erzeugten `pack-***` Ordner nacheinander hochladen. +- Standard-Ordner: `-AsFolder` ohne `-Chunk` erzeugt einen kompletten Ordner (ggf. in mehreren Uploads á 10 Dateien hochladen). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..fe2a870 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.multimediaflutter" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.multimediaflutter" + // 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 new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aeacedd --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 0000000..6a365e4 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,29 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e); + } + } +} diff --git a/android/app/src/main/kotlin/com/example/multimediaflutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/multimediaflutter/MainActivity.kt new file mode 100644 index 0000000..6662b37 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/multimediaflutter/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.multimediaflutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/dist/multimediaFlutter-src-20251029-2041-bundle.txt b/dist/multimediaFlutter-src-20251029-2041-bundle.txt new file mode 100644 index 0000000..3e304c6 --- /dev/null +++ b/dist/multimediaFlutter-src-20251029-2041-bundle.txt @@ -0,0 +1,1715 @@ +===== BEGIN FILE: analysis_options.yaml ===== +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +\n===== END FILE =====\n +===== BEGIN FILE: pubspec.yaml ===== +name: multimediaFlutter +description: Verwaltung von Videos & Serien mit Riverpod, TMDB und PHP-Backend +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.1 + dio: ^5.7.0 + cached_network_image: ^3.3.1 + intl: ^0.19.0 + + # Material 3 ist im Flutter SDK enthalten + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true +\n===== END FILE =====\n +===== BEGIN FILE: README.md ===== +# multimediaFlutter + +A new Flutter project created with FlutLab - https://flutlab.io + +## Getting Started + +A few resources to get you started if this is your first Flutter project: +- https://flutter.dev/docs/get-started/codelab +- https://flutter.dev/docs/cookbook + +For help getting started with Flutter, view https://flutter.dev/docs which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Getting Started: FlutLab - Flutter Online IDE +- How to use FlutLab? Please, view https://flutlab.io/docs +- Join the discussion and conversation on https://flutlab.io/residents + +## Upload to ChatGPT Project (ohne Git) +1. Optional: `flutter clean` im Repo ausführen. +2. Paket bauen: `pwsh tool/export.ps1` (oder `pwsh tool/export.ps1 -Clean`). +3. In ChatGPT Project "Multimedia in Flutter/Dart" → Add files → die erzeugte ZIP aus `dist/` hochladen. +4. Bei Änderungen Schritt 2 wiederholen und erneut hochladen. + +Ausgeschlossene Verzeichnisse beim Export: `build/`, `.dart_tool/`, `ios/Pods/`, `android/build/`, `.idea/`, `.vscode/`, `web/build/`, `android/.gradle`, `dist/`, `tool/`, `.git/`, `.github/`. + +## Windows Schnellstart +- Doppelklick: `tool/export.bat` erstellt ein ZIP unter `dist/`. +- Optional sauber bauen: `tool/export.bat -Clean` +- Danach im ChatGPT Project "Multimedia in Flutter/Dart" → Add files → ZIP hochladen. + +Hinweis +- Eine Flutter-`.gitignore` ist enthalten und kann später für dein eigenes Git-Repo verwendet werden. + +### Tipps bei "File content may not be accessible" +- Lade statt ZIP einen Ordner hoch: `pwsh tool/export.ps1 -AsFolder` und den erzeugten Ordner aus `dist/` per Drag & Drop ins Project ziehen. +- Minimalen Quellcode exportieren: `pwsh tool/export.ps1 -Minimal` (oder kombiniert: `-Minimal -AsFolder`). +- Große/binary Dateien (z. B. Bilder, .jar) können im Project nicht angezeigt werden – das ist normal. Für Code-Kontext reicht der Minimal-Export. + +### Upload-Limit (10 Dateien) +- Ein-Datei-Bundle: `pwsh tool/export.ps1 -Minimal -Bundle` (empfohlen für ChatGPT Anzeige). Upload nur der erzeugten `*-bundle.txt`. +- Alternativ, in 10er-Pakete splitten: `pwsh tool/export.ps1 -Minimal -AsFolder -Chunk 10` und die erzeugten `pack-***` Ordner nacheinander hochladen. +- Standard-Ordner: `-AsFolder` ohne `-Chunk` erzeugt einen kompletten Ordner (ggf. in mehreren Uploads á 10 Dateien hochladen). + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradle.properties ===== +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradlew.bat ===== +@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 + +\n===== END FILE =====\n +===== BEGIN FILE: android\local.properties ===== +sdk.dir=C:\\Users\\herwi\\AppData\\Local\\Android\\sdk +flutter.sdk=C:\\dev\\flutter +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\debug\AndroidManifest.xml ===== + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\AndroidManifest.xml ===== + + + + + + + + + + + + + + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\kotlin\com\example\multimediaflutter\MainActivity.kt ===== +package com.example.multimediaflutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\drawable\launch_background.xml ===== + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\drawable-v21\launch_background.xml ===== + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\values\styles.xml ===== + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\values-night\styles.xml ===== + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\profile\AndroidManifest.xml ===== + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradle\wrapper\gradle-wrapper.properties ===== +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Flutter\AppFrameworkInfo.plist ===== + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Flutter\flutter_export_environment.sh ===== +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/home/ubuntu/flutter" +export "FLUTTER_APPLICATION_PATH=/home/ubuntu/projects/hello_world" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\AppDelegate.swift ===== +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Info.plist ===== + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hello World + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + multimediaflutter +CFBundleDisplayName +multimediaflutter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\AppIcon.appiconset\Contents.json ===== +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\LaunchImage.imageset\Contents.json ===== +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\LaunchImage.imageset\README.md ===== +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner.xcodeproj\project.xcworkspace\xcshareddata\IDEWorkspaceChecks.plist ===== + + + + + IDEDidComputeMac32BitWarning + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner.xcworkspace\xcshareddata\IDEWorkspaceChecks.plist ===== + + + + + IDEDidComputeMac32BitWarning + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\RunnerTests\RunnerTests.swift ===== +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\app.dart ===== +import 'package:flutter/material.dart'; +import 'features/movies/presentation/movie_list_screen.dart'; +import 'features/series/presentation/series_list_screen.dart'; +import 'features/import/import_screen.dart'; +import 'features/ping/ping_test_screen.dart'; + +class MultimediaApp extends StatelessWidget { + const MultimediaApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'multimediaFlutter', + theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blueGrey), + home: const _HomeTabs(), + debugShowCheckedModeBanner: false, + ); + } +} + +class _HomeTabs extends StatefulWidget { + const _HomeTabs(); + + @override + State<_HomeTabs> createState() => _HomeTabsState(); +} + +class _HomeTabsState extends State<_HomeTabs> + with SingleTickerProviderStateMixin { + late final TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('multimediaFlutter'), + bottom: TabBar( + controller: _controller, + tabs: const [ + Tab(text: 'Filme'), + Tab(text: 'Serien'), + Tab(text: 'Import'), + Tab(text: 'Ping'), + ], + ), + ), + body: TabBarView( + controller: _controller, + children: const [ + MovieListScreen(), + SeriesListScreen(), + ImportScreen(), + PingTestScreen(), + ], + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\main.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: MultimediaApp())); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\config.dart ===== +/// Globale Konfiguration. Standard: von `--dart-define` lesen. +/// Fallbacks sind hilfreich für lokale Tests. + +class AppConfig { + static const backendBaseUrl = String.fromEnvironment( + 'BACKEND_BASE_URL', + defaultValue: 'https://api.windesign.at/multimedia.php', + ); + + /// Token eines vorhandenen Users in deiner DB (users.api_token) + static const backendToken = String.fromEnvironment( + 'BACKEND_TOKEN', + defaultValue: 'dasistwiedereinverystrongtoken', + ); + + /// TMDB API Key (nur lesend). Für Public-Apps besser: Server-Proxy/Caching. + static const tmdbApiKey = String.fromEnvironment( + 'TMDB_API_KEY', + + defaultValue: 'a33271b9e54cdcb9a80680eaf5522f1b', + + ///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo' + ); +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\status.dart ===== +enum ItemStatus { Init, Progress, Done } + +ItemStatus statusFromString(String? s) { + return ItemStatus.values.firstWhere( + (e) => e.name == (s ?? 'Init'), + orElse: () => ItemStatus.Init, + ); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\api\backend_api.dart ===== +import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class BackendApi { + final Dio _dio; + + BackendApi() + : _dio = Dio( + BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.formUrlEncodedContentType, + validateStatus: (s) => s != null && s < 500, + ), + ); + + Future> _post(Map body) async { + final payload = { + 'api_token': AppConfig.backendToken, + ...body, + }; + Map? map; + Response res; + try { + res = await _dio.post('', data: payload); + } catch (e) { + // Basic diagnostics + // ignore: avoid_print + print('Backend request failed. URL: ${_dio.options.baseUrl}, payload: $payload, error: $e'); + throw Exception('Backend request failed: $e'); + } + if (res.data is Map) { + map = res.data as Map; + } + if (res.statusCode != 200) { + // ignore: avoid_print + print('Backend HTTP ${res.statusCode}. URL: ${_dio.options.baseUrl}, payload: $payload, data: ${res.data}'); + if (map != null && map.containsKey('error')) { + throw Exception('Backend HTTP ${res.statusCode}: ${map['error']}'); + } + throw Exception('Backend HTTP ${res.statusCode}: ${res.data}'); + } + if (map == null) { + throw Exception('Backend: Unexpected response format'); + } + if (map['ok'] != true) { + // ignore: avoid_print + print('Backend logical error. URL: ${_dio.options.baseUrl}, payload: $payload, data: $map'); + throw Exception('Backend responded with error: ${map['error']}'); + } + return map; + } + + Future>> getMovies( + {String? status, String? q, int offset = 0, int limit = 50}) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'movie', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future setStatus({ + required String type, // 'movie' | 'episode' + required int refId, + required String status, // 'Init' | 'Progress' | 'Done' + String? downloadPath, + }) async { + await _post({ + 'action': 'set_status', + 'type': type, + 'ref_id': refId, + 'status': status, + if (downloadPath != null) 'download_path': downloadPath, + }); + } + + Future upsertMovie(Map tmdbJson) async { + await _post({ + 'action': 'upsert_movie', + 'tmdb': tmdbJson, + }); + } + + Future>> getEpisodes({ + String? status, + String? q, + int offset = 0, + int limit = 5000, + }) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'episode', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future upsertShow(Map tmdbJson) async { + await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); + } + + Future upsertSeason(int showId, Map seasonJson) async { + final map = await _post( + {'action': 'upsert_season', 'show_id': showId, 'tmdb': seasonJson}); + return (map['id'] as num).toInt(); + } + + Future upsertEpisode( + int seasonId, Map episodeJson) async { + final map = await _post({ + 'action': 'upsert_episode', + 'season_id': seasonId, + 'tmdb': episodeJson + }); + return (map['id'] as num).toInt(); + } + + Future getShowDbIdByTmdbId(int tmdbId) async { + final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId}); + final v = map['id']; + return v == null ? null : (v as num).toInt(); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\api\tmdb_api.dart ===== +// lib/core/api/tmdb_api.dart +import 'package:dio/dio.dart'; +import '../config.dart'; + +class TmdbApi { + final Dio _dio; + + TmdbApi() + : _dio = Dio( + BaseOptions( + baseUrl: 'https://api.themoviedb.org/3', + headers: { + 'Accept': 'application/json', + }, + validateStatus: (code) => code != null && code < 500, + ), + ); + + Map get _auth => { + 'api_key': AppConfig.tmdbApiKey, + 'language': 'de-DE', + }; + + Future> getMovie(int id) async { + final res = await _dio.get( + '/movie/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getMovie($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getShow(int id) async { + final res = await _dio.get( + '/tv/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getShow($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getSeason(int showId, int seasonNumber) async { + final res = await _dio.get( + '/tv/$showId/season/$seasonNumber', + queryParameters: _auth, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getSeason($showId,S$seasonNumber) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\import\import_screen.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../core/api/tmdb_api.dart'; +import '../../core/api/backend_api.dart'; +import '../shared/providers.dart'; + +class ImportScreen extends ConsumerStatefulWidget { + const ImportScreen({super.key}); + + @override + ConsumerState createState() => _ImportScreenState(); +} + +class _ImportScreenState extends ConsumerState { + final _movieCtrl = + TextEditingController(text: '603, 27205'); // Matrix, Inception + final _showCtrl = + TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT + String _log = ''; + bool _busy = false; + + void _append(String msg) => setState(() => _log += msg + '\n'); + + Future _importMovies() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _movieCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + for (final id in ids) { + try { + _append('Film $id: TMDB laden …'); + final json = await tmdb.getMovie(id); + await backend.upsertMovie(json); + _append('Film $id: OK ✓'); + } catch (e) { + _append('Film $id: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _importShows() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _showCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + + for (final showId in ids) { + try { + _append('Serie $showId: TMDB laden …'); + final showJson = await tmdb.getShow(showId); + print('SHOW JSON: $showJson'); // Debug-Ausgabe + + await backend.upsertShow(showJson); + _append('Serie $showId: Show OK ✓'); + + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? 0) is int) + .cast>(); + + for (final s in seasons) { + final seasonNo = (s['season_number'] as num).toInt(); + if (seasonNo < 0) continue; + _append(' S$seasonNo: TMDB Season laden …'); + + final seasonJson = await tmdb.getSeason(showId, seasonNo); + final dbShowId = await _getDbShowIdByTmdb(backend, showId); + final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson); + + _append(' S$seasonNo: Season OK (db:$dbSeasonId)'); + + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(dbSeasonId, e); + } + _append(' S$seasonNo: ${eps.length} Episoden OK ✓'); + } + } catch (e) { + // 👇 Hier kommt der erweiterte Catch hin! + if (e is DioException) { + print('❗ TMDB DioException für $showId'); + print('➡️ Request: ${e.requestOptions.uri}'); + print('➡️ Response: ${e.response?.data}'); + print('➡️ Status: ${e.response?.statusCode}'); + } + _append('Serie $showId: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _getDbShowIdByTmdb(BackendApi backend, int tmdbId) async { + final id = await backend.getShowDbIdByTmdbId(tmdbId); + if (id == null) { + throw Exception( + 'Show mit tmdb_id=$tmdbId nicht gefunden – zuerst upsert_show aufrufen.'); + } + return id; + } + + @override + Widget build(BuildContext context) { + final inputStyle = const TextStyle(fontSize: 13); + + return Scaffold( + appBar: AppBar(title: const Text('Import (TMDB → DB)')), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + const Text('Filme TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: + TextField(controller: _movieCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importMovies, + child: const Text('Import Filme'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Serien TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: TextField(controller: _showCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importShows, + child: const Text('Import Serien'), + ), + ], + ), + const SizedBox(height: 12), + if (_busy) const LinearProgressIndicator(), + const SizedBox(height: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.black12), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: SelectableText(_log, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12)), + ), + ), + ), + ], + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\data\movie_model.dart ===== +import '../../../core/status.dart'; + +class Movie { + final int id; // DB-ID + final int tmdbId; + final String title; + final int? releaseYear; + final String? posterPath; + final ItemStatus status; + final String? downloadPath; + + Movie({ + required this.id, + required this.tmdbId, + required this.title, + this.releaseYear, + this.posterPath, + this.status = ItemStatus.Init, + this.downloadPath, + }); + + factory Movie.fromJson(Map j) => Movie( + id: j['id'] as int, + tmdbId: j['tmdb_id'] as int, + title: j['title'] as String, + releaseYear: j['release_year'] as int?, + posterPath: j['poster_path'] as String?, + status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init, + downloadPath: j['download_path'] as String?, + ); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\data\movie_repository.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../shared/providers.dart'; +import 'movie_model.dart'; + +final movieFilterProvider = StateProvider((_) => null); + +final moviesProvider = FutureProvider.autoDispose>((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(movieFilterProvider); + final list = await backend.getMovies(status: st?.name); + return list.map(Movie.fromJson).toList(); +}); +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\presentation\movie_list_screen.dart ===== +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../data/movie_repository.dart'; +import 'widgets/status_chip.dart'; + +class MovieListScreen extends ConsumerWidget { + const MovieListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(movieFilterProvider); + final moviesAsync = ref.watch(moviesProvider); + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: moviesAsync.when( + data: (items) => RefreshIndicator( + onRefresh: () async => ref.invalidate(moviesProvider), + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final m = items[i]; + return ListTile( + leading: m.posterPath != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: + 'https://image.tmdb.org/t/p/w154${m.posterPath}', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ) + : const SizedBox(width: 50, height: 75), + title: Text(m.title), + subtitle: Text([ + if (m.releaseYear != null) m.releaseYear.toString(), + 'Status: ${m.status.name}', + ].join(' · ')), + trailing: Icon(_statusIcon(m.status)), + onTap: () { + // TODO: Detailseite / Status ändern + }, + ); + }, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center( + child: Text('Fehler: ${e.toString()}'), + ), + ), + ), + ], + ), + ); + } + + IconData _statusIcon(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return Icons.hourglass_empty; + case ItemStatus.Progress: + return Icons.downloading; + case ItemStatus.Done: + return Icons.check_circle; + } + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\presentation\widgets\status_chip.dart ===== +import 'package:flutter/material.dart'; +import 'package:multimediaFlutter/core/status.dart'; + +class StatusChip extends StatelessWidget { + final ItemStatus? selected; + final ValueChanged onChanged; + + const StatusChip( + {super.key, required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('Alle'), + selected: selected == null, + onSelected: (_) => onChanged(null), + ), + for (final s in ItemStatus.values) + FilterChip( + label: Text(s.name), + selected: selected == s, + onSelected: (_) => onChanged(s), + ), + ], + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\ping\ping_test_screen.dart ===== + +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class PingTestScreen extends StatefulWidget { + const PingTestScreen({super.key}); + + @override + State createState() => _PingTestScreenState(); +} + +class _PingTestScreenState extends State { + String? _result; + bool _loading = false; + + Future _pingServer() async { + setState(() { + _loading = true; + _result = null; + }); + + try { + final res = await Dio(BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.formUrlEncodedContentType, + validateStatus: (s) => s != null && s < 500, + )).post('', data: {'api_token': AppConfig.backendToken, 'action': 'ping'}); + + setState(() { + _result = '✅ Antwort: ${res.data}'; + }); + } catch (e) { + setState(() { + _result = '❌ Fehler: $e'; + }); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ping-Test')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.wifi_tethering), + label: const Text('Ping-Server'), + onPressed: _loading ? null : _pingServer, + ), + const SizedBox(height: 20), + if (_loading) const CircularProgressIndicator(), + if (_result != null) + SelectableText( + _result!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\data\episode_model.dart ===== +import '../../../core/status.dart'; + +class EpisodeItem { + final int id; + final int episodeNumber; + final int seasonNumber; + final String showName; + final String? name; + final ItemStatus status; + + EpisodeItem({ + required this.id, + required this.episodeNumber, + required this.seasonNumber, + required this.showName, + this.name, + required this.status, + }); + + factory EpisodeItem.fromJson(Map j) => EpisodeItem( + id: j['id'] as int, + episodeNumber: j['episode_number'] as int, + seasonNumber: j['season_number'] as int, + showName: j['show_name'] as String, + name: j['name'] as String?, + status: statusFromString(j['status'] as String?), + ); +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\data\series_repository.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/providers.dart'; +import '../../../core/status.dart'; +import 'episode_model.dart'; + +final episodeFilterProvider = StateProvider((_) => null); + +final seriesGroupedProvider = + FutureProvider.autoDispose((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(episodeFilterProvider); + final rows = await backend.getEpisodes(status: st?.name, limit: 5000); + final items = rows.map(EpisodeItem.fromJson).toList(); + return SeriesGroupedData.fromEpisodes(items); +}); + +class SeriesGroupedData { + final Map>> + data; // showName -> season -> episodes + SeriesGroupedData(this.data); + + factory SeriesGroupedData.fromEpisodes(List items) { + final map = >>{}; + for (final e in items) { + final bySeason = + map.putIfAbsent(e.showName, () => >{}); + final list = bySeason.putIfAbsent(e.seasonNumber, () => []); + list.add(e); + } + for (final bySeason in map.values) { + for (final eps in bySeason.values) { + eps.sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber)); + } + } + return SeriesGroupedData(map); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\presentation\series_list_screen.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../movies/presentation/widgets/status_chip.dart'; +import '../data/series_repository.dart'; +import 'widgets/episode_status_strip.dart'; + +class SeriesListScreen extends ConsumerWidget { + const SeriesListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(episodeFilterProvider); + final groupedAsync = ref.watch(seriesGroupedProvider); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => + ref.read(episodeFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: groupedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (data) { + final shows = data.data.keys.toList()..sort(); + if (shows.isEmpty) { + return const Center( + child: Text('Keine Episoden gefunden.')); + } + return ListView.builder( + itemCount: shows.length, + itemBuilder: (context, i) { + final showName = shows[i]; + final seasons = data.data[showName]!; + final seasonNumbers = seasons.keys.toList()..sort(); + return ExpansionTile( + title: Text(showName, + style: + const TextStyle(fontWeight: FontWeight.w600)), + children: [ + for (final sNo in seasonNumbers) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 64, + child: Text('S$sNo', + style: + const TextStyle(fontFeatures: [])), + ), + Expanded( + child: EpisodeStatusStrip( + episodes: seasons[sNo]!, + barWidth: 8, + barHeight: 28, + spacing: 3, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\presentation\widgets\episode_status_strip.dart ===== +import 'package:flutter/material.dart'; +import '../../../../core/status.dart'; +import '../../data/episode_model.dart'; + +class EpisodeStatusStrip extends StatelessWidget { + final List episodes; + final double barWidth; // Breite eines Balkens + final double barHeight; // Höhe eines Balkens + final double spacing; // Abstand zwischen Balken + + const EpisodeStatusStrip({ + super.key, + required this.episodes, + this.barWidth = 8, + this.barHeight = 28, + this.spacing = 3, + }); + + Color _fill(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return const Color(0xFFBDBDBD); // Grau + case ItemStatus.Progress: + return const Color(0xFF42A5F5); // Blau + case ItemStatus.Done: + return const Color(0xFF4CAF50); // Grün + } + } + + @override + Widget build(BuildContext context) { + if (episodes.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < episodes.length; i++) ...[ + Container( + width: barWidth, + height: barHeight, + decoration: BoxDecoration( + color: _fill(episodes[i].status), + border: Border.all(color: Colors.black, width: 1), // „I“-Kontur + ), + ), + if (i != episodes.length - 1) SizedBox(width: spacing), + ], + ], + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\shared\providers.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/backend_api.dart'; +import '../../core/api/tmdb_api.dart'; + +final backendApiProvider = Provider((ref) => BackendApi()); +final tmdbApiProvider = Provider((ref) => TmdbApi()); +\n===== END FILE =====\n +===== BEGIN FILE: web\index.html ===== + + + + + + + + + + + + + + + + + + + + hello_world + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: web\manifest.json ===== +{ + "name": "hello_world", + "short_name": "hello_world", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} + +\n===== END FILE =====\n diff --git a/dist/multimediaFlutter-src-20251029-2043-bundle.txt b/dist/multimediaFlutter-src-20251029-2043-bundle.txt new file mode 100644 index 0000000..da44bf6 --- /dev/null +++ b/dist/multimediaFlutter-src-20251029-2043-bundle.txt @@ -0,0 +1,1896 @@ +===== BEGIN FILE: analysis_options.yaml ===== +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +\n===== END FILE =====\n +===== BEGIN FILE: pubspec.yaml ===== +name: multimediaFlutter +description: Verwaltung von Videos & Serien mit Riverpod, TMDB und PHP-Backend +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.1 + dio: ^5.7.0 + cached_network_image: ^3.3.1 + intl: ^0.19.0 + + # Material 3 ist im Flutter SDK enthalten + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true +\n===== END FILE =====\n +===== BEGIN FILE: README.md ===== +# multimediaFlutter + +A new Flutter project created with FlutLab - https://flutlab.io + +## Getting Started + +A few resources to get you started if this is your first Flutter project: +- https://flutter.dev/docs/get-started/codelab +- https://flutter.dev/docs/cookbook + +For help getting started with Flutter, view https://flutter.dev/docs which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Getting Started: FlutLab - Flutter Online IDE +- How to use FlutLab? Please, view https://flutlab.io/docs +- Join the discussion and conversation on https://flutlab.io/residents + +## Upload to ChatGPT Project (ohne Git) +1. Optional: `flutter clean` im Repo ausführen. +2. Paket bauen: `pwsh tool/export.ps1` (oder `pwsh tool/export.ps1 -Clean`). +3. In ChatGPT Project "Multimedia in Flutter/Dart" → Add files → die erzeugte ZIP aus `dist/` hochladen. +4. Bei Änderungen Schritt 2 wiederholen und erneut hochladen. + +Ausgeschlossene Verzeichnisse beim Export: `build/`, `.dart_tool/`, `ios/Pods/`, `android/build/`, `.idea/`, `.vscode/`, `web/build/`, `android/.gradle`, `dist/`, `tool/`, `.git/`, `.github/`. + +## Windows Schnellstart +- Doppelklick: `tool/export.bat` erstellt ein ZIP unter `dist/`. +- Optional sauber bauen: `tool/export.bat -Clean` +- Danach im ChatGPT Project "Multimedia in Flutter/Dart" → Add files → ZIP hochladen. + +Hinweis +- Eine Flutter-`.gitignore` ist enthalten und kann später für dein eigenes Git-Repo verwendet werden. + +### Tipps bei "File content may not be accessible" +- Lade statt ZIP einen Ordner hoch: `pwsh tool/export.ps1 -AsFolder` und den erzeugten Ordner aus `dist/` per Drag & Drop ins Project ziehen. +- Minimalen Quellcode exportieren: `pwsh tool/export.ps1 -Minimal` (oder kombiniert: `-Minimal -AsFolder`). +- Große/binary Dateien (z. B. Bilder, .jar) können im Project nicht angezeigt werden – das ist normal. Für Code-Kontext reicht der Minimal-Export. + +### Upload-Limit (10 Dateien) +- Ein-Datei-Bundle: `pwsh tool/export.ps1 -Minimal -Bundle` (empfohlen für ChatGPT Anzeige). Upload nur der erzeugten `*-bundle.txt`. +- Alternativ, in 10er-Pakete splitten: `pwsh tool/export.ps1 -Minimal -AsFolder -Chunk 10` und die erzeugten `pack-***` Ordner nacheinander hochladen. +- Standard-Ordner: `-AsFolder` ohne `-Chunk` erzeugt einen kompletten Ordner (ggf. in mehreren Uploads á 10 Dateien hochladen). + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradle.properties ===== +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradlew.bat ===== +@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 + +\n===== END FILE =====\n +===== BEGIN FILE: android\local.properties ===== +sdk.dir=C:\\Users\\herwi\\AppData\\Local\\Android\\sdk +flutter.sdk=C:\\dev\\flutter +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\debug\AndroidManifest.xml ===== + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\AndroidManifest.xml ===== + + + + + + + + + + + + + + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\kotlin\com\example\multimediaflutter\MainActivity.kt ===== +package com.example.multimediaflutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\drawable\launch_background.xml ===== + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\drawable-v21\launch_background.xml ===== + + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\values\styles.xml ===== + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\main\res\values-night\styles.xml ===== + + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\app\src\profile\AndroidManifest.xml ===== + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: android\gradle\wrapper\gradle-wrapper.properties ===== +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Flutter\AppFrameworkInfo.plist ===== + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Flutter\flutter_export_environment.sh ===== +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/home/ubuntu/flutter" +export "FLUTTER_APPLICATION_PATH=/home/ubuntu/projects/hello_world" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\AppDelegate.swift ===== +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Info.plist ===== + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hello World + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + multimediaflutter +CFBundleDisplayName +multimediaflutter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\AppIcon.appiconset\Contents.json ===== +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\LaunchImage.imageset\Contents.json ===== +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner\Assets.xcassets\LaunchImage.imageset\README.md ===== +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner.xcodeproj\project.xcworkspace\xcshareddata\IDEWorkspaceChecks.plist ===== + + + + + IDEDidComputeMac32BitWarning + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\Runner.xcworkspace\xcshareddata\IDEWorkspaceChecks.plist ===== + + + + + IDEDidComputeMac32BitWarning + + + + +\n===== END FILE =====\n +===== BEGIN FILE: ios\RunnerTests\RunnerTests.swift ===== +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\app.dart ===== +import 'package:flutter/material.dart'; +import 'features/movies/presentation/movie_list_screen.dart'; +import 'features/series/presentation/series_list_screen.dart'; +import 'features/import/import_screen.dart'; +import 'features/ping/ping_test_screen.dart'; + +class MultimediaApp extends StatelessWidget { + const MultimediaApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'multimediaFlutter', + theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blueGrey), + home: const _HomeTabs(), + debugShowCheckedModeBanner: false, + ); + } +} + +class _HomeTabs extends StatefulWidget { + const _HomeTabs(); + + @override + State<_HomeTabs> createState() => _HomeTabsState(); +} + +class _HomeTabsState extends State<_HomeTabs> + with SingleTickerProviderStateMixin { + late final TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('multimediaFlutter'), + bottom: TabBar( + controller: _controller, + tabs: const [ + Tab(text: 'Filme'), + Tab(text: 'Serien'), + Tab(text: 'Import'), + Tab(text: 'Ping'), + ], + ), + ), + body: TabBarView( + controller: _controller, + children: const [ + MovieListScreen(), + SeriesListScreen(), + ImportScreen(), + PingTestScreen(), + ], + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\main.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: MultimediaApp())); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\config.dart ===== +/// Globale Konfiguration. Standard: von `--dart-define` lesen. +/// Fallbacks sind hilfreich für lokale Tests. + +class AppConfig { + static const backendBaseUrl = String.fromEnvironment( + 'BACKEND_BASE_URL', + defaultValue: 'https://api.windesign.at/multimedia.php', + ); + + /// Token eines vorhandenen Users in deiner DB (users.api_token) + static const backendToken = String.fromEnvironment( + 'BACKEND_TOKEN', + defaultValue: 'dasistwiedereinverystrongtoken', + ); + + /// TMDB API Key (nur lesend). Für Public-Apps besser: Server-Proxy/Caching. + static const tmdbApiKey = String.fromEnvironment( + 'TMDB_API_KEY', + + defaultValue: 'a33271b9e54cdcb9a80680eaf5522f1b', + + ///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo' + ); +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\status.dart ===== +enum ItemStatus { Init, Progress, Done } + +ItemStatus statusFromString(String? s) { + return ItemStatus.values.firstWhere( + (e) => e.name == (s ?? 'Init'), + orElse: () => ItemStatus.Init, + ); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\api\backend_api.dart ===== +import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class BackendApi { + final Dio _dio; + + BackendApi() + : _dio = Dio( + BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.formUrlEncodedContentType, + validateStatus: (s) => s != null && s < 500, + ), + ); + + Future> _post(Map body) async { + final payload = { + 'api_token': AppConfig.backendToken, + ...body, + }; + Map? map; + Response res; + try { + res = await _dio.post('', data: payload); + } catch (e) { + // Basic diagnostics + // ignore: avoid_print + print('Backend request failed. URL: ${_dio.options.baseUrl}, payload: $payload, error: $e'); + throw Exception('Backend request failed: $e'); + } + if (res.data is Map) { + map = res.data as Map; + } + if (res.statusCode != 200) { + // ignore: avoid_print + print('Backend HTTP ${res.statusCode}. URL: ${_dio.options.baseUrl}, payload: $payload, data: ${res.data}'); + if (map != null && map.containsKey('error')) { + throw Exception('Backend HTTP ${res.statusCode}: ${map['error']}'); + } + throw Exception('Backend HTTP ${res.statusCode}: ${res.data}'); + } + if (map == null) { + throw Exception('Backend: Unexpected response format'); + } + if (map['ok'] != true) { + // ignore: avoid_print + print('Backend logical error. URL: ${_dio.options.baseUrl}, payload: $payload, data: $map'); + throw Exception('Backend responded with error: ${map['error']}'); + } + return map; + } + + Future>> getMovies( + {String? status, String? q, int offset = 0, int limit = 50}) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'movie', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future setStatus({ + required String type, // 'movie' | 'episode' + required int refId, + required String status, // 'Init' | 'Progress' | 'Done' + String? downloadPath, + }) async { + await _post({ + 'action': 'set_status', + 'type': type, + 'ref_id': refId, + 'status': status, + if (downloadPath != null) 'download_path': downloadPath, + }); + } + + Future upsertMovie(Map tmdbJson) async { + await _post({ + 'action': 'upsert_movie', + 'tmdb': tmdbJson, + }); + } + + Future>> getEpisodes({ + String? status, + String? q, + int offset = 0, + int limit = 5000, + }) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'episode', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future upsertShow(Map tmdbJson) async { + await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); + } + + Future upsertSeason(int showId, Map seasonJson) async { + final map = await _post( + {'action': 'upsert_season', 'show_id': showId, 'tmdb': seasonJson}); + return (map['id'] as num).toInt(); + } + + Future upsertEpisode( + int seasonId, Map episodeJson) async { + final map = await _post({ + 'action': 'upsert_episode', + 'season_id': seasonId, + 'tmdb': episodeJson + }); + return (map['id'] as num).toInt(); + } + + Future getShowDbIdByTmdbId(int tmdbId) async { + final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId}); + final v = map['id']; + return v == null ? null : (v as num).toInt(); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\core\api\tmdb_api.dart ===== +// lib/core/api/tmdb_api.dart +import 'package:dio/dio.dart'; +import '../config.dart'; + +class TmdbApi { + final Dio _dio; + + TmdbApi() + : _dio = Dio( + BaseOptions( + baseUrl: 'https://api.themoviedb.org/3', + headers: { + 'Accept': 'application/json', + }, + validateStatus: (code) => code != null && code < 500, + ), + ); + + Map get _auth => { + 'api_key': AppConfig.tmdbApiKey, + 'language': 'de-DE', + }; + + Future> getMovie(int id) async { + final res = await _dio.get( + '/movie/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getMovie($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getShow(int id) async { + final res = await _dio.get( + '/tv/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getShow($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getSeason(int showId, int seasonNumber) async { + final res = await _dio.get( + '/tv/$showId/season/$seasonNumber', + queryParameters: _auth, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getSeason($showId,S$seasonNumber) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\import\import_screen.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../core/api/tmdb_api.dart'; +import '../../core/api/backend_api.dart'; +import '../shared/providers.dart'; + +class ImportScreen extends ConsumerStatefulWidget { + const ImportScreen({super.key}); + + @override + ConsumerState createState() => _ImportScreenState(); +} + +class _ImportScreenState extends ConsumerState { + final _movieCtrl = + TextEditingController(text: '603, 27205'); // Matrix, Inception + final _showCtrl = + TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT + String _log = ''; + bool _busy = false; + + void _append(String msg) => setState(() => _log += msg + '\n'); + + Future _importMovies() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _movieCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + for (final id in ids) { + try { + _append('Film $id: TMDB laden …'); + final json = await tmdb.getMovie(id); + await backend.upsertMovie(json); + _append('Film $id: OK ✓'); + } catch (e) { + _append('Film $id: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _importShows() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _showCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + + for (final showId in ids) { + try { + _append('Serie $showId: TMDB laden …'); + final showJson = await tmdb.getShow(showId); + print('SHOW JSON: $showJson'); // Debug-Ausgabe + + await backend.upsertShow(showJson); + _append('Serie $showId: Show OK ✓'); + + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? 0) is int) + .cast>(); + + for (final s in seasons) { + final seasonNo = (s['season_number'] as num).toInt(); + if (seasonNo < 0) continue; + _append(' S$seasonNo: TMDB Season laden …'); + + final seasonJson = await tmdb.getSeason(showId, seasonNo); + final dbShowId = await _getDbShowIdByTmdb(backend, showId); + final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson); + + _append(' S$seasonNo: Season OK (db:$dbSeasonId)'); + + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(dbSeasonId, e); + } + _append(' S$seasonNo: ${eps.length} Episoden OK ✓'); + } + } catch (e) { + // 👇 Hier kommt der erweiterte Catch hin! + if (e is DioException) { + print('❗ TMDB DioException für $showId'); + print('➡️ Request: ${e.requestOptions.uri}'); + print('➡️ Response: ${e.response?.data}'); + print('➡️ Status: ${e.response?.statusCode}'); + } + _append('Serie $showId: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _getDbShowIdByTmdb(BackendApi backend, int tmdbId) async { + final id = await backend.getShowDbIdByTmdbId(tmdbId); + if (id == null) { + throw Exception( + 'Show mit tmdb_id=$tmdbId nicht gefunden – zuerst upsert_show aufrufen.'); + } + return id; + } + + @override + Widget build(BuildContext context) { + final inputStyle = const TextStyle(fontSize: 13); + + return Scaffold( + appBar: AppBar(title: const Text('Import (TMDB → DB)')), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + const Text('Filme TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: + TextField(controller: _movieCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importMovies, + child: const Text('Import Filme'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Serien TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: TextField(controller: _showCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importShows, + child: const Text('Import Serien'), + ), + ], + ), + const SizedBox(height: 12), + if (_busy) const LinearProgressIndicator(), + const SizedBox(height: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.black12), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: SelectableText(_log, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12)), + ), + ), + ), + ], + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\data\movie_model.dart ===== +import '../../../core/status.dart'; + +class Movie { + final int id; // DB-ID + final int tmdbId; + final String title; + final int? releaseYear; + final String? posterPath; + final ItemStatus status; + final String? downloadPath; + + Movie({ + required this.id, + required this.tmdbId, + required this.title, + this.releaseYear, + this.posterPath, + this.status = ItemStatus.Init, + this.downloadPath, + }); + + factory Movie.fromJson(Map j) => Movie( + id: j['id'] as int, + tmdbId: j['tmdb_id'] as int, + title: j['title'] as String, + releaseYear: j['release_year'] as int?, + posterPath: j['poster_path'] as String?, + status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init, + downloadPath: j['download_path'] as String?, + ); +} +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\data\movie_repository.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../shared/providers.dart'; +import 'movie_model.dart'; + +final movieFilterProvider = StateProvider((_) => null); + +final moviesProvider = FutureProvider.autoDispose>((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(movieFilterProvider); + final list = await backend.getMovies(status: st?.name); + return list.map(Movie.fromJson).toList(); +}); +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\presentation\movie_list_screen.dart ===== +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../data/movie_repository.dart'; +import 'widgets/status_chip.dart'; + +class MovieListScreen extends ConsumerWidget { + const MovieListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(movieFilterProvider); + final moviesAsync = ref.watch(moviesProvider); + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: moviesAsync.when( + data: (items) => RefreshIndicator( + onRefresh: () async => ref.invalidate(moviesProvider), + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final m = items[i]; + return ListTile( + leading: m.posterPath != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: + 'https://image.tmdb.org/t/p/w154${m.posterPath}', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ) + : const SizedBox(width: 50, height: 75), + title: Text(m.title), + subtitle: Text([ + if (m.releaseYear != null) m.releaseYear.toString(), + 'Status: ${m.status.name}', + ].join(' · ')), + trailing: Icon(_statusIcon(m.status)), + onTap: () { + // TODO: Detailseite / Status ändern + }, + ); + }, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center( + child: Text('Fehler: ${e.toString()}'), + ), + ), + ), + ], + ), + ); + } + + IconData _statusIcon(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return Icons.hourglass_empty; + case ItemStatus.Progress: + return Icons.downloading; + case ItemStatus.Done: + return Icons.check_circle; + } + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\movies\presentation\widgets\status_chip.dart ===== +import 'package:flutter/material.dart'; +import 'package:multimediaFlutter/core/status.dart'; + +class StatusChip extends StatelessWidget { + final ItemStatus? selected; + final ValueChanged onChanged; + + const StatusChip( + {super.key, required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('Alle'), + selected: selected == null, + onSelected: (_) => onChanged(null), + ), + for (final s in ItemStatus.values) + FilterChip( + label: Text(s.name), + selected: selected == s, + onSelected: (_) => onChanged(s), + ), + ], + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\ping\ping_test_screen.dart ===== + +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class PingTestScreen extends StatefulWidget { + const PingTestScreen({super.key}); + + @override + State createState() => _PingTestScreenState(); +} + +class _PingTestScreenState extends State { + String? _result; + bool _loading = false; + + Future _pingServer() async { + setState(() { + _loading = true; + _result = null; + }); + + try { + final res = await Dio(BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.formUrlEncodedContentType, + validateStatus: (s) => s != null && s < 500, + )).post('', data: {'api_token': AppConfig.backendToken, 'action': 'ping'}); + + setState(() { + _result = '✅ Antwort: ${res.data}'; + }); + } catch (e) { + setState(() { + _result = '❌ Fehler: $e'; + }); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ping-Test')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.wifi_tethering), + label: const Text('Ping-Server'), + onPressed: _loading ? null : _pingServer, + ), + const SizedBox(height: 20), + if (_loading) const CircularProgressIndicator(), + if (_result != null) + SelectableText( + _result!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\data\episode_model.dart ===== +import '../../../core/status.dart'; + +class EpisodeItem { + final int id; + final int episodeNumber; + final int seasonNumber; + final String showName; + final String? name; + final ItemStatus status; + + EpisodeItem({ + required this.id, + required this.episodeNumber, + required this.seasonNumber, + required this.showName, + this.name, + required this.status, + }); + + factory EpisodeItem.fromJson(Map j) => EpisodeItem( + id: j['id'] as int, + episodeNumber: j['episode_number'] as int, + seasonNumber: j['season_number'] as int, + showName: j['show_name'] as String, + name: j['name'] as String?, + status: statusFromString(j['status'] as String?), + ); +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\data\series_repository.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/providers.dart'; +import '../../../core/status.dart'; +import 'episode_model.dart'; + +final episodeFilterProvider = StateProvider((_) => null); + +final seriesGroupedProvider = + FutureProvider.autoDispose((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(episodeFilterProvider); + final rows = await backend.getEpisodes(status: st?.name, limit: 5000); + final items = rows.map(EpisodeItem.fromJson).toList(); + return SeriesGroupedData.fromEpisodes(items); +}); + +class SeriesGroupedData { + final Map>> + data; // showName -> season -> episodes + SeriesGroupedData(this.data); + + factory SeriesGroupedData.fromEpisodes(List items) { + final map = >>{}; + for (final e in items) { + final bySeason = + map.putIfAbsent(e.showName, () => >{}); + final list = bySeason.putIfAbsent(e.seasonNumber, () => []); + list.add(e); + } + for (final bySeason in map.values) { + for (final eps in bySeason.values) { + eps.sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber)); + } + } + return SeriesGroupedData(map); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\presentation\series_list_screen.dart ===== +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../movies/presentation/widgets/status_chip.dart'; +import '../data/series_repository.dart'; +import 'widgets/episode_status_strip.dart'; + +class SeriesListScreen extends ConsumerWidget { + const SeriesListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(episodeFilterProvider); + final groupedAsync = ref.watch(seriesGroupedProvider); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => + ref.read(episodeFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: groupedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (data) { + final shows = data.data.keys.toList()..sort(); + if (shows.isEmpty) { + return const Center( + child: Text('Keine Episoden gefunden.')); + } + return ListView.builder( + itemCount: shows.length, + itemBuilder: (context, i) { + final showName = shows[i]; + final seasons = data.data[showName]!; + final seasonNumbers = seasons.keys.toList()..sort(); + return ExpansionTile( + title: Text(showName, + style: + const TextStyle(fontWeight: FontWeight.w600)), + children: [ + for (final sNo in seasonNumbers) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 64, + child: Text('S$sNo', + style: + const TextStyle(fontFeatures: [])), + ), + Expanded( + child: EpisodeStatusStrip( + episodes: seasons[sNo]!, + barWidth: 8, + barHeight: 28, + spacing: 3, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\series\presentation\widgets\episode_status_strip.dart ===== +import 'package:flutter/material.dart'; +import '../../../../core/status.dart'; +import '../../data/episode_model.dart'; + +class EpisodeStatusStrip extends StatelessWidget { + final List episodes; + final double barWidth; // Breite eines Balkens + final double barHeight; // Höhe eines Balkens + final double spacing; // Abstand zwischen Balken + + const EpisodeStatusStrip({ + super.key, + required this.episodes, + this.barWidth = 8, + this.barHeight = 28, + this.spacing = 3, + }); + + Color _fill(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return const Color(0xFFBDBDBD); // Grau + case ItemStatus.Progress: + return const Color(0xFF42A5F5); // Blau + case ItemStatus.Done: + return const Color(0xFF4CAF50); // Grün + } + } + + @override + Widget build(BuildContext context) { + if (episodes.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < episodes.length; i++) ...[ + Container( + width: barWidth, + height: barHeight, + decoration: BoxDecoration( + color: _fill(episodes[i].status), + border: Border.all(color: Colors.black, width: 1), // „I“-Kontur + ), + ), + if (i != episodes.length - 1) SizedBox(width: spacing), + ], + ], + ), + ); + } +} + +\n===== END FILE =====\n +===== BEGIN FILE: lib\features\shared\providers.dart ===== +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/backend_api.dart'; +import '../../core/api/tmdb_api.dart'; + +final backendApiProvider = Provider((ref) => BackendApi()); +final tmdbApiProvider = Provider((ref) => TmdbApi()); +\n===== END FILE =====\n +===== BEGIN FILE: lib\php\multimedia-settings.php ===== + + +\n===== END FILE =====\n +===== BEGIN FILE: lib\php\multimedia.php ===== +false,'error'=>$msg], $code); } + +$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DATABASE_HOST, DATABASE_NAME); +try { + $pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (Throwable $e) { + if (MM_DEBUG) fail('DB connection failed: '.$e->getMessage(), 500); + fail('DB connection failed', 500); +} + +$in = jsonBody(); +$action = $in['action'] ?? null; + +try { + switch ($action) { + case 'upsert_movie': { + $tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload'); + $stmt = $pdo->prepare("INSERT INTO movies (tmdb_id, title, original_title, release_year, poster_path, backdrop_path, runtime, json) + VALUES (?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE title=VALUES(title), original_title=VALUES(original_title), release_year=VALUES(release_year), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), runtime=VALUES(runtime), json=VALUES(json), updated_at=NOW()"); + $stmt->execute([ + $tmdb['id'], + $tmdb['title'] ?? $tmdb['name'] ?? '', + $tmdb['original_title'] ?? $tmdb['original_name'] ?? null, + isset($tmdb['release_date']) ? intval(substr($tmdb['release_date'],0,4)) : null, + $tmdb['poster_path'] ?? null, + $tmdb['backdrop_path'] ?? null, + $tmdb['runtime'] ?? null, + json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + ]); + $id = $pdo->lastInsertId(); + if (!$id) { $q=$pdo->prepare('SELECT id FROM movies WHERE tmdb_id=?'); $q->execute([$tmdb['id']]); $id=$q->fetchColumn(); } + resp(['ok'=>true,'id'=>(int)$id]); + } + + case 'set_status': { + $type=$in['type'] ?? null; $ref=(int)($in['ref_id'] ?? 0); $st=$in['status'] ?? null; $path=$in['download_path'] ?? null; + if (!in_array($type,['movie','episode'],true) || !$ref || !in_array($st,['Init','Progress','Done'],true)) fail('bad params'); + $stmt=$pdo->prepare("INSERT INTO item_status (type, ref_id, status, download_path) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE status=VALUES(status), download_path=VALUES(download_path), updated_at=NOW()"); + $stmt->execute([$type,$ref,$st,$path]); + resp(['ok'=>true]); + } + + case 'get_list': { + $type=$in['type'] ?? 'movie'; + $status=$in['status'] ?? null; + $q=$in['q'] ?? ''; + $limit=max(1,min(200,(int)($in['limit']??50))); + $offset=max(0,(int)($in['offset']??0)); + + if ($type === 'episode') { + $sql = "SELECT e.*, s.status, s.download_path, se.season_number, sh.name AS show_name + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN shows sh ON sh.id = se.show_id + LEFT JOIN item_status s ON s.type='episode' AND s.ref_id=e.id + WHERE 1=1"; + $params = []; + if ($status) { $sql .= " AND s.status = ?"; $params[] = $status; } + if ($q) { $sql .= " AND e.name LIKE ?"; $params[] = "%$q%"; } + $sql .= " ORDER BY sh.name ASC, se.season_number ASC, e.episode_number ASC + LIMIT ?, ?"; + $params[] = $offset; $params[] = $limit; + + $stmt = $pdo->prepare($sql); + $i=1; foreach ($params as $p) { $stmt->bindValue($i++, $p, is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR); } + $stmt->execute(); + resp(['ok'=>true,'items'=>$stmt->fetchAll()]); + } + + if ($type==='movie') { + $sql="SELECT m.*, s.status, s.download_path FROM movies m LEFT JOIN item_status s ON s.type='movie' AND s.ref_id=m.id WHERE 1=1"; + $params=[]; if($status){$sql.=" AND s.status=?"; $params[]=$status;} if($q){$sql.=" AND m.title LIKE ?"; $params[]='%'.$q.'%';} + $sql.=" ORDER BY m.updated_at DESC LIMIT ?,?"; $params[]=$offset; $params[]=$limit; + $stmt=$pdo->prepare($sql); $i=1; foreach($params as $p){$stmt->bindValue($i++,$p,is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR);} $stmt->execute(); + resp(['ok'=>true,'items'=>$stmt->fetchAll()]); + } + fail('unsupported type'); + } + + case 'get_show_by_tmdb': { + $tmdbId = (int)($in['tmdb_id'] ?? 0); + if (!$tmdbId) fail('bad params'); + $stmt = $pdo->prepare('SELECT id FROM shows WHERE tmdb_id = ?'); + $stmt->execute([$tmdbId]); + $id = $stmt->fetchColumn(); + resp(['ok' => true, 'id' => $id ? (int)$id : null]); + } + + case 'ping': { + resp([ + 'ok' => true, + 'time' => date('c'), + 'origin' => $origin, + 'php' => PHP_VERSION, + ]); + } + + default: fail('unknown action'); + } +} catch (Throwable $e) { + if (MM_DEBUG) resp(['ok'=>false,'error'=>$e->getMessage()], 500); + resp(['ok'=>false,'error'=>'server error'], 500); +} + +\n===== END FILE =====\n +===== BEGIN FILE: web\index.html ===== + + + + + + + + + + + + + + + + + + + + hello_world + + + + + + + +\n===== END FILE =====\n +===== BEGIN FILE: web\manifest.json ===== +{ + "name": "hello_world", + "short_name": "hello_world", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} + +\n===== END FILE =====\n diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/flutter_export_environment.sh b/ios/Flutter/flutter_export_environment.sh new file mode 100644 index 0000000..f276f84 --- /dev/null +++ b/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/home/ubuntu/flutter" +export "FLUTTER_APPLICATION_PATH=/home/ubuntu/projects/hello_world" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..71fa852 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloWorld; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..15cada4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..de93566 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hello World + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + multimediaflutter +CFBundleDisplayName +multimediaflutter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..1ebe47e --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'features/movies/presentation/movie_list_screen.dart'; +import 'features/series/presentation/series_list_screen.dart'; +import 'features/import/import_screen.dart'; +import 'features/ping/ping_test_screen.dart'; + +class MultimediaApp extends StatelessWidget { + const MultimediaApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'multimediaFlutter', + theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blueGrey), + home: const _HomeTabs(), + debugShowCheckedModeBanner: false, + ); + } +} + +class _HomeTabs extends StatefulWidget { + const _HomeTabs(); + + @override + State<_HomeTabs> createState() => _HomeTabsState(); +} + +class _HomeTabsState extends State<_HomeTabs> + with SingleTickerProviderStateMixin { + late final TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('multimediaFlutter'), + bottom: TabBar( + controller: _controller, + tabs: const [ + Tab(text: 'Filme'), + Tab(text: 'Serien'), + Tab(text: 'Import'), + Tab(text: 'Ping'), + ], + ), + ), + body: TabBarView( + controller: _controller, + children: const [ + MovieListScreen(), + SeriesListScreen(), + ImportScreen(), + PingTestScreen(), + ], + ), + ); + } +} diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart new file mode 100644 index 0000000..5198b76 --- /dev/null +++ b/lib/core/api/backend_api.dart @@ -0,0 +1,182 @@ +import 'dart:convert';import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class BackendApi { + final Dio _dio; + + BackendApi() + : _dio = Dio( + BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.jsonContentType, + validateStatus: (s) => s != null && s < 500, + ), + ); + + Future> _post(Map body) async { + Future sendJson(Map p) { + final prev = _dio.options.contentType; + _dio.options.contentType = Headers.jsonContentType; + return _dio.post('', data: p).whenComplete(() { + _dio.options.contentType = prev; + }); + } + + Future sendForm(Map p) { + final prev = _dio.options.contentType; + _dio.options.contentType = Headers.formUrlEncodedContentType; + final flat = p.map((k, v) => MapEntry(k, (v is Map || v is List) ? jsonEncode(v) : v)); + return _dio.post('', data: flat).whenComplete(() { + _dio.options.contentType = prev; + }); + } + + Map? map; + Response? res; + final payload = {...body}; + try { + res = await sendJson(payload); + } on DioException catch (e) { + // Capture 5xx with body + if (e.response != null) { + res = e.response; + } else { + // ignore: avoid_print + print('Backend request failed (JSON, no response). URL: ${_dio.options.baseUrl}, payload: $payload, error: $e'); + throw Exception('Backend request failed: $e'); + } + } + + bool shouldFallbackToForm() { + final sc = res?.statusCode; + if (sc == 400) { + try { + final d = res?.data; + if (d is Map && (d['error']?.toString().toLowerCase().contains('unknown action') ?? false)) { + return true; + } + } catch (_) {} + } + return false; + } + + if (shouldFallbackToForm()) { + // ignore: avoid_print + print('Retrying request as form-urlencoded due to unknown action (server likely expects \$_POST).'); + try { + res = await sendForm(payload); + } on DioException catch (e) { + if (e.response != null) { + res = e.response; + } else { + // ignore: avoid_print + print('Backend request failed (FORM, no response). URL: ${_dio.options.baseUrl}, payload: $payload, error: $e'); + throw Exception('Backend request failed: $e'); + } + } + } + + if (res != null && res.data is Map) { + map = res.data as Map; + } + if (res == null || res.statusCode != 200) { + // ignore: avoid_print + final sc = res?.statusCode; + final data = res?.data; + print('Backend HTTP ${sc}. URL: ${_dio.options.baseUrl}, payload: $payload, data: ${data}'); + if (map != null && map.containsKey('error')) { + throw Exception('Backend HTTP ${sc}: ${map['error']}'); + } + throw Exception('Backend HTTP ${sc}: ${data}'); + } + if (map == null) { + throw Exception('Backend: Unexpected response format'); + } + if (map['ok'] != true) { + // ignore: avoid_print + print('Backend logical error. URL: ${_dio.options.baseUrl}, payload: $payload, data: $map'); + throw Exception('Backend responded with error: ${map['error']}'); + } + return map; + } + + Future>> getMovies( + {String? status, String? q, int offset = 0, int limit = 50}) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'movie', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future setStatus({ + required String type, // 'movie' | 'episode' + required int refId, + required String status, // 'Init' | 'Progress' | 'Done' + }) async { + await _post({ + 'action': 'set_status', + 'type': type, + 'ref_id': refId, + 'status': status, + }); + } + + Future upsertMovie(Map tmdbJson) async { + await _post({ + 'action': 'upsert_movie', + 'tmdb': tmdbJson, + }); + } + + Future>> getEpisodes({ + String? status, + String? q, + int offset = 0, + int limit = 5000, + }) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'episode', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + + Future upsertShow(Map tmdbJson) async { + await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); + } + + Future upsertSeason(int showId, Map seasonJson) async { + final map = await _post( + {'action': 'upsert_season', 'show_id': showId, 'tmdb': seasonJson}); + return (map['id'] as num).toInt(); + } + + Future upsertEpisode( + int seasonId, Map episodeJson) async { + final map = await _post({ + 'action': 'upsert_episode', + 'season_id': seasonId, + 'tmdb': episodeJson + }); + return (map['id'] as num).toInt(); + } + + Future getShowDbIdByTmdbId(int tmdbId) async { + final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId}); + final v = map['id']; + return v == null ? null : (v as num).toInt(); + } +} + diff --git a/lib/core/api/tmdb_api.dart b/lib/core/api/tmdb_api.dart new file mode 100644 index 0000000..c4ca3a9 --- /dev/null +++ b/lib/core/api/tmdb_api.dart @@ -0,0 +1,65 @@ +// lib/core/api/tmdb_api.dart +import 'package:dio/dio.dart'; +import '../config.dart'; + +class TmdbApi { + final Dio _dio; + + TmdbApi() + : _dio = Dio( + BaseOptions( + baseUrl: 'https://api.themoviedb.org/3', + headers: { + 'Accept': 'application/json', + }, + validateStatus: (code) => code != null && code < 500, + ), + ); + + Map get _auth => { + 'api_key': AppConfig.tmdbApiKey, + 'language': 'de-DE', + }; + + Future> getMovie(int id) async { + final res = await _dio.get( + '/movie/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getMovie($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getShow(int id) async { + final res = await _dio.get( + '/tv/$id', + queryParameters: { + ..._auth, + 'append_to_response': 'images,credits', + }, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getShow($id) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } + + Future> getSeason(int showId, int seasonNumber) async { + final res = await _dio.get( + '/tv/$showId/season/$seasonNumber', + queryParameters: _auth, + ); + if (res.statusCode != 200) { + throw Exception( + 'TMDB getSeason($showId,S$seasonNumber) failed: ${res.statusCode} ${res.data}'); + } + return Map.from(res.data); + } +} diff --git a/lib/core/config.dart b/lib/core/config.dart new file mode 100644 index 0000000..33a9f55 --- /dev/null +++ b/lib/core/config.dart @@ -0,0 +1,24 @@ +/// Globale Konfiguration. Standard: von `--dart-define` lesen. +/// Fallbacks sind hilfreich für lokale Tests. + +class AppConfig { + static const backendBaseUrl = String.fromEnvironment( + 'BACKEND_BASE_URL', + defaultValue: 'https://api.windesign.at/multimedia.php', + ); + + /// Token eines vorhandenen Users in deiner DB (users.api_token) + static const backendToken = String.fromEnvironment( + 'BACKEND_TOKEN', + defaultValue: 'dasistwiedereinverystrongtoken', + ); + + /// TMDB API Key (nur lesend). Für Public-Apps besser: Server-Proxy/Caching. + static const tmdbApiKey = String.fromEnvironment( + 'TMDB_API_KEY', + + defaultValue: 'a33271b9e54cdcb9a80680eaf5522f1b', + + ///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo' + ); +} diff --git a/lib/core/status.dart b/lib/core/status.dart new file mode 100644 index 0000000..ff0c391 --- /dev/null +++ b/lib/core/status.dart @@ -0,0 +1,8 @@ +enum ItemStatus { Init, Progress, Done } + +ItemStatus statusFromString(String? s) { + return ItemStatus.values.firstWhere( + (e) => e.name == (s ?? 'Init'), + orElse: () => ItemStatus.Init, + ); +} \ No newline at end of file diff --git a/lib/features/import/import_screen.dart b/lib/features/import/import_screen.dart new file mode 100644 index 0000000..0d8a26f --- /dev/null +++ b/lib/features/import/import_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../core/api/tmdb_api.dart'; +import '../../core/api/backend_api.dart'; +import '../shared/providers.dart'; + +class ImportScreen extends ConsumerStatefulWidget { + const ImportScreen({super.key}); + + @override + ConsumerState createState() => _ImportScreenState(); +} + +class _ImportScreenState extends ConsumerState { + final _movieCtrl = + TextEditingController(text: '603, 27205'); // Matrix, Inception + final _showCtrl = + TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT + String _log = ''; + bool _busy = false; + + void _append(String msg) => setState(() => _log += msg + '\n'); + + Future _importMovies() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _movieCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + for (final id in ids) { + try { + _append('Film $id: TMDB laden …'); + final json = await tmdb.getMovie(id); + await backend.upsertMovie(json); + _append('Film $id: OK ✓'); + } catch (e) { + _append('Film $id: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _importShows() async { + setState(() => _busy = true); + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + + final ids = _showCtrl.text + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.parse); + + for (final showId in ids) { + try { + _append('Serie $showId: TMDB laden …'); + final showJson = await tmdb.getShow(showId); + print('SHOW JSON: $showJson'); // Debug-Ausgabe + + await backend.upsertShow(showJson); + _append('Serie $showId: Show OK ✓'); + + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? 0) is int) + .cast>(); + + for (final s in seasons) { + final seasonNo = (s['season_number'] as num).toInt(); + if (seasonNo < 0) continue; + _append(' S$seasonNo: TMDB Season laden …'); + + final seasonJson = await tmdb.getSeason(showId, seasonNo); + final dbShowId = await _getDbShowIdByTmdb(backend, showId); + final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson); + + _append(' S$seasonNo: Season OK (db:$dbSeasonId)'); + + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(dbSeasonId, e); + } + _append(' S$seasonNo: ${eps.length} Episoden OK ✓'); + } + } catch (e) { + // 👇 Hier kommt der erweiterte Catch hin! + if (e is DioException) { + print('❗ TMDB DioException für $showId'); + print('➡️ Request: ${e.requestOptions.uri}'); + print('➡️ Response: ${e.response?.data}'); + print('➡️ Status: ${e.response?.statusCode}'); + } + _append('Serie $showId: Fehler → $e'); + } + } + setState(() => _busy = false); + } + + Future _getDbShowIdByTmdb(BackendApi backend, int tmdbId) async { + final id = await backend.getShowDbIdByTmdbId(tmdbId); + if (id == null) { + throw Exception( + 'Show mit tmdb_id=$tmdbId nicht gefunden – zuerst upsert_show aufrufen.'); + } + return id; + } + + @override + Widget build(BuildContext context) { + final inputStyle = const TextStyle(fontSize: 13); + + return Scaffold( + appBar: AppBar(title: const Text('Import (TMDB → DB)')), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + const Text('Filme TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: + TextField(controller: _movieCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importMovies, + child: const Text('Import Filme'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Serien TMDB-IDs: '), + const SizedBox(width: 8), + Expanded( + child: TextField(controller: _showCtrl, style: inputStyle)), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _importShows, + child: const Text('Import Serien'), + ), + ], + ), + const SizedBox(height: 12), + if (_busy) const LinearProgressIndicator(), + const SizedBox(height: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.black12), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: SelectableText(_log, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12)), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/movies/data/movie_model.dart b/lib/features/movies/data/movie_model.dart new file mode 100644 index 0000000..ffd8f63 --- /dev/null +++ b/lib/features/movies/data/movie_model.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import '../../../core/status.dart'; + +class Movie { + final int id; // DB-ID + final int tmdbId; + final String title; + final int? releaseYear; + final String? posterPath; + final ItemStatus status; + final String? resolution; + final String? overview; + + Movie({ + required this.id, + required this.tmdbId, + required this.title, + this.releaseYear, + this.posterPath, + this.status = ItemStatus.Init, + this.resolution, + this.overview, + }); + + factory Movie.fromJson(Map j) { + String? ov; + if (j['overview'] is String) { + ov = j['overview'] as String?; + } else if (j['json'] is String) { + try { + final m = jsonDecode(j['json'] as String); + if (m is Map && m['overview'] is String) ov = m['overview'] as String; + } catch (_) {} + } + return Movie( + id: j['id'] as int, + tmdbId: j['tmdb_id'] as int, + title: j['title'] as String, + releaseYear: j['release_year'] as int?, + posterPath: j['poster_path'] as String?, + status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init, + resolution: j['resolution'] as String?, + overview: ov, + ); + } +} diff --git a/lib/features/movies/data/movie_repository.dart b/lib/features/movies/data/movie_repository.dart new file mode 100644 index 0000000..2140b67 --- /dev/null +++ b/lib/features/movies/data/movie_repository.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../shared/providers.dart'; +import 'movie_model.dart'; + +final movieFilterProvider = StateProvider((_) => null); + +final moviesProvider = FutureProvider.autoDispose>((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(movieFilterProvider); + final list = await backend.getMovies(status: st?.name); + return list.map(Movie.fromJson).toList(); +}); \ No newline at end of file diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart new file mode 100644 index 0000000..4712976 --- /dev/null +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -0,0 +1,167 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../data/movie_repository.dart'; +import 'widgets/status_chip.dart'; + +class MovieListScreen extends ConsumerWidget { + const MovieListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(movieFilterProvider); + final moviesAsync = ref.watch(moviesProvider); + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: moviesAsync.when( + data: (items) { + final itemsSorted = [...items] + ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + return RefreshIndicator( + onRefresh: () async => ref.invalidate(moviesProvider), + child: ListView.separated( + itemCount: itemsSorted.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final m = itemsSorted[i]; + return ListTile( + tileColor: _statusBg(m.status, context), + leading: m.posterPath != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: + 'https://image.tmdb.org/t/p/w154${m.posterPath}', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ) + : const SizedBox(width: 50, height: 75), + title: Text( + m.releaseYear != null + ? '${m.title} (${m.releaseYear})' + : m.title, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 56, + child: Align( + alignment: Alignment.topLeft, + child: FittedBox( + alignment: Alignment.topLeft, + fit: BoxFit.scaleDown, + child: _resolutionBadge(m.resolution, context), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + m.overview ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + isThreeLine: true, + onTap: () { + // TODO: Detailseite / Status ändern + }, + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center( + child: Text('Fehler: ${e.toString()}'), + ), + ), + ), + ], + ), + ); + } + + IconData _statusIcon(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return Icons.hourglass_empty; + case ItemStatus.Progress: + return Icons.downloading; + case ItemStatus.Done: + return Icons.check_circle; + } + } + + Color? _statusBg(ItemStatus s, BuildContext context) { + switch (s) { + case ItemStatus.Init: + return Colors.grey.shade200; + case ItemStatus.Progress: + return Colors.blue; + case ItemStatus.Done: + return Colors.green; + } + } + + Widget _resolutionBadge(String? res, BuildContext context) { + if (res == null || res.isEmpty) return const SizedBox.shrink(); + final m = RegExp(r"\d+").firstMatch(res); + final v = m != null ? int.tryParse(m.group(0)!) : null; + IconData? icon; + Color color = Theme.of(context).colorScheme.primary; + String label = res; + if (v != null) { + if (v >= 2000) { + icon = Icons.four_k; + color = Colors.deepOrange; + label = '${v}p'; + } else if (v >= 1000) { + icon = Icons.hd; + color = Colors.blue; + label = '${v}p'; + } else { + icon = Icons.sd_card; + color = Colors.grey; + label = '${v}p'; + } + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(0.9), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) Icon(icon, size: 14, color: color), + if (icon != null) const SizedBox(width: 4), + Text( + label, + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface), + ), + ], + ), + ); + } +} + diff --git a/lib/features/movies/presentation/widgets/status_chip.dart b/lib/features/movies/presentation/widgets/status_chip.dart new file mode 100644 index 0000000..20103ca --- /dev/null +++ b/lib/features/movies/presentation/widgets/status_chip.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:multimediaFlutter/core/status.dart'; + +class StatusChip extends StatelessWidget { + final ItemStatus? selected; + final ValueChanged onChanged; + + const StatusChip( + {super.key, required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('Alle'), + selected: selected == null, + onSelected: (_) => onChanged(null), + ), + for (final s in ItemStatus.values) + FilterChip( + label: Text(s.name), + selected: selected == s, + onSelected: (_) => onChanged(s), + ), + ], + ); + } +} diff --git a/lib/features/ping/ping_test_screen.dart b/lib/features/ping/ping_test_screen.dart new file mode 100644 index 0000000..3a3534c --- /dev/null +++ b/lib/features/ping/ping_test_screen.dart @@ -0,0 +1,74 @@ + +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import '../../core/config.dart'; + +class PingTestScreen extends StatefulWidget { + const PingTestScreen({super.key}); + + @override + State createState() => _PingTestScreenState(); +} + +class _PingTestScreenState extends State { + String? _result; + bool _loading = false; + + Future _pingServer() async { + setState(() { + _loading = true; + _result = null; + }); + + try { + final res = await Dio(BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const { + 'Accept': 'application/json', + }, + contentType: Headers.jsonContentType, + validateStatus: (s) => s != null && s < 500, + )).post('', data: {'action': 'ping'}); + + setState(() { + _result = '✅ Antwort: ${res.data}'; + }); + } catch (e) { + setState(() { + _result = '❌ Fehler: $e'; + }); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ping-Test')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.wifi_tethering), + label: const Text('Ping-Server'), + onPressed: _loading ? null : _pingServer, + ), + const SizedBox(height: 20), + if (_loading) const CircularProgressIndicator(), + if (_result != null) + SelectableText( + _result!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/series/data/episode_model.dart b/lib/features/series/data/episode_model.dart new file mode 100644 index 0000000..b6adaf9 --- /dev/null +++ b/lib/features/series/data/episode_model.dart @@ -0,0 +1,57 @@ +import '../../../core/status.dart'; + +class EpisodeItem { + final int id; + final int episodeNumber; + final int seasonNumber; + final String showName; + final String? name; + final ItemStatus status; + final String? resolution; + final int? firstAirYear; + final String? showJson; + final String? showStatus; + final bool? showCliffhanger; + final String? posterPath; + + EpisodeItem({ + required this.id, + required this.episodeNumber, + required this.seasonNumber, + required this.showName, + this.name, + required this.status, + this.resolution, + this.firstAirYear, + this.showJson, + this.showStatus, + this.showCliffhanger, + this.posterPath, + }); + + factory EpisodeItem.fromJson(Map j) => EpisodeItem( + id: j['id'] as int, + episodeNumber: j['episode_number'] as int, + seasonNumber: j['season_number'] as int, + showName: j['show_name'] as String, + name: j['name'] as String?, + status: statusFromString(j['status'] as String?), + resolution: j['resolution'] as String?, + firstAirYear: j['first_air_year'] as int?, + showJson: j['show_json'] as String?, + showStatus: j['show_status'] as String?, + showCliffhanger: _parseBool(j['show_cliffhanger']), + posterPath: j['poster_path'] as String?, + ); + + static bool? _parseBool(dynamic v) { + if (v == null) return null; + if (v is bool) return v; + if (v is num) return v != 0; + if (v is String) { + final s = v.toLowerCase(); + return s == '1' || s == 'true' || s == 'yes'; + } + return null; + } +} diff --git a/lib/features/series/data/series_repository.dart b/lib/features/series/data/series_repository.dart new file mode 100644 index 0000000..8a58d2a --- /dev/null +++ b/lib/features/series/data/series_repository.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/providers.dart'; +import '../../../core/status.dart'; +import 'episode_model.dart'; + +final episodeFilterProvider = StateProvider((_) => null); + +final seriesGroupedProvider = + FutureProvider.autoDispose((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(episodeFilterProvider); + final rows = await backend.getEpisodes(status: st?.name, limit: 5000); + final items = rows + .map(EpisodeItem.fromJson) + .where((e) => e.seasonNumber > 0) + .toList(); + return SeriesGroupedData.fromEpisodes(items); +}); + +class SeriesGroupedData { + final Map>> + data; // showName -> season -> episodes + SeriesGroupedData(this.data); + + factory SeriesGroupedData.fromEpisodes(List items) { + final map = >>{}; + for (final e in items) { + final bySeason = + map.putIfAbsent(e.showName, () => >{}); + final list = bySeason.putIfAbsent(e.seasonNumber, () => []); + list.add(e); + } + for (final bySeason in map.values) { + for (final eps in bySeason.values) { + eps.sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber)); + } + } + return SeriesGroupedData(map); + } +} diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart new file mode 100644 index 0000000..d46d840 --- /dev/null +++ b/lib/features/series/presentation/series_list_screen.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../../movies/presentation/widgets/status_chip.dart'; +import '../data/series_repository.dart'; +import 'widgets/episode_status_strip.dart'; + +class SeriesListScreen extends ConsumerWidget { + const SeriesListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(episodeFilterProvider); + final groupedAsync = ref.watch(seriesGroupedProvider); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => + ref.read(episodeFilterProvider.notifier).state = f, + ), + const SizedBox(height: 12), + Expanded( + child: groupedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (data) { + final shows = data.data.keys.toList()..sort(); + if (shows.isEmpty) { + return const Center( + child: Text('Keine Episoden gefunden.')); + } + // Collect all season columns across all shows + final allSeasons = {}; + for (final m in data.data.values) { + allSeasons.addAll(m.keys); + } + final seasonCols = allSeasons.toList()..sort(); + + List buildColumns() => [ + const DataColumn(label: Text('Serie')), + for (final s in seasonCols) DataColumn(label: Text('Staffel $s')), + ]; + + List buildRows() { + final rows = []; + for (final showName in shows) { + final bySeason = data.data[showName]!; + int? year; + String? resolution; + String? showJson; + String? showStatus; + bool? showCliffhanger; + String? posterPath; + final sortedSeasons = bySeason.keys.toList()..sort(); + for (final s in sortedSeasons) { + final eps = bySeason[s]!; + for (final e in eps) { + year ??= e.firstAirYear; + resolution ??= e.resolution; + showJson ??= e.showJson; + showStatus ??= e.showStatus; + showCliffhanger ??= e.showCliffhanger; + posterPath ??= e.posterPath; + if (year != null && resolution != null && showStatus != null && showCliffhanger != null && posterPath != null) break; + } + if (year != null && resolution != null) break; + } + if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) { + try { + final m = jsonDecode(showJson!); + if (m is Map && m['first_air_date'] is String) { + final s = (m['first_air_date'] as String); + if (s.length >= 4) year = int.tryParse(s.substring(0, 4)); + } + if (m is Map && showStatus == null && m['status'] is String) { + showStatus = m['status'] as String; + } + if (m is Map && showCliffhanger == null && m['cliffhanger'] != null) { + final v = m['cliffhanger']; + if (v is bool) showCliffhanger = v; + else if (v is num) showCliffhanger = v != 0; + else if (v is String) { + final s = v.toLowerCase(); + showCliffhanger = (s == '1' || s == 'true' || s == 'yes'); + } + } + } catch (_) {} + } + + // Determine background for left cell based on episodes status + bool anyProgress = false; + bool anyInit = false; + for (final epsList in bySeason.values) { + for (final ep in epsList) { + if (ep.status == ItemStatus.Progress) anyProgress = true; + if (ep.status == ItemStatus.Init) anyInit = true; + } + } + Color? bg; + if (anyProgress) bg = Colors.blue; + else if (anyInit) bg = Colors.grey.shade200; + else bg = Colors.green; + + final cells = [ + DataCell(Container( + color: bg, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: SizedBox( + width: 360, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ), + if (posterPath != null) const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _seriesTitle(showName, year, showStatus, showCliffhanger, context), + const SizedBox(height: 4), + _resolutionInline(resolution, context), + ], + ), + ), + ], + ), + ), + )), + ]; + + for (final s in seasonCols) { + final eps = bySeason[s]; + if (eps == null || eps.isEmpty) { + cells.add(const DataCell(Text('-', textAlign: TextAlign.center))); + } else { + cells.add(DataCell(EpisodeStatusStrip( + episodes: eps, + barWidth: 7, + barHeight: 25, + spacing: 0, + ))); + } + } + + rows.add(DataRow(cells: cells)); + } + return rows; + } + + final table = DataTable( + columns: buildColumns(), + rows: buildRows(), + headingRowHeight: 40, + dataRowMinHeight: 88, + dataRowMaxHeight: 96, + columnSpacing: 16, + ); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView(child: table), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _resolutionInline(String? res, BuildContext context) { + if (res == null || res.isEmpty) return const SizedBox.shrink(); + final m = RegExp(r"\d+").firstMatch(res); + final v = m != null ? int.tryParse(m.group(0)!) : null; + IconData? icon; + Color color = Theme.of(context).colorScheme.primary; + String label = res; + if (v != null) { + if (v >= 2000) { + icon = Icons.four_k; + color = Colors.deepOrange; + label = '${v}p'; + } else if (v >= 1000) { + icon = Icons.hd; + color = Colors.blue; + label = '${v}p'; + } else { + icon = Icons.sd_card; + color = Colors.grey; + label = '${v}p'; + } + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) Icon(icon, size: 16, color: color), + if (icon != null) const SizedBox(width: 4), + Text( + label, + style: const TextStyle(fontSize: 12), + ), + ], + ); + } + + Widget _seriesTitle(String name, int? year, String? status, bool? cliff, BuildContext context) { + final base = Theme.of(context).textTheme.titleMedium; + final s = (status ?? '').toLowerCase(); + final endedOrCanceled = s == 'ended' || s == 'canceled' || s == 'cancelled'; + final fw = endedOrCanceled ? FontWeight.normal : FontWeight.w600; + final fs = (cliff == true) ? FontStyle.italic : FontStyle.normal; + final style = base?.copyWith(fontWeight: fw, fontStyle: fs); + final text = year != null ? '$name ($year)' : name; + return Text(text, overflow: TextOverflow.ellipsis, style: style); + } +} diff --git a/lib/features/series/presentation/widgets/episode_status_strip.dart b/lib/features/series/presentation/widgets/episode_status_strip.dart new file mode 100644 index 0000000..07d85d1 --- /dev/null +++ b/lib/features/series/presentation/widgets/episode_status_strip.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../../../../core/status.dart'; +import '../../data/episode_model.dart'; + +class EpisodeStatusStrip extends StatelessWidget { + final List episodes; + final double barWidth; // Breite eines Quadrats + final double barHeight; // Höhe eines Quadrats + final double spacing; // Abstand zwischen Quadraten + + const EpisodeStatusStrip({ + super.key, + required this.episodes, + this.barWidth = 7, + this.barHeight = 25, + this.spacing = 0, + }); + + Color _fill(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return Colors.grey.shade200; // wie Filme (Init) + case ItemStatus.Progress: + return Colors.blue; // wie Filme (Progress) + case ItemStatus.Done: + return Colors.green; // wie Filme (Done) + } + } + + @override + Widget build(BuildContext context) { + if (episodes.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < episodes.length; i++) ...[ + Container( + width: barWidth, + height: barHeight, + decoration: BoxDecoration( + color: _fill(episodes[i].status), + border: Border( + left: BorderSide(color: Colors.black, width: i == 0 ? 1 : 0), + top: const BorderSide(color: Colors.black, width: 1), + right: const BorderSide(color: Colors.black, width: 1), + bottom: const BorderSide(color: Colors.black, width: 1), + ), + ), + ), + if (i != episodes.length - 1 && spacing != 0) SizedBox(width: spacing), + ], + ], + ), + ); + } +} + diff --git a/lib/features/shared/providers.dart b/lib/features/shared/providers.dart new file mode 100644 index 0000000..82cef69 --- /dev/null +++ b/lib/features/shared/providers.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/backend_api.dart'; +import '../../core/api/tmdb_api.dart'; + +final backendApiProvider = Provider((ref) => BackendApi()); +final tmdbApiProvider = Provider((ref) => TmdbApi()); \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..1bbe1fd --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: MultimediaApp())); +} \ No newline at end of file diff --git a/lib/php/multimedia-settings.php b/lib/php/multimedia-settings.php new file mode 100644 index 0000000..374049f --- /dev/null +++ b/lib/php/multimedia-settings.php @@ -0,0 +1,27 @@ + diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php new file mode 100644 index 0000000..e632072 --- /dev/null +++ b/lib/php/multimedia.php @@ -0,0 +1,365 @@ +false,'error'=>$msg], $code); } + +$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DATABASE_HOST, DATABASE_NAME); +try { + $pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (Throwable $e) { + if (MM_DEBUG) fail('DB connection failed: '.$e->getMessage(), 500); + fail('DB connection failed', 500); +} + +$in = inputBody(); +$action = $in['action'] ?? null; + +try { + switch ($action) { + case 'upsert_show': { + $tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload'); + try { + $stmt = $pdo->prepare("INSERT INTO shows (tmdb_id, name, original_name, first_air_year, poster_path, backdrop_path, json) + VALUES (?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), original_name=VALUES(original_name), first_air_year=VALUES(first_air_year), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), json=VALUES(json)"); + $stmt->execute([ + $tmdb['id'], + $tmdb['name'] ?? '', + $tmdb['original_name'] ?? null, + isset($tmdb['first_air_date']) ? intval(substr($tmdb['first_air_date'],0,4)) : null, + $tmdb['poster_path'] ?? null, + $tmdb['backdrop_path'] ?? null, + json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + ]); + } catch (Throwable $e) { + // Fallback for schemas without original_name/first_air_year + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $stmt = $pdo->prepare("INSERT INTO shows (tmdb_id, name, poster_path, backdrop_path, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), json=VALUES(json)"); + $stmt->execute([ + $tmdb['id'], + $tmdb['name'] ?? '', + $tmdb['poster_path'] ?? null, + $tmdb['backdrop_path'] ?? null, + json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + ]); + } else { + throw $e; + } + } + $id = $pdo->lastInsertId(); + if (!$id) { $q=$pdo->prepare('SELECT id FROM shows WHERE tmdb_id=?'); $q->execute([$tmdb['id']]); $id=$q->fetchColumn(); } + resp(['ok'=>true,'id'=>(int)$id]); + } + + case 'upsert_season': { + $showId = (int)($in['show_id'] ?? 0); + $tmdb = $in['tmdb'] ?? null; if (!$showId || !$tmdb) fail('bad params'); + $seasonNo = isset($tmdb['season_number']) ? (int)$tmdb['season_number'] : null; if ($seasonNo===null) fail('missing season_number'); + $name = $tmdb['name'] ?? (isset($seasonNo) ? ('Season '.$seasonNo) : ''); + $airDate = $tmdb['air_date'] ?? null; + try { + $stmt = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, air_date, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), air_date=VALUES(air_date), json=VALUES(json)"); + $stmt->execute([$showId, $seasonNo, $name, $airDate, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]); + } catch (Throwable $e) { + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $stmt = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, json) + VALUES (?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), json=VALUES(json)"); + $stmt->execute([$showId, $seasonNo, $name, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]); + } else { throw $e; } + } + $id = $pdo->lastInsertId(); + if (!$id) { $q=$pdo->prepare('SELECT id FROM seasons WHERE show_id=? AND season_number=?'); $q->execute([$showId,$seasonNo]); $id=$q->fetchColumn(); } + resp(['ok'=>true,'id'=>(int)$id]); + } + + case 'upsert_episode': { + $seasonId = (int)($in['season_id'] ?? 0); + $tmdb = $in['tmdb'] ?? null; if (!$seasonId || !$tmdb) fail('bad params'); + $epNo = isset($tmdb['episode_number']) ? (int)$tmdb['episode_number'] : null; if ($epNo===null) fail('missing episode_number'); + $name = $tmdb['name'] ?? ('Episode '.$epNo); + $runtime = isset($tmdb['runtime']) ? (int)$tmdb['runtime'] : null; + $tmdbId = $tmdb['id'] ?? null; + try { + if ($tmdbId) { + $stmt = $pdo->prepare("INSERT INTO episodes (season_id, tmdb_id, episode_number, name, runtime, json) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE episode_number=VALUES(episode_number), name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)"); + $stmt->execute([$seasonId, $tmdbId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]); + } else { + // No tmdb_id + $stmt = $pdo->prepare("INSERT INTO episodes (season_id, episode_number, name, runtime, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)"); + $stmt->execute([$seasonId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]); + } + } catch (Throwable $e) { + // Fallback if tmdb_id column doesn't exist: always use (season_id, episode_number) + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $stmt = $pdo->prepare("INSERT INTO episodes (season_id, episode_number, name, runtime, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)"); + $stmt->execute([$seasonId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]); + } else { throw $e; } + } + $id = $pdo->lastInsertId(); + if (!$id) { + if ($tmdbId) { $q=$pdo->prepare('SELECT id FROM episodes WHERE tmdb_id=?'); $q->execute([$tmdbId]); $id=$q->fetchColumn(); } + if (!$id) { $q=$pdo->prepare('SELECT id FROM episodes WHERE season_id=? AND episode_number=?'); $q->execute([$seasonId,$epNo]); $id=$q->fetchColumn(); } + } + resp(['ok'=>true,'id'=>(int)$id]); + } + case 'upsert_movie': { + $tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload'); + $stmt = $pdo->prepare("INSERT INTO movies (tmdb_id, title, original_title, release_year, poster_path, backdrop_path, runtime, json) + VALUES (?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE title=VALUES(title), original_title=VALUES(original_title), release_year=VALUES(release_year), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), runtime=VALUES(runtime), json=VALUES(json)"); + $stmt->execute([ + $tmdb['id'], + $tmdb['title'] ?? $tmdb['name'] ?? '', + $tmdb['original_title'] ?? $tmdb['original_name'] ?? null, + isset($tmdb['release_date']) ? intval(substr($tmdb['release_date'],0,4)) : null, + $tmdb['poster_path'] ?? null, + $tmdb['backdrop_path'] ?? null, + $tmdb['runtime'] ?? null, + json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + ]); + $id = $pdo->lastInsertId(); + if (!$id) { $q=$pdo->prepare('SELECT id FROM movies WHERE tmdb_id=?'); $q->execute([$tmdb['id']]); $id=$q->fetchColumn(); } + resp(['ok'=>true,'id'=>(int)$id]); + } + + case 'set_status': { + $type=$in['type'] ?? null; + $ref=(int)($in['ref_id'] ?? 0); + $st=$in['status'] ?? null; // may be string or int + if (!in_array($type,['movie','episode'],true) || !$ref || $st===null) fail('bad params'); + // Map string statuses to int codes: 0 Init, 1 Progress, 2 Done + $stInt = is_numeric($st) ? (int)$st : (function($s){ + $s = strtolower((string)$s); + if ($s==='progress') return 1; if ($s==='done') return 2; return 0; + })($st); + if ($type==='movie') { + $stmt=$pdo->prepare("UPDATE movies SET status=? WHERE id=?"); + $stmt->execute([$stInt,$ref]); + } else { + $stmt=$pdo->prepare("UPDATE episodes SET status=? WHERE id=?"); + $stmt->execute([$stInt,$ref]); + } + resp(['ok'=>true]); + } + + case 'get_list': { + $type=$in['type'] ?? 'movie'; + $status=$in['status'] ?? null; + $q=$in['q'] ?? ''; + $limit=max(1,(int)($in['limit']??50)); + $offset=max(0,(int)($in['offset']??0)); + + if ($type === 'episode') { + $statusVal = null; + if ($status) { + $s = strtolower($status); + if ($s==='init') $statusVal = 0; elseif ($s==='progress') $statusVal = 1; elseif ($s==='done') $statusVal = 2; + } + $base = "FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN shows sh ON sh.id = se.show_id + WHERE 1=1"; + // Prefer extracting show status directly from JSON and include cliffhanger column if present + $selectWithYearJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.first_air_year AS first_air_year, + JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; + $selectNoYearJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + NULL AS first_air_year, + JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; + // Fallback selects without JSON_EXTRACT (older MySQL) — frontend will parse from show_json + $selectWithYear = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.first_air_year AS first_air_year, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; + $selectNoYear = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + NULL AS first_air_year, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; + + $run = function(string $sql) use ($pdo, $statusVal, $q, $offset, $limit) { + $params = []; + if ($statusVal !== null) { $sql .= " AND e.status = ?"; $params[] = $statusVal; } + if ($q) { $sql .= " AND e.name LIKE ?"; $params[] = "%$q%"; } + $sql .= " ORDER BY sh.name ASC, se.season_number ASC, e.episode_number ASC LIMIT ?, ?"; + $params[] = $offset; $params[] = $limit; + $stmt = $pdo->prepare($sql); + $i=1; foreach ($params as $p) { $stmt->bindValue($i++, $p, is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR); } + $stmt->execute(); + return $stmt->fetchAll(); + }; + + try { + $rows = $run($selectWithYearJson); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e) { + $msg = $e->getMessage(); + if (strpos($msg, 'Unknown column') !== false) { + // Maybe first_air_year missing — try JSON version without year + try { + $rows = $run($selectNoYearJson); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e2) { + $msg2 = $e2->getMessage(); + if (stripos($msg2, 'JSON_EXTRACT') !== false || stripos($msg2, 'Unknown function') !== false) { + // Fallback to non-JSON_EXTRACT selects + try { + $rows = $run($selectWithYear); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e3) { + if (strpos($e3->getMessage(), 'Unknown column') !== false) { + $rows = $run($selectNoYear); + resp(['ok'=>true,'items'=>$rows]); + } else { throw $e3; } + } + } else { throw $e2; } + } + } elseif (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) { + // JSON functions not available + try { + $rows = $run($selectWithYear); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e4) { + if (strpos($e4->getMessage(), 'Unknown column') !== false) { + $rows = $run($selectNoYear); + resp(['ok'=>true,'items'=>$rows]); + } else { throw $e4; } + } + } else { throw $e; } + } + } + + if ($type==='movie') { + $statusVal = null; + if ($status) { + $s = strtolower($status); + if ($s==='init') $statusVal = 0; elseif ($s==='progress') $statusVal = 1; elseif ($s==='done') $statusVal = 2; + } + $sql = "SELECT m.*, CASE m.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, m.resolution + FROM movies m WHERE 1=1"; + $params=[]; + if ($statusVal !== null) { $sql.=" AND m.status=?"; $params[]=$statusVal; } + if ($q){$sql.=" AND m.title LIKE ?"; $params[]='%'.$q.'%';} + $sql.=" ORDER BY m.title ASC LIMIT ?,?"; $params[]=$offset; $params[]=$limit; + $stmt=$pdo->prepare($sql); $i=1; foreach($params as $p){$stmt->bindValue($i++,$p,is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR);} $stmt->execute(); + resp(['ok'=>true,'items'=>$stmt->fetchAll()]); + } + fail('unsupported type'); + } + + case 'get_show_by_tmdb': { + $tmdbId = (int)($in['tmdb_id'] ?? 0); + if (!$tmdbId) fail('bad params'); + $stmt = $pdo->prepare('SELECT id FROM shows WHERE tmdb_id = ?'); + $stmt->execute([$tmdbId]); + $id = $stmt->fetchColumn(); + resp(['ok' => true, 'id' => $id ? (int)$id : null]); + } + + case 'ping': { + resp([ + 'ok' => true, + 'time' => date('c'), + 'origin' => $origin, + 'php' => PHP_VERSION, + ]); + } + + default: fail('unknown action'); + } +} catch (Throwable $e) { + if (MM_DEBUG) resp(['ok'=>false,'error'=>$e->getMessage()], 500); + resp(['ok'=>false,'error'=>'server error'], 500); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..32109d7 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,509 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..071ccac --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,25 @@ +name: multimediaFlutter +description: Verwaltung von Videos & Serien mit Riverpod, TMDB und PHP-Backend +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.1 + dio: ^5.7.0 + cached_network_image: ^3.3.1 + intl: ^0.19.0 + + # Material 3 ist im Flutter SDK enthalten + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/tool/export.bat b/tool/export.bat new file mode 100644 index 0000000..f7318b8 --- /dev/null +++ b/tool/export.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +REM Wrapper to run export.ps1 with double-click or CLI +set SCRIPT_DIR=%~dp0 +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%export.ps1" %* +set EXITCODE=%ERRORLEVEL% +if %EXITCODE% NEQ 0 ( + echo Export failed. Exit code %EXITCODE%. + exit /b %EXITCODE% +) +echo Archive created. Check the dist\ folder. +endlocal + diff --git a/tool/export.ps1 b/tool/export.ps1 new file mode 100644 index 0000000..d0299ce --- /dev/null +++ b/tool/export.ps1 @@ -0,0 +1,133 @@ +Param( + [switch]$Clean, + [switch]$Minimal, + [switch]$AsFolder, + [switch]$Bundle, + [int]$Chunk = 0, + [string]$OutDir = "dist", + [string]$Name = "multimediaFlutter-src" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Ensure output and temp directories +if (-not (Test-Path $OutDir)) { + New-Item -ItemType Directory -Path $OutDir | Out-Null +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmm' +$baseName = "$Name-$timestamp" +$zipPath = Join-Path $OutDir ("$baseName.zip") +$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("mf_export_" + [Guid]::NewGuid().ToString('N')) +if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null } +$stageDir = Join-Path $tempDir 'stage' +if (-not (Test-Path $stageDir)) { New-Item -ItemType Directory -Path $stageDir -Force | Out-Null } + +try { + if ($Clean) { + try { + Write-Host "Running 'flutter clean'..." -ForegroundColor Cyan + flutter clean | Out-Null + } catch { + Write-Warning "'flutter clean' failed or Flutter not in PATH. Continuing." + } + } + + Write-Host "Staging files to temp folder..." -ForegroundColor Cyan + if ($Minimal) { + # Copy a minimal set for code review in ChatGPT + $includeDirs = @('lib','test','assets') + foreach ($dir in $includeDirs) { + if (Test-Path $dir) { + Copy-Item $dir -Destination (Join-Path $stageDir $dir) -Recurse -Force + } + } + $rootFiles = @('pubspec.yaml','analysis_options.yaml','README.md') + foreach ($f in $rootFiles) { + if (Test-Path $f) { Copy-Item $f -Destination (Join-Path $stageDir $f) -Force } + } + if (Test-Path 'web') { + New-Item -ItemType Directory -Force -Path (Join-Path $stageDir 'web') | Out-Null + foreach ($wf in @('index.html','manifest.json')) { + if (Test-Path (Join-Path 'web' $wf)) { + Copy-Item (Join-Path 'web' $wf) -Destination (Join-Path $stageDir (Join-Path 'web' $wf)) -Force + } + } + } + } else { + # Use robocopy to copy everything except heavy/generated folders + $excludeDirs = @('build', '.dart_tool', 'ios\Pods', 'android\build', '.idea', '.vscode', 'web\build', 'android\.gradle', 'dist', 'tool', '.git', '.github') + $xdArgs = @() + foreach ($d in $excludeDirs) { $xdArgs += @('/XD', $d) } + + # robocopy exit codes: treat 0-7 as success + $robocopyCmd = @('robocopy', '.', $stageDir, '/E') + $xdArgs + $p = Start-Process -FilePath $robocopyCmd[0] -ArgumentList $robocopyCmd[1..($robocopyCmd.Length-1)] -Wait -PassThru -NoNewWindow + if ($p.ExitCode -gt 7) { + throw "Robocopy failed with exit code $($p.ExitCode)" + } + } + + # Option A: Single bundled text file (bypasses 10-file limit) + if ($Bundle) { + $bundlePath = Join-Path $OutDir ("$baseName-bundle.txt") + if (Test-Path $bundlePath) { Remove-Item $bundlePath -Force } + Write-Host "Creating bundled text: $bundlePath" -ForegroundColor Cyan + $textExt = @('.dart','.yaml','.yml','.md','.json','.html','.htm','.css','.js','.xml','.gradle','.properties','.kt','.swift','.plist','.sh','.bat','.ps1','.txt','.php') + $files = Get-ChildItem -Path $stageDir -Recurse -File | Where-Object { $textExt -contains ([IO.Path]::GetExtension($_.Name).ToLower()) } + foreach ($f in $files) { + $rel = $f.FullName.Substring($stageDir.Length + 1) + "===== BEGIN FILE: $rel =====" | Out-File -FilePath $bundlePath -Append -Encoding utf8 + try { (Get-Content -Raw -Path $f.FullName) | Out-File -FilePath $bundlePath -Append -Encoding utf8 } catch { "[binary or unreadable content omitted]" | Out-File -FilePath $bundlePath -Append -Encoding utf8 } + "\n===== END FILE =====\n" | Out-File -FilePath $bundlePath -Append -Encoding utf8 + } + Write-Host "Done. Upload this single file to your ChatGPT Project:" -ForegroundColor Green + Write-Host " $bundlePath" + } + elseif ($Chunk -gt 0) { + # Option B: Chunk into folder packs of N files + $outBase = Join-Path $OutDir $baseName + if (Test-Path $outBase) { Remove-Item $outBase -Recurse -Force } + New-Item -ItemType Directory -Path $outBase | Out-Null + $allFiles = Get-ChildItem -Path $stageDir -Recurse -File + if ($allFiles.Count -eq 0) { throw "No files found to export." } + $pack = 1 + $inPack = 0 + $packDir = Join-Path $outBase ("pack-" + $pack.ToString('000')) + New-Item -ItemType Directory -Path $packDir | Out-Null + foreach ($file in $allFiles) { + if ($inPack -ge $Chunk) { + $pack++ + $inPack = 0 + $packDir = Join-Path $outBase ("pack-" + $pack.ToString('000')) + New-Item -ItemType Directory -Path $packDir | Out-Null + } + $rel = $file.FullName.Substring($stageDir.Length + 1) + $destPath = Join-Path $packDir $rel + $destDir = Split-Path -Parent $destPath + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Copy-Item $file.FullName -Destination $destPath -Force + $inPack++ + } + Write-Host "Done. Upload each pack folder (<= $Chunk files) one by one:" -ForegroundColor Green + Write-Host " $outBase" + } + elseif ($AsFolder) { + $outFolder = Join-Path $OutDir $baseName + if (Test-Path $outFolder) { Remove-Item $outFolder -Recurse -Force } + Write-Host "Preparing folder for drag-and-drop: $outFolder" -ForegroundColor Cyan + if (-not (Test-Path $outFolder)) { New-Item -ItemType Directory -Path $outFolder | Out-Null } + Copy-Item (Join-Path $stageDir '*') -Destination $outFolder -Recurse -Force + Write-Host "Done. Drag-and-drop this folder into your ChatGPT Project:" -ForegroundColor Green + Write-Host " $outFolder" + } else { + Write-Host "Creating archive: $zipPath" -ForegroundColor Cyan + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $zipPath -Force + Write-Host "Done. Upload this file to your ChatGPT Project:" -ForegroundColor Green + Write-Host " $zipPath" + } +} finally { + if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ed88c34 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + hello_world + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..17f2843 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "hello_world", + "short_name": "hello_world", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}