init: Initial Flutter project setup with Android and iOS platform files.

This commit is contained in:
Michael committed 2025-12-09 08:40:50 +01:00
1 parent 0dd3edfc79
commit d0c5c4ff3a
73 files changed
+3277 -318

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# 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: "66dd93f9a27ffe2a9bfc8297506ce066ff51265f"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: android
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: ios
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
# 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'
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
Whitespace-only changes.
+44
View File
@@ -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.xtremflow"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.xtremflow"
// 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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+50
View File
@@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions for IPTV streaming -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="XtremFlow"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.xtremflow
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+616
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 = 13.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.xtremflow;
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.xtremflow.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.xtremflow.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.xtremflow.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 = 13.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 = 13.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.xtremflow;
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.xtremflow;
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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+13
View File
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -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.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Xtremflow</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>xtremflow</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+12
View File
@@ -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.
}
}
@@ -0,0 +1,471 @@
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../models/xtream_models.dart' as xm;
/// Xtream Codes API Service for Mobile (no dart:html dependency)
///
/// Handles all communication with Xtream API servers
class XtreamServiceMobile {
late final Dio _dio;
late final CacheOptions _cacheOptions;
PlaylistConfig? _currentPlaylist;
XtreamServiceMobile() {
_dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),);
// Setup caching for API responses
_cacheOptions = CacheOptions(
store: HiveCacheStore('./cache'),
policy: CachePolicy.forceCache,
maxStale: const Duration(hours: 1),
priority: CachePriority.high,
);
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
}
/// Initialize connection with a playlist
void setPlaylist(PlaylistConfig playlist) {
_currentPlaylist = playlist;
}
PlaylistConfig? get currentPlaylist => _currentPlaylist;
/// Generate direct stream URL for live TV (HLS format for mobile compatibility)
String getLiveStreamUrl(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Use .m3u8 format for HLS streaming (best mobile compatibility)
return '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8';
}
/// Generate stream URL for VOD (movies)
String getVodStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
}
/// Generate stream URL for series episodes
String getSeriesStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
}
/// Authenticate and get server info
Future<Map<String, dynamic>> authenticate() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
},
options: Options(extra: _cacheOptions.toExtra()),
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Authentication failed: $e');
}
}
/// Load categories mapping (category_id -> category_name)
Future<Map<String, String>> _getLiveCategories() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) {
categoryMap[id] = name;
}
}
return categoryMap;
} catch (e) {
return {}; // Return empty map on error, channels will be "Uncategorized"
}
}
/// Get all live TV channels grouped by category
Future<Map<String, List<Channel>>> getLiveChannels() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// First, load categories to get the mapping
final categoryMap = await _getLiveCategories();
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> streams = response.data as List<dynamic>;
final Map<String, List<Channel>> groupedChannels = {};
for (final streamData in streams) {
final data = streamData as Map<String, dynamic>;
// Get category name from our mapping using category_id
final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
// Inject category_name into data before parsing
data['category_name'] = categoryName;
final channel = Channel.fromJson(data);
if (!groupedChannels.containsKey(categoryName)) {
groupedChannels[categoryName] = [];
}
groupedChannels[categoryName]!.add(channel);
}
return groupedChannels;
} catch (e) {
throw Exception('Failed to fetch live channels: $e');
}
}
/// Load VOD categories mapping
Future<Map<String, String>> _getVodCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Load Series categories mapping
Future<Map<String, String>> _getSeriesCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load categories for mapping
final categoryMap = await _getVodCategories();
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>;
// Apply pagination
final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit;
if (offset >= allMovies.length) return [];
final paginatedMovies = allMovies.sublist(offset, endIndex);
return paginatedMovies.map((movieData) {
final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data);
}).toList();
} catch (e) {
throw Exception('Failed to fetch movies: $e');
}
}
/// Search movies in the entire catalogue
Future<List<xm.Movie>> searchMovies(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
final categoryMap = await _getVodCategories();
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allMovies
.where((m) {
final name = (m['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((movieData) {
final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load categories for mapping
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>;
// Apply pagination
final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit;
if (offset >= allSeries.length) return [];
final paginatedSeries = allSeries.sublist(offset, endIndex);
return paginatedSeries.map((seriesData) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data);
}).toList();
} catch (e) {
throw Exception('Failed to fetch series: $e');
}
}
/// Search series in the entire catalogue
Future<List<xm.Series>> searchSeries(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allSeries
.where((s) {
final name = (s['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((seriesData) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series info with seasons and episodes
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_info',
'series_id': seriesId,
},
options: Options(extra: _cacheOptions.toExtra()),
);
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to fetch series info: $e');
}
}
/// Get short EPG for a specific stream
Future<List<EpgEntry>> getShortEpg(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5), // EPG changes frequently
).toExtra(),
),
);
if (response.data == null || response.data['epg_listings'] == null) {
return [];
}
final List<dynamic> epgData = response.data['epg_listings'] as List<dynamic>;
return epgData
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
.toList();
} catch (e) {
// EPG is optional, don't throw on failure
return [];
}
}
/// Get short EPG as ShortEPG object (for EPGWidget)
Future<xm.ShortEPG> getShortEPG(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_currentPlaylist!.apiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5),
).toExtra(),
),
);
if (response.data == null) {
return const xm.ShortEPG();
}
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
// EPG is optional, don't throw on failure
return const xm.ShortEPG();
}
}
/// Dispose resources
void dispose() {
_dio.close();
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'core/theme/app_theme.dart';
import 'core/providers/theme_provider.dart';
import 'mobile/features/iptv/screens/mobile_playlist_screen.dart';
import 'mobile/features/iptv/screens/mobile_dashboard_screen.dart';
import 'core/models/playlist_config.dart';
/// Mobile-optimized entry point for XtremFlow IPTV
/// No authentication - direct access to playlist selection
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive for local storage
await Hive.initFlutter();
// Register adapters for Hive
if (!Hive.isAdapterRegistered(1)) {
Hive.registerAdapter(PlaylistConfigAdapter());
}
runApp(const ProviderScope(child: MobileApp()));
}
class MobileApp extends ConsumerWidget {
const MobileApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeState = ref.watch(themeProvider);
return MaterialApp(
title: 'XtremFlow IPTV',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeState.themeMode,
// Start directly at playlist selection (no auth)
home: const MobilePlaylistScreen(),
onGenerateRoute: (settings) {
// Handle navigation to dashboard with playlist
if (settings.name == '/dashboard') {
final playlist = settings.arguments as PlaylistConfig?;
if (playlist != null) {
return MaterialPageRoute(
builder: (context) => MobileDashboardScreen(playlist: playlist),
);
}
}
return null;
},
);
}
}
@@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
import '../../../services/local_playlist_service.dart';
import 'mobile_dashboard_screen.dart';
/// Provider for local playlists
final localPlaylistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
final service = LocalPlaylistService();
return service.getPlaylists();
});
/// Mobile playlist selection screen - simplified without auth
class MobilePlaylistScreen extends ConsumerStatefulWidget {
const MobilePlaylistScreen({super.key});
@override
ConsumerState<MobilePlaylistScreen> createState() => _MobilePlaylistScreenState();
}
class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
final LocalPlaylistService _playlistService = LocalPlaylistService();
@override
void initState() {
super.initState();
_playlistService.init();
}
void _refreshPlaylists() {
ref.invalidate(localPlaylistsProvider);
}
void _navigateToDashboard(PlaylistConfig playlist) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MobileDashboardScreen(playlist: playlist),
),
);
}
void _showAddPlaylistDialog() {
final nameController = TextEditingController();
final dnsController = TextEditingController();
final usernameController = TextEditingController();
final passwordController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.textTertiary,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
// Title
const Text(
'Ajouter une playlist',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 24),
// Form fields
_buildTextField(
controller: nameController,
label: 'Nom',
hint: 'Ma Playlist IPTV',
icon: Icons.label_outline,
),
const SizedBox(height: 16),
_buildTextField(
controller: dnsController,
label: 'Serveur URL',
hint: 'http://example.com:8080',
icon: Icons.dns_outlined,
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
_buildTextField(
controller: usernameController,
label: 'Nom d\'utilisateur',
hint: 'username',
icon: Icons.person_outline,
),
const SizedBox(height: 16),
_buildTextField(
controller: passwordController,
label: 'Mot de passe',
hint: '••••••••',
icon: Icons.lock_outline,
obscureText: true,
),
const SizedBox(height: 32),
// Save button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
if (nameController.text.isEmpty ||
dnsController.text.isEmpty ||
usernameController.text.isEmpty ||
passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez remplir tous les champs'),
backgroundColor: AppColors.error,
),
);
return;
}
await _playlistService.addPlaylist(
name: nameController.text,
dns: dnsController.text,
username: usernameController.text,
password: passwordController.text,
);
if (mounted) {
Navigator.pop(context);
_refreshPlaylists();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Playlist ajoutée avec succès'),
backgroundColor: Colors.green,
),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
bool obscureText = false,
TextInputType? keyboardType,
}) {
return TextField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon, color: AppColors.textSecondary),
labelStyle: const TextStyle(color: AppColors.textSecondary),
hintStyle: const TextStyle(color: AppColors.textTertiary),
filled: true,
fillColor: AppColors.background,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
),
);
}
void _showDeleteConfirmation(PlaylistConfig playlist) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Supprimer la playlist ?',
style: TextStyle(color: AppColors.textPrimary)),
content: Text('Voulez-vous supprimer "${playlist.name}" ?',
style: const TextStyle(color: AppColors.textSecondary)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
await _playlistService.deletePlaylist(playlist.id);
if (mounted) {
Navigator.pop(context);
_refreshPlaylists();
}
},
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final playlistsAsync = ref.watch(localPlaylistsProvider);
return Theme(
data: MobileTheme.darkTheme,
child: Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('XtremFlow'),
centerTitle: true,
backgroundColor: AppColors.background,
elevation: 0,
),
body: playlistsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
const Text('Erreur de chargement',
style: TextStyle(color: AppColors.textPrimary)),
const SizedBox(height: 8),
Text(error.toString(),
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
TextButton.icon(
onPressed: _refreshPlaylists,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.playlist_add,
size: 40,
color: AppColors.textTertiary,
),
),
const SizedBox(height: 24),
const Text(
'Aucune playlist',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Ajoutez votre première playlist IPTV',
style: TextStyle(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _showAddPlaylistDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter une playlist'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: playlists.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final playlist = playlists[index];
return _PlaylistCard(
playlist: playlist,
onTap: () => _navigateToDashboard(playlist),
onLongPress: () => _showDeleteConfirmation(playlist),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddPlaylistDialog,
backgroundColor: AppColors.primary,
child: const Icon(Icons.add),
),
),
);
}
}
class _PlaylistCard extends StatelessWidget {
final PlaylistConfig playlist;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _PlaylistCard({
required this.playlist,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border.withOpacity(0.3)),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.playlist_play,
color: AppColors.primary,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
Uri.tryParse(playlist.dns)?.host ?? playlist.dns,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppColors.textTertiary,
),
],
),
),
),
),
);
}
}
@@ -4,9 +4,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../providers/mobile_xtream_providers.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart'; import '../../../providers/mobile_settings_providers.dart';
import 'mobile_player_screen.dart'; import 'native_player_screen.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
class MobileSeriesDetailScreen extends ConsumerStatefulWidget { class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
@@ -51,8 +51,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
_error = null; _error = null;
}); });
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(mobileXtreamServiceProvider(widget.playlist));
final info = await service.getSeriesInfo(widget.series.seriesId); final info = await service.getSeriesInfo(widget.series.seriesId.toString());
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -254,8 +254,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
} }
Widget _buildEpisodeTile(Episode episode) { Widget _buildEpisodeTile(Episode episode) {
final watchHistory = ref.watch(watchHistoryProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider);
final episodeKey = WatchHistory.episodeKey( final episodeKey = MobileWatchHistory.episodeKey(
widget.series.seriesId, widget.series.seriesId,
_selectedSeason, _selectedSeason,
episode.episodeNum, episode.episodeNum,
@@ -264,11 +264,11 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
return InkWell( return InkWell(
onTap: () { onTap: () {
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey); ref.read(mobileWatchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => NativePlayerScreen(
streamId: episode.id, streamId: episode.id,
title: '${widget.series.name} - ${episode.title}', title: '${widget.series.name} - ${episode.title}',
playlist: widget.playlist, playlist: widget.playlist,
@@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../features/iptv/services/xtream_service_mobile.dart';
/// Stream type enum for player
enum StreamType { live, vod, series }
/// Native video player screen for Android/iOS
/// Uses video_player + chewie for HLS/MP4 playback
class NativePlayerScreen extends ConsumerStatefulWidget {
final String streamId;
final String title;
final StreamType streamType;
final String containerExtension;
final PlaylistConfig playlist;
final List<Channel>? channels;
final int initialIndex;
const NativePlayerScreen({
super.key,
required this.streamId,
required this.title,
required this.playlist,
this.streamType = StreamType.live,
this.containerExtension = 'mp4',
this.channels,
this.initialIndex = 0,
});
@override
ConsumerState<NativePlayerScreen> createState() => _NativePlayerScreenState();
}
class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
VideoPlayerController? _videoController;
ChewieController? _chewieController;
bool _isLoading = true;
String? _errorMessage;
late int _currentIndex;
late XtreamServiceMobile _xtreamService;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_xtreamService = XtreamServiceMobile();
_xtreamService.setPlaylist(widget.playlist);
// Lock to landscape for video playback
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_initializePlayer();
}
Future<void> _initializePlayer() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Dispose previous controllers if switching channels
await _disposeControllers();
// Determine effective Stream ID
final currentStreamId = widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].streamId
: widget.streamId;
// Build stream URL based on type
String streamUrl;
if (widget.streamType == StreamType.live) {
// Live TV - use HLS format for best mobile compatibility
streamUrl = _xtreamService.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == StreamType.vod) {
// Movies
streamUrl = _xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension);
} else {
// Series
streamUrl = _xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension);
}
debugPrint('NativePlayer: Loading stream: $streamUrl');
// Initialize video controller
_videoController = VideoPlayerController.networkUrl(
Uri.parse(streamUrl),
httpHeaders: {
'User-Agent': 'XtremFlow/1.0',
},
);
await _videoController!.initialize();
// Initialize chewie controller with custom options
_chewieController = ChewieController(
videoPlayerController: _videoController!,
autoPlay: true,
looping: widget.streamType == StreamType.live, // Loop live streams
allowFullScreen: true,
allowMuting: true,
showControls: true,
showControlsOnInitialize: false,
hideControlsTimer: const Duration(seconds: 3),
materialProgressColors: ChewieProgressColors(
playedColor: AppColors.primary,
handleColor: AppColors.primary,
backgroundColor: Colors.white24,
bufferedColor: Colors.white38,
),
placeholder: Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
),
errorBuilder: (context, errorMessage) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
'Erreur de lecture',
style: const TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 8),
Text(
errorMessage,
style: const TextStyle(color: Colors.white70, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
);
},
);
setState(() {
_isLoading = false;
});
} catch (e) {
debugPrint('NativePlayer error: $e');
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
Future<void> _disposeControllers() async {
_chewieController?.dispose();
_chewieController = null;
await _videoController?.dispose();
_videoController = null;
}
void _playNext() {
if (widget.channels == null || widget.channels!.isEmpty) return;
final nextIndex = (_currentIndex + 1) % widget.channels!.length;
_switchChannel(nextIndex);
}
void _playPrevious() {
if (widget.channels == null || widget.channels!.isEmpty) return;
final prevIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length;
_switchChannel(prevIndex);
}
void _switchChannel(int index) {
setState(() {
_currentIndex = index;
});
_initializePlayer();
}
@override
void dispose() {
_disposeControllers();
_xtreamService.dispose();
// Restore normal orientation
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
final title = widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].name
: widget.title;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Video Player
if (_errorMessage != null)
_buildErrorView()
else if (_isLoading)
_buildLoadingView()
else if (_chewieController != null)
Center(
child: Chewie(controller: _chewieController!),
)
else
_buildLoadingView(),
// Custom top bar for channel switching (live TV only)
if (widget.channels != null && widget.channels!.isNotEmpty)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.skip_previous, color: Colors.white),
onPressed: _playPrevious,
),
IconButton(
icon: const Icon(Icons.skip_next, color: Colors.white),
onPressed: _playNext,
),
],
),
),
),
),
// Back button for VOD/Series (no channel switching)
if (widget.channels == null || widget.channels!.isEmpty)
Positioned(
top: 0,
left: 0,
child: SafeArea(
child: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
),
),
],
),
);
}
Widget _buildLoadingView() {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.primary),
SizedBox(height: 16),
Text(
'Chargement...',
style: TextStyle(color: Colors.white70),
),
],
),
);
}
Widget _buildErrorView() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text(
'Erreur de lecture',
style: TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Une erreur inconnue est survenue',
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Retour'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _initializePlayer,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
),
),
],
),
],
),
);
}
}
@@ -1,15 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../providers/mobile_xtream_providers.dart';
import '../../../../features/iptv/providers/settings_provider.dart'; import '../../../providers/mobile_settings_providers.dart';
import '../../../../features/iptv/providers/favorites_provider.dart'; import '../screens/native_player_screen.dart';
import '../screens/mobile_player_screen.dart';
import '../../../../core/models/iptv_models.dart'; import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart'; import '../../../theme/mobile_theme.dart';
import '../../../../core/widgets/components/ui_components.dart'; // For images/CachedNetworkImage if needed
class MobileLiveTVTab extends ConsumerStatefulWidget { class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -43,9 +41,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync = ref.watch(mobileLiveChannelsProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider); final favorites = ref.watch(mobileFavoritesProvider);
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
@@ -216,7 +214,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
void _playChannel(BuildContext context, Channel channel) { void _playChannel(BuildContext context, Channel channel) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => NativePlayerScreen(
streamId: channel.streamId, streamId: channel.streamId,
title: channel.name, title: channel.name,
playlist: widget.playlist, playlist: widget.playlist,
@@ -235,8 +233,9 @@ class _MobileChannelTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// On mobile, use direct URL (no proxy needed)
final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}' ? channel.streamIcon
: null; : null;
return InkWell( return InkWell(
@@ -5,11 +5,10 @@ import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/components/hero_carousel.dart'; import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart'; import '../../../../core/widgets/components/ui_components.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart'; import '../../../providers/mobile_settings_providers.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../providers/mobile_xtream_providers.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../../features/iptv/services/xtream_service_mobile.dart';
import '../../../../features/iptv/providers/settings_provider.dart'; import '../screens/native_player_screen.dart';
import '../screens/mobile_player_screen.dart';
class MobileMoviesTab extends ConsumerStatefulWidget { class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -78,7 +77,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(mobileXtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query); final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
@@ -99,7 +98,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(mobileXtreamServiceProvider(widget.playlist));
final newMovies = await service.getMoviesPaginated( final newMovies = await service.getMoviesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, limit: _pageSize,
@@ -129,22 +128,19 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues /// On mobile, use direct URLs (no proxy needed)
String _getProxiedImageUrl(String? originalUrl) { String _getImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl; return originalUrl;
} }
void _playMovie(Movie movie) { void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); ref.read(mobileWatchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => NativePlayerScreen(
streamId: movie.streamId, streamId: movie.streamId,
title: movie.name, title: movie.name,
playlist: widget.playlist, playlist: widget.playlist,
@@ -157,8 +153,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider);
List<Movie> displayMovies; List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
@@ -173,7 +169,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
final heroItems = displayMovies.take(3).map((m) => HeroItem( final heroItems = displayMovies.take(3).map((m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon), imageUrl: _getImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m),
)).toList(); )).toList();
@@ -264,14 +260,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return MediaCard( return MediaCard(
title: movie.name, title: movie.name,
imageUrl: _getProxiedImageUrl(movie.streamIcon), imageUrl: _getImageUrl(movie.streamIcon),
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
rating: _formatRating(movie.rating), rating: _formatRating(movie.rating),
isWatched: isWatched, isWatched: isWatched,
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie),
onLongPress: () { onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); ref.read(mobileWatchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
}, },
); );
}, },
@@ -3,12 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/components/hero_carousel.dart'; import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart'; import '../../../../core/widgets/components/ui_components.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../providers/mobile_settings_providers.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../providers/mobile_xtream_providers.dart';
import '../../../../features/iptv/providers/settings_provider.dart'; import '../../../../features/iptv/services/xtream_service_mobile.dart';
import '../screens/mobile_series_detail_screen.dart'; import '../screens/mobile_series_detail_screen.dart';
class MobileSeriesTab extends ConsumerStatefulWidget { class MobileSeriesTab extends ConsumerStatefulWidget {
@@ -78,7 +77,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(mobileXtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query); final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
@@ -99,7 +98,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(mobileXtreamServiceProvider(widget.playlist));
final newSeries = await service.getSeriesPaginated( final newSeries = await service.getSeriesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, limit: _pageSize,
@@ -129,12 +128,9 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues /// On mobile, use direct URLs (no proxy needed)
String _getProxiedImageUrl(String? originalUrl) { String _getImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl; return originalUrl;
} }
@@ -152,7 +148,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
List<Series> displaySeries; List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
@@ -167,7 +163,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
final heroItems = displaySeries.take(3).map((s) => HeroItem( final heroItems = displaySeries.take(3).map((s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
imageUrl: _getProxiedImageUrl(s.cover), imageUrl: _getImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
)).toList(); )).toList();
@@ -265,7 +261,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return MediaCard( return MediaCard(
title: series.name, title: series.name,
imageUrl: _getProxiedImageUrl(series.cover), imageUrl: _getImageUrl(series.cover),
subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null, subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null,
rating: _formatRating(series.rating), rating: _formatRating(series.rating),
@@ -1,12 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../../features/auth/providers/auth_provider.dart'; import '../../../providers/mobile_settings_providers.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../core/providers/theme_provider.dart'; import '../../../../core/providers/theme_provider.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
/// Simplified mobile settings tab - no auth, local storage only
class MobileSettingsTab extends ConsumerStatefulWidget { class MobileSettingsTab extends ConsumerStatefulWidget {
const MobileSettingsTab({super.key}); const MobileSettingsTab({super.key});
@@ -36,23 +35,22 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
super.dispose(); super.dispose();
} }
void _syncControllers(IptvSettings settings) { void _syncControllers(MobileSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { if (!_initialized) {
_liveTvController.text = settings.liveTvCategoryFilter; _liveTvController.text = settings.liveTvKeywords.join(', ');
} _moviesController.text = settings.moviesKeywords.join(', ');
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { _seriesController.text = settings.seriesKeywords.join(', ');
_moviesController.text = settings.moviesCategoryFilter;
}
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter;
}
_initialized = true; _initialized = true;
} }
}
List<String> _parseKeywords(String text) {
return text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser; final settings = ref.watch(mobileSettingsProvider);
final settings = ref.watch(iptvSettingsProvider);
final themeState = ref.watch(themeProvider); final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier); final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -69,12 +67,14 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
Padding( Padding(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: Text( child: Text(
'Settings', 'Paramètres',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.textPrimary,
),
), ),
), ),
// User Profile // App Info
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -83,35 +83,36 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
), ),
child: Row( child: Row(
children: [ children: [
CircleAvatar( Container(
backgroundColor: AppColors.primary.withOpacity(0.2), width: 48,
child: Text( height: 48,
currentUser?.username.substring(0, 1).toUpperCase() ?? 'U', decoration: BoxDecoration(
style: const TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary), color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 28),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( const Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
currentUser?.username ?? 'Guest', 'XtremFlow Mobile',
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
if (currentUser?.isAdmin ?? false) SizedBox(height: 4),
Container( Text(
margin: const EdgeInsets.only(top: 4), 'Version 1.0.0 (Local)',
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), style: TextStyle(
decoration: BoxDecoration( fontSize: 13,
color: AppColors.primary.withOpacity(0.2), color: AppColors.textSecondary,
borderRadius: BorderRadius.circular(4),
), ),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.bold)),
), ),
], ],
), ),
@@ -122,108 +123,26 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Appearance // Appearance
const _SectionHeader(title: 'Appearance'), const _SectionHeader(title: 'Apparence'),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: SwitchListTile(
children: [ title: const Text('Mode Sombre',
SwitchListTile( style: TextStyle(color: AppColors.textPrimary)),
title: const Text('Dark Mode', style: TextStyle(color: AppColors.textPrimary)), secondary: Icon(isDark ? Icons.dark_mode : Icons.light_mode,
secondary: Icon(isDark ? Icons.dark_mode : Icons.light_mode, color: AppColors.primary), color: AppColors.primary),
value: isDark, value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(), onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary, activeColor: AppColors.primary,
), ),
const Divider(height: 1),
SwitchListTile(
title: const Text('Show Clock', style: TextStyle(color: AppColors.textPrimary)),
secondary: const Icon(Icons.access_time, color: AppColors.primary),
value: settings.showClock,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setShowClock(val),
activeColor: AppColors.primary,
),
],
),
),
const SizedBox(height: 24),
// Streaming Settings
const _SectionHeader(title: 'Streaming'),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
_DropdownSetting<StreamQuality>(
label: 'Quality',
icon: Icons.high_quality,
value: settings.streamQuality,
items: StreamQuality.values,
labelBuilder: (v) => v.name.toUpperCase(),
onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setStreamQuality(v);
},
),
const Divider(height: 1),
_DropdownSetting<BufferSize>(
label: 'Buffer Size',
icon: Icons.memory,
value: settings.bufferSize,
items: BufferSize.values,
labelBuilder: (v) => v.name.toUpperCase(),
onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setBufferSize(v);
},
),
const Divider(height: 1),
_DropdownSetting<ConnectionTimeout>(
label: 'Timeout',
icon: Icons.timer,
value: settings.connectionTimeout,
items: ConnectionTimeout.values,
labelBuilder: (v) => '${v.name.toUpperCase()} (${ref.read(iptvSettingsProvider).timeoutSeconds}s)',
onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setConnectionTimeout(v);
},
),
const Divider(height: 1),
_DropdownSetting<TranscodingMode>(
label: 'Mode',
icon: Icons.settings_input_component,
value: settings.transcodingMode,
items: TranscodingMode.values,
labelBuilder: (v) {
switch (v) {
case TranscodingMode.auto: return 'AUTO';
case TranscodingMode.forced: return 'TRANSCODE';
case TranscodingMode.disabled: return 'DIRECT';
}
},
onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setTranscodingMode(v);
},
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Auto Reconnect', style: TextStyle(color: AppColors.textPrimary)),
secondary: const Icon(Icons.refresh, color: AppColors.primary),
value: settings.autoReconnect,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setAutoReconnect(val),
activeColor: AppColors.primary,
),
],
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Content Filters // Content Filters
const _SectionHeader(title: 'Content Filters'), const _SectionHeader(title: 'Filtres de Contenu'),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
@@ -231,59 +150,82 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
), ),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text(
'Utilisez des mots-clés pour filtrer les catégories (séparés par des virgules)',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
_FilterInput( _FilterInput(
label: 'Live TV Keywords', label: 'TV en Direct',
hint: 'Ex: FR, HD, SPORT',
icon: Icons.live_tv, icon: Icons.live_tv,
controller: _liveTvController, controller: _liveTvController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(val), onChanged: (val) {
ref.read(mobileSettingsProvider.notifier)
.setLiveTvKeywords(_parseKeywords(val));
},
), ),
const Divider(height: 24), const SizedBox(height: 16),
_FilterInput( _FilterInput(
label: 'Movies Keywords', label: 'Films',
hint: 'Ex: FR, VF, 4K',
icon: Icons.movie, icon: Icons.movie,
controller: _moviesController, controller: _moviesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setMoviesFilter(val), onChanged: (val) {
ref.read(mobileSettingsProvider.notifier)
.setMoviesKeywords(_parseKeywords(val));
},
), ),
const Divider(height: 24), const SizedBox(height: 16),
_FilterInput( _FilterInput(
label: 'Series Keywords', label: 'Séries',
hint: 'Ex: FR, VF',
icon: Icons.tv, icon: Icons.tv,
controller: _seriesController, controller: _seriesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setSeriesFilter(val), onChanged: (val) {
ref.read(mobileSettingsProvider.notifier)
.setSeriesKeywords(_parseKeywords(val));
},
), ),
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Actions // Change Playlist
if (currentUser?.isAdmin ?? false) InkWell(
_SettingsButton( onTap: () {
icon: Icons.admin_panel_settings, // Navigate back to playlist selection
label: 'Admin Panel', Navigator.of(context).popUntil((route) => route.isFirst);
onTap: () => context.go('/admin'),
),
const SizedBox(height: 12),
_SettingsButton(
icon: Icons.playlist_play,
label: 'Change Playlist',
onTap: () => context.go('/playlists'),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
}, },
style: FilledButton.styleFrom( borderRadius: BorderRadius.circular(12),
backgroundColor: AppColors.error.withOpacity(0.1), child: Container(
foregroundColor: AppColors.error, padding: const EdgeInsets.all(16),
minimumSize: const Size(double.infinity, 50), decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
children: [
Icon(Icons.playlist_play, color: AppColors.textPrimary),
SizedBox(width: 16),
Text(
'Changer de Playlist',
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
Spacer(),
Icon(Icons.chevron_right, color: AppColors.textSecondary),
],
),
), ),
icon: const Icon(Icons.logout),
label: const Text('Logout'),
), ),
const SizedBox(height: 100), const SizedBox(height: 100),
], ],
@@ -314,12 +256,14 @@ class _SectionHeader extends StatelessWidget {
class _FilterInput extends StatelessWidget { class _FilterInput extends StatelessWidget {
final String label; final String label;
final String hint;
final IconData icon; final IconData icon;
final TextEditingController controller; final TextEditingController controller;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _FilterInput({ const _FilterInput({
required this.label, required this.label,
required this.hint,
required this.icon, required this.icon,
required this.controller, required this.controller,
required this.onChanged, required this.onChanged,
@@ -327,126 +271,42 @@ class _FilterInput extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
children: [
Icon(icon, size: 20, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), Row(
children: [
Icon(icon, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
Text(label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 8),
TextField( TextField(
controller: controller, controller: controller,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 4), hintText: hint,
border: InputBorder.none, hintStyle: const TextStyle(color: AppColors.textTertiary),
hintText: 'e.g. FR, HD', filled: true,
fillColor: AppColors.background,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
), ),
onChanged: onChanged, onChanged: onChanged,
), ),
], ],
),
),
],
);
}
}
class _SettingsButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _SettingsButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: AppColors.textPrimary),
const SizedBox(width: 16),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(),
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
],
),
),
);
}
}
class _DropdownSetting<T> extends StatelessWidget {
final String label;
final IconData icon;
final T value;
final List<T> items;
final String Function(T) labelBuilder;
final ValueChanged<T?> onChanged;
const _DropdownSetting({
required this.label,
required this.icon,
required this.value,
required this.items,
required this.labelBuilder,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Icon(icon, color: AppColors.primary),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
),
),
),
DropdownButton<T>(
value: value,
underline: const SizedBox(),
dropdownColor: AppColors.surfaceVariant,
icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary),
style: const TextStyle(color: AppColors.textPrimary),
items: items.map((item) {
return DropdownMenuItem<T>(
value: item,
child: Text(labelBuilder(item)),
);
}).toList(),
onChanged: onChanged,
),
],
),
); );
} }
} }
@@ -0,0 +1,206 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// Mobile-specific settings (stored locally in Hive)
/// Simplified version without server API dependencies
class MobileSettings {
final List<String> liveTvKeywords;
final List<String> moviesKeywords;
final List<String> seriesKeywords;
const MobileSettings({
this.liveTvKeywords = const [],
this.moviesKeywords = const [],
this.seriesKeywords = const [],
});
MobileSettings copyWith({
List<String>? liveTvKeywords,
List<String>? moviesKeywords,
List<String>? seriesKeywords,
}) {
return MobileSettings(
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
moviesKeywords: moviesKeywords ?? this.moviesKeywords,
seriesKeywords: seriesKeywords ?? this.seriesKeywords,
);
}
/// Check if category matches Live TV filter
bool matchesLiveTvFilter(String? category) {
if (liveTvKeywords.isEmpty) return true;
if (category == null) return true;
final lowerCategory = category.toLowerCase();
return liveTvKeywords.any((keyword) =>
lowerCategory.contains(keyword.toLowerCase()));
}
/// Check if category matches Movies filter
bool matchesMoviesFilter(String? category) {
if (moviesKeywords.isEmpty) return true;
if (category == null) return true;
final lowerCategory = category.toLowerCase();
return moviesKeywords.any((keyword) =>
lowerCategory.contains(keyword.toLowerCase()));
}
/// Check if category matches Series filter
bool matchesSeriesFilter(String? category) {
if (seriesKeywords.isEmpty) return true;
if (category == null) return true;
final lowerCategory = category.toLowerCase();
return seriesKeywords.any((keyword) =>
lowerCategory.contains(keyword.toLowerCase()));
}
}
class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
static const String _boxName = 'mobile_settings';
Box? _box;
MobileSettingsNotifier() : super(const MobileSettings()) {
_init();
}
Future<void> _init() async {
_box = await Hive.openBox(_boxName);
final liveTv = _box?.get('liveTvKeywords', defaultValue: <String>[]) as List?;
final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
final series = _box?.get('seriesKeywords', defaultValue: <String>[]) as List?;
state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [],
moviesKeywords: movies?.cast<String>() ?? [],
seriesKeywords: series?.cast<String>() ?? [],
);
}
void setLiveTvKeywords(List<String> keywords) {
state = state.copyWith(liveTvKeywords: keywords);
_box?.put('liveTvKeywords', keywords);
}
void setMoviesKeywords(List<String> keywords) {
state = state.copyWith(moviesKeywords: keywords);
_box?.put('moviesKeywords', keywords);
}
void setSeriesKeywords(List<String> keywords) {
state = state.copyWith(seriesKeywords: keywords);
_box?.put('seriesKeywords', keywords);
}
}
final mobileSettingsProvider = StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) {
return MobileSettingsNotifier();
});
/// Watch history for mobile (stored locally)
class MobileWatchHistory {
final Set<String> watchedMovies;
final Set<String> watchedEpisodes;
const MobileWatchHistory({
this.watchedMovies = const {},
this.watchedEpisodes = const {},
});
MobileWatchHistory copyWith({
Set<String>? watchedMovies,
Set<String>? watchedEpisodes,
}) {
return MobileWatchHistory(
watchedMovies: watchedMovies ?? this.watchedMovies,
watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes,
);
}
bool isMovieWatched(String streamId) => watchedMovies.contains(streamId);
bool isEpisodeWatched(String episodeKey) => watchedEpisodes.contains(episodeKey);
static String episodeKey(dynamic seriesId, int season, int episodeNum) {
return '$seriesId:$season:$episodeNum';
}
}
class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
static const String _boxName = 'mobile_watch_history';
Box? _box;
MobileWatchHistoryNotifier() : super(const MobileWatchHistory()) {
_init();
}
Future<void> _init() async {
_box = await Hive.openBox(_boxName);
final movies = _box?.get('watchedMovies', defaultValue: <String>[]) as List?;
final episodes = _box?.get('watchedEpisodes', defaultValue: <String>[]) as List?;
state = MobileWatchHistory(
watchedMovies: movies?.cast<String>().toSet() ?? {},
watchedEpisodes: episodes?.cast<String>().toSet() ?? {},
);
}
void markMovieWatched(String streamId) {
final updated = {...state.watchedMovies, streamId};
state = state.copyWith(watchedMovies: updated);
_box?.put('watchedMovies', updated.toList());
}
void toggleMovieWatched(String streamId) {
final movies = {...state.watchedMovies};
if (movies.contains(streamId)) {
movies.remove(streamId);
} else {
movies.add(streamId);
}
state = state.copyWith(watchedMovies: movies);
_box?.put('watchedMovies', movies.toList());
}
void markEpisodeWatched(String episodeKey) {
final updated = {...state.watchedEpisodes, episodeKey};
state = state.copyWith(watchedEpisodes: updated);
_box?.put('watchedEpisodes', updated.toList());
}
}
final mobileWatchHistoryProvider = StateNotifierProvider<MobileWatchHistoryNotifier, MobileWatchHistory>((ref) {
return MobileWatchHistoryNotifier();
});
/// Favorites for mobile (stored locally)
class MobileFavoritesNotifier extends StateNotifier<Set<String>> {
static const String _boxName = 'mobile_favorites';
Box? _box;
MobileFavoritesNotifier() : super({}) {
_init();
}
Future<void> _init() async {
_box = await Hive.openBox(_boxName);
final favorites = _box?.get('favorites', defaultValue: <String>[]) as List?;
state = favorites?.cast<String>().toSet() ?? {};
}
void toggle(String streamId) {
final updated = {...state};
if (updated.contains(streamId)) {
updated.remove(streamId);
} else {
updated.add(streamId);
}
state = updated;
_box?.put('favorites', updated.toList());
}
bool isFavorite(String streamId) => state.contains(streamId);
}
final mobileFavoritesProvider = StateNotifierProvider<MobileFavoritesNotifier, Set<String>>((ref) {
return MobileFavoritesNotifier();
});
@@ -0,0 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' show Channel;
import '../../features/iptv/services/xtream_service_mobile.dart';
import '../../features/iptv/models/xtream_models.dart';
export '../../features/iptv/models/xtream_models.dart';
/// Mobile-specific Xtream service provider (no dart:html dependency)
final mobileXtreamServiceProvider = Provider.family<XtreamServiceMobile, PlaylistConfig>((ref, playlist) {
final service = XtreamServiceMobile();
service.setPlaylist(playlist);
return service;
});
/// Mobile-specific live channels provider
final mobileLiveChannelsProvider = FutureProvider.family<Map<String, List<Channel>>, PlaylistConfig>((ref, playlist) async {
final service = ref.read(mobileXtreamServiceProvider(playlist));
return service.getLiveChannels();
});
/// Mobile-specific movies pagination provider
final mobileMoviesProvider = FutureProvider.family<List<Movie>, PlaylistConfig>((ref, playlist) async {
final service = ref.read(mobileXtreamServiceProvider(playlist));
return service.getMoviesPaginated(offset: 0, limit: 100);
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider = FutureProvider.family<List<Series>, PlaylistConfig>((ref, playlist) async {
final service = ref.read(mobileXtreamServiceProvider(playlist));
return service.getSeriesPaginated(offset: 0, limit: 100);
});
/// Mobile-specific series info provider
final mobileSeriesInfoProvider = FutureProvider.family<SeriesInfo, String>((ref, seriesId) {
throw UnimplementedError('Use mobileSeriesInfoByPlaylistProvider instead');
});
/// Series info with playlist context
class SeriesInfoRequest {
final PlaylistConfig playlist;
final String seriesId;
SeriesInfoRequest({required this.playlist, required this.seriesId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SeriesInfoRequest &&
runtimeType == other.runtimeType &&
playlist == other.playlist &&
seriesId == other.seriesId;
@override
int get hashCode => playlist.hashCode ^ seriesId.hashCode;
}
final mobileSeriesInfoByPlaylistProvider = FutureProvider.family<SeriesInfo, SeriesInfoRequest>((ref, request) async {
final service = ref.read(mobileXtreamServiceProvider(request.playlist));
return service.getSeriesInfo(request.seriesId);
});
@@ -0,0 +1,92 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import '../../core/models/playlist_config.dart';
/// Local playlist storage service for mobile
/// Uses Hive for persistent local storage
class LocalPlaylistService {
static const String _boxName = 'playlists_mobile';
Box<PlaylistConfig>? _box;
/// Initialize the service and open the Hive box
Future<void> init() async {
if (_box != null && _box!.isOpen) return;
// Register adapter if not already registered
if (!Hive.isAdapterRegistered(1)) {
Hive.registerAdapter(PlaylistConfigAdapter());
}
_box = await Hive.openBox<PlaylistConfig>(_boxName);
}
/// Get all playlists
Future<List<PlaylistConfig>> getPlaylists() async {
await init();
return _box!.values.toList();
}
/// Add a new playlist
Future<PlaylistConfig> addPlaylist({
required String name,
required String dns,
required String username,
required String password,
}) async {
await init();
final playlist = PlaylistConfig(
id: const Uuid().v4(),
name: name,
dns: dns.endsWith('/') ? dns.substring(0, dns.length - 1) : dns,
username: username,
password: password,
createdAt: DateTime.now(),
);
await _box!.put(playlist.id, playlist);
return playlist;
}
/// Update an existing playlist
Future<PlaylistConfig?> updatePlaylist({
required String id,
required String name,
required String dns,
required String username,
required String password,
}) async {
await init();
final existing = _box!.get(id);
if (existing == null) return null;
final updated = existing.copyWith(
name: name,
dns: dns.endsWith('/') ? dns.substring(0, dns.length - 1) : dns,
username: username,
password: password,
);
await _box!.put(id, updated);
return updated;
}
/// Delete a playlist
Future<bool> deletePlaylist(String id) async {
await init();
await _box!.delete(id);
return true;
}
/// Get a single playlist by ID
Future<PlaylistConfig?> getPlaylist(String id) async {
await init();
return _box!.get(id);
}
/// Close the box when done
Future<void> dispose() async {
await _box?.close();
}
}
+30
View File
@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremflow/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}