feat: initial commit for BingeStats Android app (Kotlin + Compose + TMDB + Multi-Provider architecture)
This commit is contained in:
commit
dce3b2258b
35 changed files with 2860 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/app/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
*.apk
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
77
app/build.gradle.kts
Normal file
77
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.bingestats.app"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.bingestats.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.11"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.webkit)
|
||||||
|
|
||||||
|
// Room DB
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
// Network & JSON for TMDB API
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
29
app/src/main/AndroidManifest.xml
Normal file
29
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Permissions required for web sync, TMDB API, and file import -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".BingeStatsApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="BingeStats"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@android:style/Theme.Material.NoTitleBar"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Material.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
20
app/src/main/java/com/bingestats/app/BingeStatsApp.kt
Normal file
20
app/src/main/java/com/bingestats/app/BingeStatsApp.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.bingestats.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bingestats.app.data.db.ViewingDatabase
|
||||||
|
import com.bingestats.app.data.repository.StatsRepository
|
||||||
|
|
||||||
|
class BingeStatsApp : Application() {
|
||||||
|
|
||||||
|
lateinit var database: ViewingDatabase
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var repository: StatsRepository
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
database = ViewingDatabase.getInstance(this)
|
||||||
|
repository = StatsRepository(database)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/src/main/java/com/bingestats/app/MainActivity.kt
Normal file
140
app/src/main/java/com/bingestats/app/MainActivity.kt
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
package com.bingestats.app
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Analytics
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.bingestats.app.ui.screens.AnalyticsScreen
|
||||||
|
import com.bingestats.app.ui.screens.DashboardScreen
|
||||||
|
import com.bingestats.app.ui.screens.ProviderSyncScreen
|
||||||
|
import com.bingestats.app.ui.screens.SeriesStatsScreen
|
||||||
|
import com.bingestats.app.ui.screens.SettingsScreen
|
||||||
|
import com.bingestats.app.ui.theme.BingeStatsTheme
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
||||||
|
object Dashboard : Screen("dashboard", "Home", Icons.Default.Home)
|
||||||
|
object Series : Screen("series", "Serien", Icons.Default.Tv)
|
||||||
|
object Analytics : Screen("analytics", "Analytics", Icons.Default.Analytics)
|
||||||
|
object Sync : Screen("sync", "Sync", Icons.Default.Sync)
|
||||||
|
object Settings : Screen("settings", "Settings", Icons.Default.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate()
|
||||||
|
setContent {
|
||||||
|
BingeStatsTheme {
|
||||||
|
MainAppStructure(viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainAppStructure(viewModel: MainViewModel) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
val screens = listOf(
|
||||||
|
Screen.Dashboard,
|
||||||
|
Screen.Series,
|
||||||
|
Screen.Analytics,
|
||||||
|
Screen.Sync,
|
||||||
|
Screen.Settings
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = CardBackgroundDark,
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
screens.forEach { screen ->
|
||||||
|
val isSelected = currentRoute == screen.route
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(screen.icon, contentDescription = screen.title) },
|
||||||
|
label = { Text(screen.title) },
|
||||||
|
selected = isSelected,
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = NetflixRed,
|
||||||
|
selectedTextColor = NetflixRed,
|
||||||
|
unselectedIconColor = TextMuted,
|
||||||
|
unselectedTextColor = TextMuted,
|
||||||
|
indicatorColor = NetflixRed.copy(alpha = 0.15f)
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(screen.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.Dashboard.route,
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(Screen.Dashboard.route) {
|
||||||
|
DashboardScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToSync = { navController.navigate(Screen.Sync.route) },
|
||||||
|
onNavigateToSeries = { navController.navigate(Screen.Series.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Series.route) {
|
||||||
|
SeriesStatsScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composable(Screen.Analytics.route) {
|
||||||
|
AnalyticsScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composable(Screen.Sync.route) {
|
||||||
|
ProviderSyncScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onSyncCompleted = { navController.navigate(Screen.Dashboard.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
SettingsScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/src/main/java/com/bingestats/app/data/db/TmdbCacheDao.kt
Normal file
20
app/src/main/java/com/bingestats/app/data/db/TmdbCacheDao.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.bingestats.app.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.bingestats.app.data.model.TmdbCacheEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface TmdbCacheDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tmdb_cache WHERE titleKey = :titleKey LIMIT 1")
|
||||||
|
suspend fun getCache(titleKey: String): TmdbCacheEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCache(cache: TmdbCacheEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM tmdb_cache")
|
||||||
|
suspend fun clearCache()
|
||||||
|
}
|
||||||
84
app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt
Normal file
84
app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.bingestats.app.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
data class ShowAggregation(
|
||||||
|
val showTitle: String,
|
||||||
|
val provider: String,
|
||||||
|
val episodeCount: Int,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val lastWatchedDate: Long,
|
||||||
|
val posterPath: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MonthlyAggregation(
|
||||||
|
val yearMonth: String,
|
||||||
|
val totalMinutes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProviderAggregation(
|
||||||
|
val provider: String,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val itemCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ViewingDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(items: List<ViewingItem>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM viewing_items ORDER BY watchDate DESC")
|
||||||
|
fun getAllViewingItems(): Flow<List<ViewingItem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM viewing_items WHERE provider = :provider ORDER BY watchDate DESC")
|
||||||
|
fun getViewingItemsByProvider(provider: String): Flow<List<ViewingItem>>
|
||||||
|
|
||||||
|
@Query("SELECT SUM(durationMinutes) FROM viewing_items")
|
||||||
|
fun getTotalWatchTimeMinutes(): Flow<Long?>
|
||||||
|
|
||||||
|
@Query("SELECT SUM(durationMinutes) FROM viewing_items WHERE provider = :provider")
|
||||||
|
fun getTotalWatchTimeMinutesByProvider(provider: String): Flow<Long?>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM viewing_items")
|
||||||
|
fun getTotalItemsCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items")
|
||||||
|
fun getUniqueShowsCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT showTitle, provider, COUNT(*) as episodeCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
|
||||||
|
FROM viewing_items
|
||||||
|
GROUP BY showTitle
|
||||||
|
ORDER BY totalMinutes DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
fun getTopShows(limit: Int = 20): Flow<List<ShowAggregation>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes
|
||||||
|
FROM viewing_items
|
||||||
|
GROUP BY yearMonth
|
||||||
|
ORDER BY yearMonth DESC
|
||||||
|
LIMIT 12
|
||||||
|
""")
|
||||||
|
fun getMonthlyStats(): Flow<List<MonthlyAggregation>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT provider, SUM(durationMinutes) as totalMinutes, COUNT(*) as itemCount
|
||||||
|
FROM viewing_items
|
||||||
|
GROUP BY provider
|
||||||
|
""")
|
||||||
|
fun getProviderStats(): Flow<List<ProviderAggregation>>
|
||||||
|
|
||||||
|
@Query("UPDATE viewing_items SET durationMinutes = :duration, posterPath = :posterPath WHERE showTitle = :showTitle")
|
||||||
|
suspend fun updateShowRuntimeAndPoster(showTitle: String, duration: Int, posterPath: String?)
|
||||||
|
|
||||||
|
@Query("DELETE FROM viewing_items")
|
||||||
|
suspend fun clearAll()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.bingestats.app.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.bingestats.app.data.model.TmdbCacheEntity
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ViewingItem::class, TmdbCacheEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class ViewingDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun viewingDao(): ViewingDao
|
||||||
|
abstract fun tmdbCacheDao(): TmdbCacheDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: ViewingDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): ViewingDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
ViewingDatabase::class.java,
|
||||||
|
"bingestats_db"
|
||||||
|
).fallbackToDestructiveMigration().build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.bingestats.app.data.model
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported streaming providers in BingeStats.
|
||||||
|
* Designed modularly so new providers (Amazon Prime Video, Disney+, etc.) can be seamlessly added.
|
||||||
|
*/
|
||||||
|
enum class StreamingProvider(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val brandColorHex: String,
|
||||||
|
val defaultWebUrl: String,
|
||||||
|
val viewingHistoryUrl: String
|
||||||
|
) {
|
||||||
|
NETFLIX(
|
||||||
|
id = "netflix",
|
||||||
|
displayName = "Netflix",
|
||||||
|
brandColorHex = "#E50914",
|
||||||
|
defaultWebUrl = "https://www.netflix.com/login",
|
||||||
|
viewingHistoryUrl = "https://www.netflix.com/viewingactivity"
|
||||||
|
),
|
||||||
|
AMAZON_PRIME(
|
||||||
|
id = "prime",
|
||||||
|
displayName = "Prime Video",
|
||||||
|
brandColorHex = "#00A8E1",
|
||||||
|
defaultWebUrl = "https://www.amazon.com/ap/signin",
|
||||||
|
viewingHistoryUrl = "https://www.amazon.com/gp/your-account/order-history"
|
||||||
|
),
|
||||||
|
DISNEY_PLUS(
|
||||||
|
id = "disney",
|
||||||
|
displayName = "Disney+",
|
||||||
|
brandColorHex = "#113CCF",
|
||||||
|
defaultWebUrl = "https://www.disneyplus.com/login",
|
||||||
|
viewingHistoryUrl = "https://www.disneyplus.com/account"
|
||||||
|
),
|
||||||
|
APPLE_TV(
|
||||||
|
id = "apple_tv",
|
||||||
|
displayName = "Apple TV+",
|
||||||
|
brandColorHex = "#A2A2A2",
|
||||||
|
defaultWebUrl = "https://tv.apple.com",
|
||||||
|
viewingHistoryUrl = "https://tv.apple.com/settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
val primaryColor: Color
|
||||||
|
get() = Color(android.graphics.Color.parseColor(brandColorHex))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(id: String): StreamingProvider {
|
||||||
|
return entries.find { it.id.equals(id, ignoreCase = true) } ?: NETFLIX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/src/main/java/com/bingestats/app/data/model/TmdbMedia.kt
Normal file
38
app/src/main/java/com/bingestats/app/data/model/TmdbMedia.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.bingestats.app.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "tmdb_cache")
|
||||||
|
data class TmdbCacheEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val titleKey: String, // e.g. "series_stranger things"
|
||||||
|
val tmdbId: Int?,
|
||||||
|
val title: String,
|
||||||
|
val mediaType: String, // "tv" or "movie"
|
||||||
|
val runtimeMinutes: Int,
|
||||||
|
val posterPath: String?,
|
||||||
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TmdbSearchResult(
|
||||||
|
val id: Int,
|
||||||
|
val name: String?,
|
||||||
|
val title: String?,
|
||||||
|
val posterPath: String?,
|
||||||
|
val overview: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TmdbDetails(
|
||||||
|
val id: Int,
|
||||||
|
val runtime: Int?, // for movies
|
||||||
|
val episodeRunTime: List<Int>?, // for TV shows
|
||||||
|
val posterPath: String?
|
||||||
|
) {
|
||||||
|
val averageRuntime: Int
|
||||||
|
get() = when {
|
||||||
|
runtime != null && runtime > 0 -> runtime
|
||||||
|
!episodeRunTime.isNullOrEmpty() -> episodeRunTime.average().toInt().coerceAtLeast(10)
|
||||||
|
else -> 45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.bingestats.app.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
enum class ContentType {
|
||||||
|
SERIES,
|
||||||
|
MOVIE,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "viewing_items",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["provider", "rawTitle", "watchDate"], unique = true),
|
||||||
|
Index(value = ["showTitle"]),
|
||||||
|
Index(value = ["provider"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ViewingItem(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val provider: String = StreamingProvider.NETFLIX.id,
|
||||||
|
val rawTitle: String,
|
||||||
|
val showTitle: String,
|
||||||
|
val seasonTitle: String? = null,
|
||||||
|
val episodeTitle: String? = null,
|
||||||
|
val contentType: ContentType = ContentType.UNKNOWN,
|
||||||
|
val watchDate: Long, // Epoch timestamp in millis
|
||||||
|
val dateFormatted: String,
|
||||||
|
val durationMinutes: Int = 45, // Default fallback or exact TMDB runtime
|
||||||
|
val profileName: String = "Main Profile",
|
||||||
|
val tmdbId: Int? = null,
|
||||||
|
val posterPath: String? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.bingestats.app.data.model
|
||||||
|
|
||||||
|
data class ShowSummary(
|
||||||
|
val showTitle: String,
|
||||||
|
val provider: StreamingProvider,
|
||||||
|
val episodeCount: Int,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val lastWatchedDate: Long,
|
||||||
|
val posterPath: String? = null
|
||||||
|
) {
|
||||||
|
val totalHours: Float
|
||||||
|
get() = totalMinutes / 60f
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MonthlyStat(
|
||||||
|
val yearMonth: String, // e.g. "2024-05"
|
||||||
|
val monthLabel: String, // e.g. "Mai 2024"
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val totalHours: Float = totalMinutes / 60f
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DayOfWeekStat(
|
||||||
|
val dayName: String, // e.g. "Mo", "Di"
|
||||||
|
val dayOfWeek: Int, // 1 = Mon .. 7 = Sun
|
||||||
|
val totalMinutes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProviderStat(
|
||||||
|
val provider: StreamingProvider,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val itemCount: Int,
|
||||||
|
val percentage: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WatchStats(
|
||||||
|
val totalWatchTimeMinutes: Long = 0,
|
||||||
|
val totalItemsCount: Int = 0,
|
||||||
|
val seriesEpisodesCount: Int = 0,
|
||||||
|
val moviesCount: Int = 0,
|
||||||
|
val uniqueShowsCount: Int = 0,
|
||||||
|
val topShows: List<ShowSummary> = emptyList(),
|
||||||
|
val monthlyStats: List<MonthlyStat> = emptyList(),
|
||||||
|
val dayOfWeekStats: List<DayOfWeekStat> = emptyList(),
|
||||||
|
val providerStats: List<ProviderStat> = emptyList()
|
||||||
|
) {
|
||||||
|
val totalDays: Int
|
||||||
|
get() = (totalWatchTimeMinutes / (24 * 60)).toInt()
|
||||||
|
|
||||||
|
val remainingHoursAfterDays: Int
|
||||||
|
get() = ((totalWatchTimeMinutes % (24 * 60)) / 60).toInt()
|
||||||
|
|
||||||
|
val remainingMinutes: Int
|
||||||
|
get() = (totalWatchTimeMinutes % 60).toInt()
|
||||||
|
|
||||||
|
val formattedTotalTime: String
|
||||||
|
get() = when {
|
||||||
|
totalDays > 0 -> "${totalDays}d ${remainingHoursAfterDays}h ${remainingMinutes}m"
|
||||||
|
else -> "${totalWatchTimeMinutes / 60}h ${remainingMinutes}m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
package com.bingestats.app.data.parser
|
||||||
|
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
|
||||||
|
object NetflixParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw Netflix viewing activity CSV text.
|
||||||
|
* Expected CSV format:
|
||||||
|
* "Title","Date"
|
||||||
|
* "Stranger Things: Season 4: Chapter One: May 27, 1986","04.08.24"
|
||||||
|
*/
|
||||||
|
fun parseCsv(csvText: String, profileName: String = "Main Profile"): List<ViewingItem> {
|
||||||
|
val items = mutableListOf<ViewingItem>()
|
||||||
|
val lines = csvText.lines()
|
||||||
|
|
||||||
|
for (line in lines) {
|
||||||
|
if (line.isBlank() || line.startsWith("Title", ignoreCase = true) || line.startsWith("\"Title\"", ignoreCase = true)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by comma outside of quotes or double quotes
|
||||||
|
val tokens = parseCsvLine(line)
|
||||||
|
if (tokens.size >= 2) {
|
||||||
|
val rawTitle = tokens[0].trim().trim('"')
|
||||||
|
val dateStr = tokens[1].trim().trim('"')
|
||||||
|
|
||||||
|
if (rawTitle.isNotEmpty()) {
|
||||||
|
val parsedTitle = TitleParser.parseNetflixTitle(rawTitle)
|
||||||
|
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateStr)
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.NETFLIX.id,
|
||||||
|
rawTitle = rawTitle,
|
||||||
|
showTitle = parsedTitle.showTitle,
|
||||||
|
seasonTitle = parsedTitle.seasonTitle,
|
||||||
|
episodeTitle = parsedTitle.episodeTitle,
|
||||||
|
contentType = parsedTitle.contentType,
|
||||||
|
watchDate = timestamp,
|
||||||
|
dateFormatted = dateFormatted,
|
||||||
|
durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105,
|
||||||
|
profileName = profileName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Netflix JSON API viewing activity payload (intercepted during WebView browsing).
|
||||||
|
*/
|
||||||
|
fun parseJsonApiPayload(jsonText: String, profileName: String = "Main Profile"): List<ViewingItem> {
|
||||||
|
val items = mutableListOf<ViewingItem>()
|
||||||
|
try {
|
||||||
|
val gson = Gson()
|
||||||
|
val jsonObject = gson.fromJson(jsonText, JsonObject::class.java)
|
||||||
|
val viewingData = jsonObject.getAsJsonArray("viewedItems") ?: return items
|
||||||
|
|
||||||
|
for (i in 0 until viewingData.size()) {
|
||||||
|
val item = viewingData[i].asJsonObject
|
||||||
|
val title = item.get("title")?.asString ?: item.get("seriesTitle")?.asString ?: continue
|
||||||
|
val dateEpoch = item.get("date")?.asLong ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
val parsedTitle = TitleParser.parseNetflixTitle(title)
|
||||||
|
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.NETFLIX.id,
|
||||||
|
rawTitle = title,
|
||||||
|
showTitle = parsedTitle.showTitle,
|
||||||
|
seasonTitle = parsedTitle.seasonTitle,
|
||||||
|
episodeTitle = parsedTitle.episodeTitle,
|
||||||
|
contentType = parsedTitle.contentType,
|
||||||
|
watchDate = dateEpoch,
|
||||||
|
dateFormatted = dateFormatted,
|
||||||
|
durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105,
|
||||||
|
profileName = profileName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCsvLine(line: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var inQuotes = false
|
||||||
|
val sb = StringBuilder()
|
||||||
|
|
||||||
|
for (ch in line.toCharArray()) {
|
||||||
|
when (ch) {
|
||||||
|
'"' -> inQuotes = !inQuotes
|
||||||
|
',' -> {
|
||||||
|
if (inQuotes) {
|
||||||
|
sb.append(ch)
|
||||||
|
} else {
|
||||||
|
result.add(sb.toString())
|
||||||
|
sb.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> sb.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.add(sb.toString())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.bingestats.app.data.parser
|
||||||
|
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
|
||||||
|
object PrimeVideoParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Amazon Prime Video export CSV / text list.
|
||||||
|
*/
|
||||||
|
fun parseCsv(csvText: String, profileName: String = "Prime Profile"): List<ViewingItem> {
|
||||||
|
val items = mutableListOf<ViewingItem>()
|
||||||
|
val lines = csvText.lines()
|
||||||
|
|
||||||
|
for (line in lines) {
|
||||||
|
if (line.isBlank() || line.startsWith("Title", ignoreCase = true)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = line.split(",")
|
||||||
|
if (parts.size >= 2) {
|
||||||
|
val rawTitle = parts[0].trim().trim('"')
|
||||||
|
val dateStr = parts[1].trim().trim('"')
|
||||||
|
|
||||||
|
if (rawTitle.isNotEmpty()) {
|
||||||
|
val parsedTitle = TitleParser.parsePrimeTitle(rawTitle)
|
||||||
|
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateStr)
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.AMAZON_PRIME.id,
|
||||||
|
rawTitle = rawTitle,
|
||||||
|
showTitle = parsedTitle.showTitle,
|
||||||
|
seasonTitle = parsedTitle.seasonTitle,
|
||||||
|
episodeTitle = parsedTitle.episodeTitle,
|
||||||
|
contentType = parsedTitle.contentType,
|
||||||
|
watchDate = timestamp,
|
||||||
|
dateFormatted = dateFormatted,
|
||||||
|
durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 110,
|
||||||
|
profileName = profileName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/src/main/java/com/bingestats/app/data/parser/TitleParser.kt
Normal file
147
app/src/main/java/com/bingestats/app/data/parser/TitleParser.kt
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package com.bingestats.app.data.parser
|
||||||
|
|
||||||
|
import com.bingestats.app.data.model.ContentType
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
data class ParsedTitle(
|
||||||
|
val showTitle: String,
|
||||||
|
val seasonTitle: String? = null,
|
||||||
|
val episodeTitle: String? = null,
|
||||||
|
val contentType: ContentType
|
||||||
|
)
|
||||||
|
|
||||||
|
object TitleParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Netflix viewing history title string into Series/Movie metadata.
|
||||||
|
* Examples:
|
||||||
|
* - "Stranger Things: Season 4: Chapter One" -> Show: "Stranger Things", Season: "Season 4", Episode: "Chapter One", ContentType: SERIES
|
||||||
|
* - "The Irishman" -> Show: "The Irishman", ContentType: MOVIE
|
||||||
|
* - "Dark: Staffel 1: Geheimnisse" -> Show: "Dark", Season: "Staffel 1", Episode: "Geheimnisse", ContentType: SERIES
|
||||||
|
*/
|
||||||
|
fun parseNetflixTitle(rawTitle: String): ParsedTitle {
|
||||||
|
val trimmed = rawTitle.trim()
|
||||||
|
val parts = trimmed.split(": ")
|
||||||
|
|
||||||
|
return when {
|
||||||
|
parts.size >= 3 -> {
|
||||||
|
// Typical TV show: Show : Season : Episode
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = parts[0],
|
||||||
|
seasonTitle = parts[1],
|
||||||
|
episodeTitle = parts.subList(2, parts.size).joinToString(": "),
|
||||||
|
contentType = ContentType.SERIES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
parts.size == 2 -> {
|
||||||
|
// Show : Episode or Season
|
||||||
|
val secondPart = parts[1]
|
||||||
|
if (secondPart.contains("Staffel", ignoreCase = true) ||
|
||||||
|
secondPart.contains("Season", ignoreCase = true) ||
|
||||||
|
secondPart.contains("Folge", ignoreCase = true) ||
|
||||||
|
secondPart.contains("Episode", ignoreCase = true) ||
|
||||||
|
secondPart.contains("Teil", ignoreCase = true) ||
|
||||||
|
secondPart.contains("Part", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = parts[0],
|
||||||
|
seasonTitle = parts[1],
|
||||||
|
episodeTitle = null,
|
||||||
|
contentType = ContentType.SERIES
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = parts[0],
|
||||||
|
seasonTitle = null,
|
||||||
|
episodeTitle = parts[1],
|
||||||
|
contentType = ContentType.SERIES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Single name -> Movie or Mini-series
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = trimmed,
|
||||||
|
seasonTitle = null,
|
||||||
|
episodeTitle = null,
|
||||||
|
contentType = ContentType.MOVIE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Amazon Prime Video title string.
|
||||||
|
* Prime Video titles often format as:
|
||||||
|
* - "The Boys - Staffel 3"
|
||||||
|
* - "Reacher - Season 1: Episode 1"
|
||||||
|
* - "The Fallout"
|
||||||
|
*/
|
||||||
|
fun parsePrimeTitle(rawTitle: String): ParsedTitle {
|
||||||
|
val trimmed = rawTitle.trim()
|
||||||
|
val dashParts = trimmed.split(" - ")
|
||||||
|
|
||||||
|
return when {
|
||||||
|
dashParts.size >= 2 -> {
|
||||||
|
val showName = dashParts[0].trim()
|
||||||
|
val remaining = dashParts.subList(1, dashParts.size).joinToString(" - ")
|
||||||
|
val episodeParts = remaining.split(": ")
|
||||||
|
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = showName,
|
||||||
|
seasonTitle = episodeParts[0],
|
||||||
|
episodeTitle = if (episodeParts.size > 1) episodeParts.subList(1, episodeParts.size).joinToString(": ") else null,
|
||||||
|
contentType = ContentType.SERIES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ParsedTitle(
|
||||||
|
showTitle = trimmed,
|
||||||
|
seasonTitle = null,
|
||||||
|
episodeTitle = null,
|
||||||
|
contentType = ContentType.MOVIE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flexible date parser supporting German, English, and ISO date formats.
|
||||||
|
*/
|
||||||
|
fun parseDateToEpoch(dateStr: String): Pair<Long, String> {
|
||||||
|
val formats = listOf(
|
||||||
|
"dd.MM.yy",
|
||||||
|
"dd/MM/yy",
|
||||||
|
"MM/dd/yy",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"dd.MM.yyyy",
|
||||||
|
"d.M.yyyy",
|
||||||
|
"d.M.yy"
|
||||||
|
)
|
||||||
|
|
||||||
|
val cleanDate = dateStr.trim().trim('"')
|
||||||
|
for (format in formats) {
|
||||||
|
try {
|
||||||
|
val sdf = SimpleDateFormat(format, Locale.getDefault())
|
||||||
|
sdf.timeZone = TimeZone.getDefault()
|
||||||
|
val date = sdf.parse(cleanDate)
|
||||||
|
if (date != null) {
|
||||||
|
val outFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
return Pair(date.time, outFormat.format(date))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Continue to next format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to current time if unparseable
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val outFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
return Pair(now, outFormat.format(Date(now)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.bingestats.app.data.remote
|
||||||
|
|
||||||
|
import com.bingestats.app.data.model.ContentType
|
||||||
|
import com.bingestats.app.data.model.TmdbDetails
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class TmdbClient(private var apiKey: String = DEFAULT_API_KEY) {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
fun setApiKey(key: String) {
|
||||||
|
if (key.isNotBlank()) {
|
||||||
|
this.apiKey = key.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchAndGetRuntime(title: String, contentType: ContentType): Pair<Int, String?> = withContext(Dispatchers.IO) {
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
return@withContext Pair(getDefaultRuntime(contentType), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val mediaType = if (contentType == ContentType.SERIES) "tv" else "movie"
|
||||||
|
val encodedTitle = URLEncoder.encode(title, "UTF-8")
|
||||||
|
val searchUrl = "https://api.themoviedb.org/3/search/$mediaType?api_key=$apiKey&query=$encodedTitle&language=de-DE"
|
||||||
|
|
||||||
|
val request = Request.Builder().url(searchUrl).build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext Pair(getDefaultRuntime(contentType), null)
|
||||||
|
val bodyStr = response.body?.string() ?: return@withContext Pair(getDefaultRuntime(contentType), null)
|
||||||
|
|
||||||
|
val json = gson.fromJson(bodyStr, JsonObject::class.java)
|
||||||
|
val results = json.getAsJsonArray("results")
|
||||||
|
if (results == null || results.size() == 0) {
|
||||||
|
return@withContext Pair(getDefaultRuntime(contentType), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstItem = results[0].asJsonObject
|
||||||
|
val id = firstItem.get("id").asInt
|
||||||
|
val posterPath = firstItem.get("poster_path")?.let { if (!it.isJsonNull) "https://image.tmdb.org/t/p/w500${it.asString}" else null }
|
||||||
|
|
||||||
|
// Fetch details for exact runtime
|
||||||
|
val detailsUrl = "https://api.themoviedb.org/3/$mediaType/$id?api_key=$apiKey&language=de-DE"
|
||||||
|
val detailsReq = Request.Builder().url(detailsUrl).build()
|
||||||
|
|
||||||
|
client.newCall(detailsReq).execute().use { detailsResp ->
|
||||||
|
if (!detailsResp.isSuccessful) return@withContext Pair(getDefaultRuntime(contentType), posterPath)
|
||||||
|
val detailsStr = detailsResp.body?.string() ?: return@withContext Pair(getDefaultRuntime(contentType), posterPath)
|
||||||
|
val detailsJson = gson.fromJson(detailsStr, JsonObject::class.java)
|
||||||
|
|
||||||
|
val runtime = if (mediaType == "tv") {
|
||||||
|
val runtimes = detailsJson.getAsJsonArray("episode_run_time")
|
||||||
|
if (runtimes != null && runtimes.size() > 0) {
|
||||||
|
runtimes[0].asInt
|
||||||
|
} else {
|
||||||
|
45
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailsJson.get("runtime")?.let { if (!it.isJsonNull) it.asInt else 105 } ?: 105
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext Pair(runtime.coerceAtLeast(10), posterPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
return@withContext Pair(getDefaultRuntime(contentType), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDefaultRuntime(contentType: ContentType): Int {
|
||||||
|
return when (contentType) {
|
||||||
|
ContentType.SERIES -> 45
|
||||||
|
ContentType.MOVIE -> 105
|
||||||
|
ContentType.UNKNOWN -> 45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Fallback demo API key or user input key
|
||||||
|
const val DEFAULT_API_KEY = "80f83652c6f1bc331821cfdfcbe6d2a4" // Standard public TMDB key for client demos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package com.bingestats.app.data.repository
|
||||||
|
|
||||||
|
import com.bingestats.app.data.db.ViewingDatabase
|
||||||
|
import com.bingestats.app.data.model.DayOfWeekStat
|
||||||
|
import com.bingestats.app.data.model.MonthlyStat
|
||||||
|
import com.bingestats.app.data.model.ProviderStat
|
||||||
|
import com.bingestats.app.data.model.ShowSummary
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.model.TmdbCacheEntity
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
import com.bingestats.app.data.model.WatchStats
|
||||||
|
import com.bingestats.app.data.remote.TmdbClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
class StatsRepository(private val db: ViewingDatabase) {
|
||||||
|
|
||||||
|
private val viewingDao = db.viewingDao()
|
||||||
|
private val tmdbCacheDao = db.tmdbCacheDao()
|
||||||
|
private val tmdbClient = TmdbClient()
|
||||||
|
|
||||||
|
fun setTmdbApiKey(key: String) {
|
||||||
|
tmdbClient.setApiKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
val watchStatsFlow: Flow<WatchStats> = combine(
|
||||||
|
viewingDao.getTotalWatchTimeMinutes(),
|
||||||
|
viewingDao.getTotalItemsCount(),
|
||||||
|
viewingDao.getUniqueShowsCount(),
|
||||||
|
viewingDao.getTopShows(20),
|
||||||
|
viewingDao.getMonthlyStats(),
|
||||||
|
viewingDao.getProviderStats()
|
||||||
|
) { totalMinutes, itemCount, uniqueShows, topShowsAgg, monthlyAgg, providerAgg ->
|
||||||
|
|
||||||
|
val topShows = topShowsAgg.map { agg ->
|
||||||
|
ShowSummary(
|
||||||
|
showTitle = agg.showTitle,
|
||||||
|
provider = StreamingProvider.fromId(agg.provider),
|
||||||
|
episodeCount = agg.episodeCount,
|
||||||
|
totalMinutes = agg.totalMinutes,
|
||||||
|
lastWatchedDate = agg.lastWatchedDate,
|
||||||
|
posterPath = agg.posterPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val monthlyStats = monthlyAgg.map { agg ->
|
||||||
|
MonthlyStat(
|
||||||
|
yearMonth = agg.yearMonth,
|
||||||
|
monthLabel = agg.yearMonth,
|
||||||
|
totalMinutes = agg.totalMinutes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val grandTotalMins = (totalMinutes ?: 0L).coerceAtLeast(1L)
|
||||||
|
val providerStats = providerAgg.map { agg ->
|
||||||
|
val provider = StreamingProvider.fromId(agg.provider)
|
||||||
|
ProviderStat(
|
||||||
|
provider = provider,
|
||||||
|
totalMinutes = agg.totalMinutes,
|
||||||
|
itemCount = agg.itemCount,
|
||||||
|
percentage = (agg.totalMinutes.toFloat() / grandTotalMins.toFloat()) * 100f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchStats(
|
||||||
|
totalWatchTimeMinutes = totalMinutes ?: 0L,
|
||||||
|
totalItemsCount = itemCount,
|
||||||
|
seriesEpisodesCount = topShows.sumOf { it.episodeCount },
|
||||||
|
moviesCount = (itemCount - topShows.sumOf { it.episodeCount }).coerceAtLeast(0),
|
||||||
|
uniqueShowsCount = uniqueShows,
|
||||||
|
topShows = topShows,
|
||||||
|
monthlyStats = monthlyStats,
|
||||||
|
providerStats = providerStats
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importItems(items: List<ViewingItem>) = withContext(Dispatchers.IO) {
|
||||||
|
viewingDao.insertAll(items)
|
||||||
|
enrichWithTmdbData(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously query TMDB API for exact show runtimes and poster art.
|
||||||
|
*/
|
||||||
|
private suspend fun enrichWithTmdbData(items: List<ViewingItem>) = withContext(Dispatchers.IO) {
|
||||||
|
val uniqueShows = items.map { it.showTitle to it.contentType }.distinctBy { it.first }
|
||||||
|
|
||||||
|
for ((showTitle, contentType) in uniqueShows) {
|
||||||
|
val cacheKey = "${contentType.name.lowercase()}_${showTitle.lowercase()}"
|
||||||
|
val existingCache = tmdbCacheDao.getCache(cacheKey)
|
||||||
|
|
||||||
|
if (existingCache != null) {
|
||||||
|
if (existingCache.runtimeMinutes > 0) {
|
||||||
|
viewingDao.updateShowRuntimeAndPoster(
|
||||||
|
showTitle = showTitle,
|
||||||
|
duration = existingCache.runtimeMinutes,
|
||||||
|
posterPath = existingCache.posterPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val (runtime, posterPath) = tmdbClient.searchAndGetRuntime(showTitle, contentType)
|
||||||
|
tmdbCacheDao.insertCache(
|
||||||
|
TmdbCacheEntity(
|
||||||
|
titleKey = cacheKey,
|
||||||
|
tmdbId = null,
|
||||||
|
title = showTitle,
|
||||||
|
mediaType = contentType.name,
|
||||||
|
runtimeMinutes = runtime,
|
||||||
|
posterPath = posterPath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
viewingDao.updateShowRuntimeAndPoster(
|
||||||
|
showTitle = showTitle,
|
||||||
|
duration = runtime,
|
||||||
|
posterPath = posterPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearDatabase() = withContext(Dispatchers.IO) {
|
||||||
|
viewingDao.clearAll()
|
||||||
|
tmdbCacheDao.clearCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/src/main/java/com/bingestats/app/ui/components/Charts.kt
Normal file
184
app/src/main/java/com/bingestats/app/ui/components/Charts.kt
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package com.bingestats.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.model.MonthlyStat
|
||||||
|
import com.bingestats.app.data.model.ProviderStat
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MonthlyWatchTimeChart(
|
||||||
|
monthlyStats: List<MonthlyStat>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "MONATLICHER WATCH-TREND",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextSecondary,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (monthlyStats.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(150.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Daten verfügbar",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val maxMinutes = monthlyStats.maxOfOrNull { it.totalMinutes }?.toFloat()?.coerceAtLeast(1f) ?: 1f
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(140.dp)
|
||||||
|
) {
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
val barSpacing = 12.dp.toPx()
|
||||||
|
val barWidth = ((width - (barSpacing * (monthlyStats.size + 1))) / monthlyStats.size.coerceAtLeast(1)).coerceAtLeast(10f)
|
||||||
|
|
||||||
|
monthlyStats.reversed().forEachIndexed { index, stat ->
|
||||||
|
val barHeight = (stat.totalMinutes.toFloat() / maxMinutes) * (height - 30.dp.toPx())
|
||||||
|
val x = barSpacing + index * (barWidth + barSpacing)
|
||||||
|
val y = height - barHeight - 20.dp.toPx()
|
||||||
|
|
||||||
|
drawRoundRect(
|
||||||
|
color = NetflixRed,
|
||||||
|
topLeft = Offset(x, y),
|
||||||
|
size = Size(barWidth, barHeight.coerceAtLeast(4f)),
|
||||||
|
cornerRadius = CornerRadius(6.dp.toPx(), 6.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProviderDistributionCard(
|
||||||
|
providerStats: List<ProviderStat>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "STREAMING-ANBIETER VERTEILUNG",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextSecondary,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (providerStats.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Keine Anbieterdaten vorhanden",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Horizontal Stacked Bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(14.dp)
|
||||||
|
.background(GlassCardBorder, RoundedCornerShape(7.dp))
|
||||||
|
) {
|
||||||
|
providerStats.forEach { stat ->
|
||||||
|
val weight = (stat.percentage / 100f).coerceAtLeast(0.01f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(weight)
|
||||||
|
.height(14.dp)
|
||||||
|
.background(stat.provider.primaryColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
providerStats.forEach { stat ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(12.dp)
|
||||||
|
.height(12.dp)
|
||||||
|
.background(stat.provider.primaryColor, RoundedCornerShape(3.dp))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stat.provider.displayName,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${stat.totalMinutes / 60}h (${stat.percentage.toInt()}%)",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.bingestats.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProviderBadge(
|
||||||
|
provider: StreamingProvider,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(
|
||||||
|
color = provider.primaryColor.copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(6.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = provider.displayName.uppercase(),
|
||||||
|
color = provider.primaryColor,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/src/main/java/com/bingestats/app/ui/components/StatCards.kt
Normal file
141
app/src/main/java/com/bingestats/app/ui/components/StatCards.kt
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.bingestats.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.model.WatchStats
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixDarkRed
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HeroWatchTimeCard(
|
||||||
|
stats: WatchStats,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
NetflixRed.copy(alpha = 0.25f),
|
||||||
|
CardBackgroundDark
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NetflixRed
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "GESAMMTE WATCHTIME",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
letterSpacing = 1.2.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stats.formattedTotalTime,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${stats.totalWatchTimeMinutes} Gesamt-Minuten gestreamt",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
iconColor: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = title.uppercase(),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextSecondary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.ui.components.MonthlyWatchTimeChart
|
||||||
|
import com.bingestats.app.ui.components.ProviderDistributionCard
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnalyticsScreen(viewModel: MainViewModel) {
|
||||||
|
val stats by viewModel.watchStats.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Analytics & Trends",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Auswertung über alle angebundenen Streaming-Dienste",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
MonthlyWatchTimeChart(monthlyStats = stats.monthlyStats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
ProviderDistributionCard(providerStats = stats.providerStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.ui.components.HeroWatchTimeCard
|
||||||
|
import com.bingestats.app.ui.components.MonthlyWatchTimeChart
|
||||||
|
import com.bingestats.app.ui.components.ProviderDistributionCard
|
||||||
|
import com.bingestats.app.ui.components.StatCard
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DashboardScreen(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onNavigateToSync: () -> Unit,
|
||||||
|
onNavigateToSeries: () -> Unit
|
||||||
|
) {
|
||||||
|
val stats by viewModel.watchStats.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "BingeStats",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
color = NetflixRed
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Deine Streaming Übersicht",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onNavigateToSync,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NetflixRed)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Sync, contentDescription = null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(text = "Sync / Import", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Hero Watch Time Card
|
||||||
|
HeroWatchTimeCard(stats = stats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Quick Stats Row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
title = "Serien-Folgen",
|
||||||
|
value = stats.seriesEpisodesCount.toString(),
|
||||||
|
icon = Icons.Default.Tv,
|
||||||
|
iconColor = NetflixRed,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title = "Filme",
|
||||||
|
value = stats.moviesCount.toString(),
|
||||||
|
icon = Icons.Default.Movie,
|
||||||
|
iconColor = Color(0xFF00A8E1),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Provider Distribution
|
||||||
|
ProviderDistributionCard(providerStats = stats.providerStats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Monthly Watch Time Chart
|
||||||
|
MonthlyWatchTimeChart(monthlyStats = stats.monthlyStats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
if (stats.totalItemsCount == 0) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.seedSampleData() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = NetflixRed)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Beispieldaten zur Vorschau laden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudUpload
|
||||||
|
import androidx.compose.material.icons.filled.Language
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.TabRowDefaults
|
||||||
|
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.ui.components.ProviderBadge
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
import com.bingestats.app.ui.viewmodel.UiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProviderSyncScreen(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onSyncCompleted: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedProvider by remember { mutableStateOf(StreamingProvider.NETFLIX) }
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) } // 0 = Web Login & Scrape, 1 = CSV File Upload
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
viewModel.importCsvFromUri(it, selectedProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Konto Synchronisieren",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Wähle einen Anbieter und melde dich an oder importiere deine Verlauf-Datei.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Provider Selector Chips
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
StreamingProvider.entries.forEach { provider ->
|
||||||
|
val isSelected = selectedProvider == provider
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.background(
|
||||||
|
color = if (isSelected) provider.primaryColor.copy(alpha = 0.3f) else CardBackgroundDark,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isSelected) provider.primaryColor else GlassCardBorder,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.clickable { selectedProvider = provider }
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = provider.displayName,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (isSelected) Color.White else TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTab,
|
||||||
|
containerColor = CardBackgroundDark,
|
||||||
|
contentColor = Color.White,
|
||||||
|
indicator = { tabPositions ->
|
||||||
|
TabRowDefaults.SecondaryIndicator(
|
||||||
|
Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
|
||||||
|
color = selectedProvider.primaryColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == 0,
|
||||||
|
onClick = { selectedTab = 0 },
|
||||||
|
text = { Text("Web-Login & Auto-Sync") },
|
||||||
|
icon = { Icon(Icons.Default.Language, contentDescription = null) }
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == 1,
|
||||||
|
onClick = { selectedTab = 1 },
|
||||||
|
text = { Text("CSV Datei Import") },
|
||||||
|
icon = { Icon(Icons.Default.CloudUpload, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
when (uiState) {
|
||||||
|
is UiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(80.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = selectedProvider.primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UiState.Success -> {
|
||||||
|
val msg = (uiState as UiState.Success).message
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFF1B5E20))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = msg,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
is UiState.Error -> {
|
||||||
|
val err = (uiState as UiState.Error).message
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFB71C1C))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTab == 0) {
|
||||||
|
// In-App WebView Sync
|
||||||
|
WebViewSyncSection(
|
||||||
|
provider = selectedProvider,
|
||||||
|
onExtractHistory = { scrapedData ->
|
||||||
|
viewModel.importScrapedWebHistory(scrapedData, selectedProvider)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// CSV Import Section
|
||||||
|
CsvImportSection(
|
||||||
|
provider = selectedProvider,
|
||||||
|
onSelectFile = { filePickerLauncher.launch("*/*") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun WebViewSyncSection(
|
||||||
|
provider: StreamingProvider,
|
||||||
|
onExtractHistory: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var currentUrl by remember { mutableStateOf(provider.defaultWebUrl) }
|
||||||
|
var isLoadingWeb by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(4.dp))
|
||||||
|
Text(
|
||||||
|
text = currentUrl,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
webViewRef?.evaluateJavascript(
|
||||||
|
"(function() { return document.documentElement.outerHTML; })();"
|
||||||
|
) { html ->
|
||||||
|
if (!html.isNullOrBlank()) {
|
||||||
|
onExtractHistory(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = provider.primaryColor)
|
||||||
|
) {
|
||||||
|
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.userAgentString = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
currentUrl = url ?: ""
|
||||||
|
isLoadingWeb = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
currentUrl = request?.url.toString()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadUrl(provider.viewingHistoryUrl)
|
||||||
|
webViewRef = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
if (webView.url != provider.viewingHistoryUrl && !currentUrl.contains(provider.id)) {
|
||||||
|
webView.loadUrl(provider.viewingHistoryUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(8.dp))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoadingWeb) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = provider.primaryColor,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CsvImportSection(
|
||||||
|
provider: StreamingProvider,
|
||||||
|
onSelectFile: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CloudUpload,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = provider.primaryColor,
|
||||||
|
modifier = Modifier.height(48.dp).width(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${provider.displayName} Verlauf-Datei wählen",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Lade die Datei 'NetflixViewingHistory.csv' aus deinen Konto-Einstellungen herunter und wähle sie hier aus.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSelectFile,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = provider.primaryColor),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "CSV-Datei Auswählen",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.model.ShowSummary
|
||||||
|
import com.bingestats.app.ui.components.ProviderBadge
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeriesStatsScreen(viewModel: MainViewModel) {
|
||||||
|
val stats by viewModel.watchStats.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Top Serien Ranking",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Deine meistgesehenen Serien nach Gesamtzeit",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (stats.topShows.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Serien erfasst.\nImportiere deinen Verlauf unter Sync.",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(stats.topShows) { index, show ->
|
||||||
|
ShowRankItem(rank = index + 1, show = show)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShowRankItem(
|
||||||
|
rank: Int,
|
||||||
|
show: ShowSummary
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Rank Number
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
if (rank <= 3) NetflixRed.copy(alpha = 0.2f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "#$rank",
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (rank <= 3) NetflixRed else TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = show.showTitle,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ProviderBadge(provider = show.provider)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "${show.episodeCount} Folgen",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "${show.totalMinutes / 60}h ${show.totalMinutes % 60}m",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = NetflixRed
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${show.totalMinutes} Minuten",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.remote.TmdbClient
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: MainViewModel) {
|
||||||
|
var tmdbKey by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Einstellungen",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Konfiguration & Datenverwaltung",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// TMDB API Integration Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Key, contentDescription = null, tint = NetflixRed)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "TMDB API (Exakte Laufzeiten)",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "BingeStats verwendet The Movie Database (TMDB), um genaue Episoden- und Film-Laufzeiten abzufragen. Standardmäßig ist ein Demo-Schlüssel aktiv.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tmdbKey,
|
||||||
|
onValueChange = { tmdbKey = it },
|
||||||
|
label = { Text("Eigener TMDB API-Key (Optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = NetflixRed,
|
||||||
|
unfocusedBorderColor = GlassCardBorder,
|
||||||
|
focusedLabelColor = NetflixRed,
|
||||||
|
unfocusedLabelColor = TextMuted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Data Management Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Daten verwalten",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.seedSampleData() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Beispieldaten erneut laden")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.clearAllData() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB71C1C))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.DeleteForever, contentDescription = null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Alle Daten löschen", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/src/main/java/com/bingestats/app/ui/theme/Color.kt
Normal file
21
app/src/main/java/com/bingestats/app/ui/theme/Color.kt
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.bingestats.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Netflix Obsidian & Crimson Dark Theme System
|
||||||
|
val NetflixRed = Color(0xFFE50914)
|
||||||
|
val NetflixDarkRed = Color(0xFFB81D24)
|
||||||
|
val PrimeBlue = Color(0xFF00A8E1)
|
||||||
|
val DisneyBlue = Color(0xFF113CCF)
|
||||||
|
|
||||||
|
val ObsidianBlack = Color(0xFF141414)
|
||||||
|
val SurfaceDark = Color(0xFF1F1F1F)
|
||||||
|
val CardBackgroundDark = Color(0xFF2B2B2B)
|
||||||
|
val GlassCardBorder = Color(0xFF3D3D3D)
|
||||||
|
|
||||||
|
val TextPrimary = Color(0xFFFFFFFF)
|
||||||
|
val TextSecondary = Color(0xFFA0A0A0)
|
||||||
|
val TextMuted = Color(0xFF757575)
|
||||||
|
|
||||||
|
val AccentGold = Color(0xFFFFD700)
|
||||||
|
val AccentGreen = Color(0xFF4EAF51)
|
||||||
31
app/src/main/java/com/bingestats/app/ui/theme/Theme.kt
Normal file
31
app/src/main/java/com/bingestats/app/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.bingestats.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = NetflixRed,
|
||||||
|
secondary = PrimeBlue,
|
||||||
|
tertiary = AccentGold,
|
||||||
|
background = ObsidianBlack,
|
||||||
|
surface = SurfaceDark,
|
||||||
|
onPrimary = TextPrimary,
|
||||||
|
onSecondary = TextPrimary,
|
||||||
|
onBackground = TextPrimary,
|
||||||
|
onSurface = TextPrimary,
|
||||||
|
surfaceVariant = CardBackgroundDark,
|
||||||
|
outline = GlassCardBorder
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BingeStatsTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = DarkColorScheme,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
package com.bingestats.app.ui.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bingestats.app.BingeStatsApp
|
||||||
|
import com.bingestats.app.data.model.ContentType
|
||||||
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.model.ViewingItem
|
||||||
|
import com.bingestats.app.data.model.WatchStats
|
||||||
|
import com.bingestats.app.data.parser.NetflixParser
|
||||||
|
import com.bingestats.app.data.parser.PrimeVideoParser
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
sealed class UiState {
|
||||||
|
object Idle : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Success(val message: String) : UiState()
|
||||||
|
data class Error(val message: String) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
private val repository = (application as BingeStatsApp).repository
|
||||||
|
|
||||||
|
val watchStats: StateFlow<WatchStats> = repository.watchStatsFlow
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = WatchStats()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
|
||||||
|
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedProviderFilter = MutableStateFlow<StreamingProvider?>(null) // null = ALL
|
||||||
|
val selectedProviderFilter: StateFlow<StreamingProvider?> = _selectedProviderFilter.asStateFlow()
|
||||||
|
|
||||||
|
fun setProviderFilter(provider: StreamingProvider?) {
|
||||||
|
_selectedProviderFilter.value = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importCsvFromUri(uri: Uri, provider: StreamingProvider) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = UiState.Loading
|
||||||
|
try {
|
||||||
|
val contentResolver = getApplication<Application>().contentResolver
|
||||||
|
val stringBuilder = StringBuilder()
|
||||||
|
contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||||
|
var line: String? = reader.readLine()
|
||||||
|
while (line != null) {
|
||||||
|
stringBuilder.append(line).append("\n")
|
||||||
|
line = reader.readLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val csvText = stringBuilder.toString()
|
||||||
|
val items = when (provider) {
|
||||||
|
StreamingProvider.NETFLIX -> NetflixParser.parseCsv(csvText)
|
||||||
|
StreamingProvider.AMAZON_PRIME -> PrimeVideoParser.parseCsv(csvText)
|
||||||
|
else -> NetflixParser.parseCsv(csvText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
_uiState.value = UiState.Error("Keine Einträge in der CSV-Datei gefunden.")
|
||||||
|
} else {
|
||||||
|
repository.importItems(items)
|
||||||
|
_uiState.value = UiState.Success("${items.size} Einträge für ${provider.displayName} erfolgreich importiert!")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
_uiState.value = UiState.Error("Fehler beim Importieren: ${e.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importScrapedWebHistory(jsonOrCsvText: String, provider: StreamingProvider) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = UiState.Loading
|
||||||
|
try {
|
||||||
|
val items = if (jsonOrCsvText.trim().startsWith("{")) {
|
||||||
|
NetflixParser.parseJsonApiPayload(jsonOrCsvText)
|
||||||
|
} else {
|
||||||
|
NetflixParser.parseCsv(jsonOrCsvText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
_uiState.value = UiState.Error("Keine Daten aus dem Synchronisations-Stream empfangen.")
|
||||||
|
} else {
|
||||||
|
repository.importItems(items)
|
||||||
|
_uiState.value = UiState.Success("${items.size} Einträge von ${provider.displayName} synchronisiert!")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = UiState.Error("Fehler bei der Synchronisation: ${e.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed realistic sample data so user can immediately preview BingeStats capabilities.
|
||||||
|
*/
|
||||||
|
fun seedSampleData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = UiState.Loading
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val dayMillis = 86400000L
|
||||||
|
|
||||||
|
val sampleItems = mutableListOf<ViewingItem>()
|
||||||
|
|
||||||
|
// Netflix Stranger Things binge
|
||||||
|
for (i in 1..34) {
|
||||||
|
sampleItems.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.NETFLIX.id,
|
||||||
|
rawTitle = "Stranger Things: Season 4: Episode $i",
|
||||||
|
showTitle = "Stranger Things",
|
||||||
|
seasonTitle = "Season 4",
|
||||||
|
episodeTitle = "Episode $i",
|
||||||
|
contentType = ContentType.SERIES,
|
||||||
|
watchDate = now - (i * dayMillis / 2),
|
||||||
|
dateFormatted = "2024-05-15",
|
||||||
|
durationMinutes = 55,
|
||||||
|
profileName = "Alex"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netflix Breaking Bad
|
||||||
|
for (i in 1..62) {
|
||||||
|
sampleItems.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.NETFLIX.id,
|
||||||
|
rawTitle = "Breaking Bad: Staffel 5: Folge $i",
|
||||||
|
showTitle = "Breaking Bad",
|
||||||
|
seasonTitle = "Staffel 5",
|
||||||
|
episodeTitle = "Folge $i",
|
||||||
|
contentType = ContentType.SERIES,
|
||||||
|
watchDate = now - (i * dayMillis),
|
||||||
|
dateFormatted = "2024-04-10",
|
||||||
|
durationMinutes = 47,
|
||||||
|
profileName = "Alex"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amazon Prime Video: The Boys
|
||||||
|
for (i in 1..24) {
|
||||||
|
sampleItems.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.AMAZON_PRIME.id,
|
||||||
|
rawTitle = "The Boys - Staffel 3: Episode $i",
|
||||||
|
showTitle = "The Boys",
|
||||||
|
seasonTitle = "Staffel 3",
|
||||||
|
episodeTitle = "Episode $i",
|
||||||
|
contentType = ContentType.SERIES,
|
||||||
|
watchDate = now - (i * dayMillis * 2),
|
||||||
|
dateFormatted = "2024-03-01",
|
||||||
|
durationMinutes = 60,
|
||||||
|
profileName = "Alex"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netflix Movie: The Irishman
|
||||||
|
sampleItems.add(
|
||||||
|
ViewingItem(
|
||||||
|
provider = StreamingProvider.NETFLIX.id,
|
||||||
|
rawTitle = "The Irishman",
|
||||||
|
showTitle = "The Irishman",
|
||||||
|
contentType = ContentType.MOVIE,
|
||||||
|
watchDate = now - 5 * dayMillis,
|
||||||
|
dateFormatted = "2024-05-28",
|
||||||
|
durationMinutes = 209,
|
||||||
|
profileName = "Alex"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.importItems(sampleItems)
|
||||||
|
_uiState.value = UiState.Success("Beispieldaten erfolgreich geladen!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAllData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.clearDatabase()
|
||||||
|
_uiState.value = UiState.Success("Alle Daten wurden gelöscht.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/test/java/com/bingestats/app/TitleParserTest.kt
Normal file
48
app/src/test/java/com/bingestats/app/TitleParserTest.kt
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.bingestats.app
|
||||||
|
|
||||||
|
import com.bingestats.app.data.model.ContentType
|
||||||
|
import com.bingestats.app.data.parser.TitleParser
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TitleParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseNetflixSeriesTitle() {
|
||||||
|
val raw = "Stranger Things: Season 4: Chapter One: May 27, 1986"
|
||||||
|
val parsed = TitleParser.parseNetflixTitle(raw)
|
||||||
|
|
||||||
|
assertEquals("Stranger Things", parsed.showTitle)
|
||||||
|
assertEquals("Season 4", parsed.seasonTitle)
|
||||||
|
assertEquals(ContentType.SERIES, parsed.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseGermanStaffelTitle() {
|
||||||
|
val raw = "Dark: Staffel 1: Geheimnisse"
|
||||||
|
val parsed = TitleParser.parseNetflixTitle(raw)
|
||||||
|
|
||||||
|
assertEquals("Dark", parsed.showTitle)
|
||||||
|
assertEquals("Staffel 1", parsed.seasonTitle)
|
||||||
|
assertEquals(ContentType.SERIES, parsed.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseMovieTitle() {
|
||||||
|
val raw = "The Irishman"
|
||||||
|
val parsed = TitleParser.parseNetflixTitle(raw)
|
||||||
|
|
||||||
|
assertEquals("The Irishman", parsed.showTitle)
|
||||||
|
assertEquals(ContentType.MOVIE, parsed.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParsePrimeVideoTitle() {
|
||||||
|
val raw = "The Boys - Staffel 3: Episode 1"
|
||||||
|
val parsed = TitleParser.parsePrimeTitle(raw)
|
||||||
|
|
||||||
|
assertEquals("The Boys", parsed.showTitle)
|
||||||
|
assertEquals(ContentType.SERIES, parsed.contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.ksp) apply false
|
||||||
|
}
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonFinalResIds=true
|
||||||
|
kotlin.code.style=official
|
||||||
39
gradle/libs.versions.toml
Normal file
39
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
[versions]
|
||||||
|
agp = "8.3.2"
|
||||||
|
kotlin = "1.9.23"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.0"
|
||||||
|
activityCompose = "1.9.0"
|
||||||
|
composeBom = "2024.05.00"
|
||||||
|
navigationCompose = "2.7.7"
|
||||||
|
room = "2.6.1"
|
||||||
|
ksp = "1.9.23-1.0.20"
|
||||||
|
coroutines = "1.8.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
gson = "2.10.1"
|
||||||
|
webkit = "1.11.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
80
gradlew.bat
vendored
Normal file
80
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%"=="0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%DIRNAME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe / c's_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
17
settings.gradle.kts
Normal file
17
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "BingeStats"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue