feat: initial commit for BingeStats Android app (Kotlin + Compose + TMDB + Multi-Provider architecture)

This commit is contained in:
Janik Dietz 2026-08-04 15:04:40 +02:00
commit dce3b2258b
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
35 changed files with 2860 additions and 0 deletions

77
app/build.gradle.kts Normal file
View 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)
}

View 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>

View 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)
}
}

View 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)
}
}
}
}

View 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()
}

View 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()
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}

View 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
}
}

View file

@ -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
)

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View 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)))
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View 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
)
}
}
}
}
}
}

View file

@ -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
)
}
}

View 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
)
}
}
}

View file

@ -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)
}
}

View file

@ -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")
}
}
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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)
}
}
}
}
}

View 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)

View 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
)
}

View file

@ -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.")
}
}
}

View 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)
}
}