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