commit dce3b2258b0b5df9ebc63bc817561eb8b85c714a Author: Janik Dietz Date: Tue Aug 4 15:04:40 2026 +0200 feat: initial commit for BingeStats Android app (Kotlin + Compose + TMDB + Multi-Provider architecture) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e1dab6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea +.idea/ +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx +*.apk +*.ap_ +*.dex diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..514bb12 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..721b0e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bingestats/app/BingeStatsApp.kt b/app/src/main/java/com/bingestats/app/BingeStatsApp.kt new file mode 100644 index 0000000..2a01286 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/BingeStatsApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bingestats/app/MainActivity.kt b/app/src/main/java/com/bingestats/app/MainActivity.kt new file mode 100644 index 0000000..6f89d0a --- /dev/null +++ b/app/src/main/java/com/bingestats/app/MainActivity.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/data/db/TmdbCacheDao.kt b/app/src/main/java/com/bingestats/app/data/db/TmdbCacheDao.kt new file mode 100644 index 0000000..9807e35 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/db/TmdbCacheDao.kt @@ -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() +} diff --git a/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt b/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt new file mode 100644 index 0000000..822e8bc --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt @@ -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) + + @Query("SELECT * FROM viewing_items ORDER BY watchDate DESC") + fun getAllViewingItems(): Flow> + + @Query("SELECT * FROM viewing_items WHERE provider = :provider ORDER BY watchDate DESC") + fun getViewingItemsByProvider(provider: String): Flow> + + @Query("SELECT SUM(durationMinutes) FROM viewing_items") + fun getTotalWatchTimeMinutes(): Flow + + @Query("SELECT SUM(durationMinutes) FROM viewing_items WHERE provider = :provider") + fun getTotalWatchTimeMinutesByProvider(provider: String): Flow + + @Query("SELECT COUNT(*) FROM viewing_items") + fun getTotalItemsCount(): Flow + + @Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items") + fun getUniqueShowsCount(): Flow + + @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> + + @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> + + @Query(""" + SELECT provider, SUM(durationMinutes) as totalMinutes, COUNT(*) as itemCount + FROM viewing_items + GROUP BY provider + """) + fun getProviderStats(): Flow> + + @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() +} diff --git a/app/src/main/java/com/bingestats/app/data/db/ViewingDatabase.kt b/app/src/main/java/com/bingestats/app/data/db/ViewingDatabase.kt new file mode 100644 index 0000000..8b1a16e --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/db/ViewingDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt b/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt new file mode 100644 index 0000000..94a3bee --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/bingestats/app/data/model/TmdbMedia.kt b/app/src/main/java/com/bingestats/app/data/model/TmdbMedia.kt new file mode 100644 index 0000000..a67ee71 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/model/TmdbMedia.kt @@ -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?, // 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 + } +} diff --git a/app/src/main/java/com/bingestats/app/data/model/ViewingItem.kt b/app/src/main/java/com/bingestats/app/data/model/ViewingItem.kt new file mode 100644 index 0000000..be00471 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/model/ViewingItem.kt @@ -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 +) diff --git a/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt b/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt new file mode 100644 index 0000000..2c8ffca --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt @@ -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 = emptyList(), + val monthlyStats: List = emptyList(), + val dayOfWeekStats: List = emptyList(), + val providerStats: List = 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" + } +} diff --git a/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt b/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt new file mode 100644 index 0000000..6b318c1 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt @@ -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 { + val items = mutableListOf() + 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 { + val items = mutableListOf() + 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 { + val result = mutableListOf() + 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 + } +} diff --git a/app/src/main/java/com/bingestats/app/data/parser/PrimeVideoParser.kt b/app/src/main/java/com/bingestats/app/data/parser/PrimeVideoParser.kt new file mode 100644 index 0000000..422bcc3 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/parser/PrimeVideoParser.kt @@ -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 { + val items = mutableListOf() + 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 + } +} diff --git a/app/src/main/java/com/bingestats/app/data/parser/TitleParser.kt b/app/src/main/java/com/bingestats/app/data/parser/TitleParser.kt new file mode 100644 index 0000000..9c9739e --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/parser/TitleParser.kt @@ -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 { + 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))) + } +} diff --git a/app/src/main/java/com/bingestats/app/data/remote/TmdbClient.kt b/app/src/main/java/com/bingestats/app/data/remote/TmdbClient.kt new file mode 100644 index 0000000..c62ef84 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/remote/TmdbClient.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/com/bingestats/app/data/repository/StatsRepository.kt b/app/src/main/java/com/bingestats/app/data/repository/StatsRepository.kt new file mode 100644 index 0000000..8dac1d7 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/repository/StatsRepository.kt @@ -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 = 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) = 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) = 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() + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/components/Charts.kt b/app/src/main/java/com/bingestats/app/ui/components/Charts.kt new file mode 100644 index 0000000..fc73c48 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/components/Charts.kt @@ -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, + 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, + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/components/ProviderBadge.kt b/app/src/main/java/com/bingestats/app/ui/components/ProviderBadge.kt new file mode 100644 index 0000000..de435ad --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/components/ProviderBadge.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/components/StatCards.kt b/app/src/main/java/com/bingestats/app/ui/components/StatCards.kt new file mode 100644 index 0000000..99dbddc --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/components/StatCards.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/screens/AnalyticsScreen.kt b/app/src/main/java/com/bingestats/app/ui/screens/AnalyticsScreen.kt new file mode 100644 index 0000000..fb28f71 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/screens/AnalyticsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/screens/DashboardScreen.kt b/app/src/main/java/com/bingestats/app/ui/screens/DashboardScreen.kt new file mode 100644 index 0000000..5d9b477 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/screens/DashboardScreen.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/screens/ProviderSyncScreen.kt b/app/src/main/java/com/bingestats/app/ui/screens/ProviderSyncScreen.kt new file mode 100644 index 0000000..0c9dc8a --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/screens/ProviderSyncScreen.kt @@ -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(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 + ) + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/screens/SeriesStatsScreen.kt b/app/src/main/java/com/bingestats/app/ui/screens/SeriesStatsScreen.kt new file mode 100644 index 0000000..ffd345f --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/screens/SeriesStatsScreen.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/screens/SettingsScreen.kt b/app/src/main/java/com/bingestats/app/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..31eeb7a --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/screens/SettingsScreen.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/bingestats/app/ui/theme/Color.kt b/app/src/main/java/com/bingestats/app/ui/theme/Color.kt new file mode 100644 index 0000000..ed7bf16 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/bingestats/app/ui/theme/Theme.kt b/app/src/main/java/com/bingestats/app/ui/theme/Theme.kt new file mode 100644 index 0000000..98ed326 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/bingestats/app/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/bingestats/app/ui/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..66317bd --- /dev/null +++ b/app/src/main/java/com/bingestats/app/ui/viewmodel/MainViewModel.kt @@ -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 = repository.watchStatsFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = WatchStats() + ) + + private val _uiState = MutableStateFlow(UiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _selectedProviderFilter = MutableStateFlow(null) // null = ALL + val selectedProviderFilter: StateFlow = _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().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() + + // 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.") + } + } +} diff --git a/app/src/test/java/com/bingestats/app/TitleParserTest.kt b/app/src/test/java/com/bingestats/app/TitleParserTest.kt new file mode 100644 index 0000000..d0cee5d --- /dev/null +++ b/app/src/test/java/com/bingestats/app/TitleParserTest.kt @@ -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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6fab765 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..72fd3c1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonFinalResIds=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..2a7693e --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8cb577c --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9d78b87 --- /dev/null +++ b/settings.gradle.kts @@ -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")