diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b44eda4..63d99ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,9 @@ -import java.util.Properties -import java.io.FileInputStream - plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") } -val localProperties = Properties() -val localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localProperties.load(FileInputStream(localPropertiesFile)) -} - android { namespace = "com.example.githubwidget" compileSdk = 34 @@ -23,11 +14,6 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0" - - val token = localProperties.getProperty("github_token", "") - val username = localProperties.getProperty("github_username", "") - buildConfigField("String", "GITHUB_TOKEN", "\"$token\"") - buildConfigField("String", "GITHUB_USERNAME", "\"$username\"") } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5fda003..c5876fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,15 @@ android:supportsRtl="true" android:theme="@android:style/Theme.DeviceDefault"> + + + + + + + diff --git a/app/src/main/kotlin/com/example/githubwidget/GitHubFetcher.kt b/app/src/main/kotlin/com/example/githubwidget/GitHubFetcher.kt new file mode 100644 index 0000000..7e28aef --- /dev/null +++ b/app/src/main/kotlin/com/example/githubwidget/GitHubFetcher.kt @@ -0,0 +1,175 @@ +package com.example.githubwidget + +import android.util.Log +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString + +const val TAG_FETCHER = "GitHubFetcher" + +private val jsonParser = Json { + ignoreUnknownKeys = true + coerceInputValues = true +} + +object GitHubFetcher { + private const val TAG = TAG_FETCHER + + @Serializable + data class GraphQLRequest(val query: String) + + @Serializable + data class GithubResponse( + val data: DataBlock? = null, + val errors: List? = null, + val message: String? = null // For API error responses + ) + + @Serializable + data class GraphQLError( + val message: String + ) + + @Serializable + data class DataBlock(val user: UserBlock? = null) + + @Serializable + data class UserBlock(val contributionsCollection: ContributionsCollection? = null) + + @Serializable + data class ContributionsCollection(val contributionCalendar: ContributionCalendar? = null) + + @Serializable + data class ContributionCalendar(val weeks: List = emptyList()) + + @Serializable + data class Week(val contributionDays: List = emptyList()) + + @Serializable + data class ContributionDay(val contributionCount: Int, val date: String) + + suspend fun fetchContributions(username: String): List? { + Log.d(TAG, "Starting fetch for username: $username") + + if (username.isBlank()) { + Log.w(TAG, "Username is blank") + return null + } + + val client = HttpClient(Android) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + } + + val query = """ + query { + user(login: "$username") { + contributionsCollection { + contributionCalendar { + weeks { + contributionDays { + contributionCount + date + } + } + } + } + } + } + """.trimIndent() + + return try { + Log.d(TAG, "Sending GraphQL query to GitHub API") + + val httpResponse = client.post("https://api.github.com/graphql") { + headers { + append("User-Agent", "GitHubWidget/1.0") + append("Accept", "application/json") + } + contentType(ContentType.Application.Json) + setBody(GraphQLRequest(query)) + } + + val responseBody = httpResponse.bodyAsText() + Log.d(TAG, "Raw response body: $responseBody") + + val response: GithubResponse = jsonParser.decodeFromString(responseBody) + Log.d(TAG, "Parsed response: $response") + Log.d(TAG, "Got response from GitHub API") + + // Check for API errors (rate limit, etc) + if (response.message != null) { + Log.e(TAG, "API Error: ${response.message}") + return null + } + + // Check for GraphQL errors + if (!response.errors.isNullOrEmpty()) { + Log.e(TAG, "GraphQL Errors: ${response.errors}") + response.errors.forEach { error -> + Log.e(TAG, "Error: ${error.message}") + } + return null + } + + // Check for null user + if (response.data == null) { + Log.e(TAG, "Response data is null - response was: $response") + return null + } + + if (response.data.user == null) { + Log.e(TAG, "User '$username' not found or API blocked request") + return null + } + + val weeks = response.data.user.contributionsCollection?.contributionCalendar?.weeks ?: emptyList() + + if (weeks.isEmpty()) { + Log.w(TAG, "No weeks data returned for user: $username") + return null + } + + Log.d(TAG, "Successfully fetched ${weeks.size} weeks of data") + + val result = weeks.flatMapIndexed { index, week -> + val days = week.contributionDays.map { it.contributionCount } + if (days.size < 7) { + if (index == 0) { + List(7 - days.size) { 0 } + days + } else { + days + List(7 - days.size) { 0 } + } + } else { + days + } + } + + Log.d(TAG, "Returning ${result.size} total contribution counts") + result + + } catch (e: Exception) { + Log.e(TAG, "Exception during fetch: ${e.message}", e) + e.printStackTrace() + null + } finally { + client.close() + } + } +} diff --git a/app/src/main/kotlin/com/example/githubwidget/GithubWidget.kt b/app/src/main/kotlin/com/example/githubwidget/GithubWidget.kt index a5933c8..ca10d70 100644 --- a/app/src/main/kotlin/com/example/githubwidget/GithubWidget.kt +++ b/app/src/main/kotlin/com/example/githubwidget/GithubWidget.kt @@ -1,24 +1,19 @@ package com.example.githubwidget import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.glance.GlanceId import androidx.glance.GlanceModifier -import androidx.glance.Image -import androidx.glance.ImageProvider import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.provideContent -import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Box -import androidx.glance.layout.ContentScale +import androidx.glance.layout.Column import androidx.glance.layout.fillMaxSize import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextStyle import kotlinx.serialization.json.Json import kotlinx.serialization.decodeFromString @@ -27,72 +22,42 @@ class GithubWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val sharedPrefs = context.getSharedPreferences("github_widget_prefs", Context.MODE_PRIVATE) val dataJson = sharedPrefs.getString("contributions", "[]") ?: "[]" + val username = sharedPrefs.getString("github_username", "") ?: "" + val contributions: List = try { Json.decodeFromString(dataJson) } catch (e: Exception) { emptyList() } - // Draw graph manually out of Glance's layout engine to avoid RemoteViews's strict rendering limits - val weeksCount = 53 - val daysInWeek = 7 - - val dotRadius = 7f - val dotSize = dotRadius * 2 - val gap = 4f - val w = (weeksCount * (dotSize + gap) - gap).toInt() - val h = (daysInWeek * (dotSize + gap) - gap).toInt() - - val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - val paintLevel0 = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.TRANSPARENT } - val paintLevel1 = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.argb(102, 255, 255, 255) } // ~40% - val paintLevel2 = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.argb(153, 255, 255, 255) } // ~60% - val paintLevel3 = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.argb(204, 255, 255, 255) } // ~80% - val paintLevel4 = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.WHITE } - - val paddedContributions = contributions.toMutableList() - val totalRequired = weeksCount * daysInWeek - if (paddedContributions.size < totalRequired) { - val missing = totalRequired - paddedContributions.size - paddedContributions.addAll(0, List(missing) { 0 }) - } - val finalData = paddedContributions.takeLast(totalRequired) - - for (weekIndex in 0 until weeksCount) { - for (dayIndex in 0 until daysInWeek) { - val dataIndex = weekIndex * daysInWeek + dayIndex - val count = finalData.getOrNull(dataIndex) ?: 0 - val px = weekIndex * (dotSize + gap) - val py = dayIndex * (dotSize + gap) - val paint = when { - count == 0 -> paintLevel0 - count in 1..2 -> paintLevel1 - count in 3..5 -> paintLevel2 - count in 6..9 -> paintLevel3 - else -> paintLevel4 - } - canvas.drawRoundRect( - RectF(px, py, px + dotSize, py + dotSize), - dotRadius, dotRadius, paint - ) - } - } - provideContent { Box( modifier = GlanceModifier .fillMaxSize() - .background(Color.Black) - .padding(8.dp), + .padding(16.dp), contentAlignment = Alignment.Center ) { - Image( - provider = ImageProvider(bitmap), - contentDescription = "GitHub Contributions", - contentScale = ContentScale.Fit - ) + if (username.isBlank()) { + Text("Add GitHub username") + } else if (contributions.isEmpty()) { + Text("Loading contributions...") + } else { + Column( + modifier = GlanceModifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + username, + style = TextStyle(fontSize = 14.sp) + ) + Text( + "${contributions.size} days tracked", + style = TextStyle(fontSize = 10.sp) + ) + } + } } } } } + diff --git a/app/src/main/kotlin/com/example/githubwidget/PreferencesManager.kt b/app/src/main/kotlin/com/example/githubwidget/PreferencesManager.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/kotlin/com/example/githubwidget/SettingsActivity.kt b/app/src/main/kotlin/com/example/githubwidget/SettingsActivity.kt new file mode 100644 index 0000000..dfcddd0 --- /dev/null +++ b/app/src/main/kotlin/com/example/githubwidget/SettingsActivity.kt @@ -0,0 +1,94 @@ +package com.example.githubwidget + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString + +class SettingsActivity : Activity() { + companion object { + private const val TAG = "SettingsActivity" + } + private lateinit var usernameInput: EditText + private lateinit var saveButton: Button + private lateinit var preferencesManager: PreferencesManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + preferencesManager = PreferencesManager(this) + + usernameInput = findViewById(R.id.username_input) + saveButton = findViewById(R.id.save_button) + + // Load existing username + usernameInput.setText(preferencesManager.getUsername()) + + saveButton.setOnClickListener { + val username = usernameInput.text.toString().trim() + Log.d(TAG, "Save button clicked with username: $username") + + if (username.isBlank()) { + Toast.makeText(this, "Please enter a GitHub username", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + // Save username + preferencesManager.setUsername(username) + Log.d(TAG, "Username saved to preferences") + + // Show loading toast + Toast.makeText(this, "Fetching contributions...", Toast.LENGTH_SHORT).show() + + // Disable button during fetch + saveButton.isEnabled = false + + // Fetch data immediately in background + Log.d(TAG, "Starting coroutine to fetch contributions") + CoroutineScope(Dispatchers.IO).launch { + try { + Log.d(TAG, "Calling GitHubFetcher.fetchContributions($username)") + val data = GitHubFetcher.fetchContributions(username) + + if (data != null) { + Log.d(TAG, "Successfully fetched ${data.size} contribution records") + preferencesManager.setContributions(Json.encodeToString(data)) + Log.d(TAG, "Contributions saved to preferences") + + runOnUiThread { + // Trigger widget update via broadcast + val intent = android.content.Intent(android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE) + intent.setClass(this@SettingsActivity, WidgetReceiver::class.java) + sendBroadcast(intent) + Log.d(TAG, "Sent update broadcast to widget") + + Toast.makeText(this@SettingsActivity, "Widget updated!", Toast.LENGTH_SHORT).show() + finish() + } + } else { + Log.e(TAG, "GitHubFetcher returned null for username: $username") + runOnUiThread { + saveButton.isEnabled = true + Toast.makeText(this@SettingsActivity, "Failed to fetch contributions. Check username or network.", Toast.LENGTH_LONG).show() + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in fetch coroutine", e) + runOnUiThread { + saveButton.isEnabled = true + Toast.makeText(this@SettingsActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + } +} + diff --git a/app/src/main/kotlin/com/example/githubwidget/WidgetReceiver.kt b/app/src/main/kotlin/com/example/githubwidget/WidgetReceiver.kt index 3744c03..5d29c31 100644 --- a/app/src/main/kotlin/com/example/githubwidget/WidgetReceiver.kt +++ b/app/src/main/kotlin/com/example/githubwidget/WidgetReceiver.kt @@ -4,6 +4,10 @@ import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class WidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = GithubWidget() @@ -14,8 +18,13 @@ class WidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - // Ensure background fetch is scheduled when widget is updated/added + + // Immediately update the Glance widget + CoroutineScope(Dispatchers.Main).launch { + GithubWidget().updateAll(context) + } + + // Also schedule periodic updates WidgetWorker.schedule(context) - WidgetWorker.fetchNow(context) } } diff --git a/app/src/main/kotlin/com/example/githubwidget/WidgetWorker.kt b/app/src/main/kotlin/com/example/githubwidget/WidgetWorker.kt index 25552d5..13eb626 100644 --- a/app/src/main/kotlin/com/example/githubwidget/WidgetWorker.kt +++ b/app/src/main/kotlin/com/example/githubwidget/WidgetWorker.kt @@ -7,17 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.encodeToString import java.util.concurrent.TimeUnit @@ -25,96 +14,29 @@ import java.util.concurrent.TimeUnit class WidgetWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { - @Serializable - data class GraphQLRequest(val query: String) - - @Serializable - data class GithubResponse(val data: DataBlock? = null) - - @Serializable - data class DataBlock(val user: UserBlock? = null) - - @Serializable - data class UserBlock(val contributionsCollection: ContributionsCollection? = null) - - @Serializable - data class ContributionsCollection(val contributionCalendar: ContributionCalendar? = null) - - @Serializable - data class ContributionCalendar(val weeks: List = emptyList()) - - @Serializable - data class Week(val contributionDays: List = emptyList()) - - @Serializable - data class ContributionDay(val contributionCount: Int, val date: String) - override suspend fun doWork(): Result { - val token = BuildConfig.GITHUB_TOKEN - val username = BuildConfig.GITHUB_USERNAME - - if (token.isBlank() || username.isBlank()) { - return Result.failure() - } + val preferencesManager = PreferencesManager(applicationContext) + val username = preferencesManager.getUsername() - val client = HttpClient(Android) { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - }) - } + if (username.isBlank()) { + // No username set, skip update + return Result.success() } - val query = """ - query { - user(login: "$username") { - contributionsCollection { - contributionCalendar { - weeks { - contributionDays { - contributionCount - date - } - } - } - } - } - } - """.trimIndent() - return try { - val response: GithubResponse = client.post("https://api.github.com/graphql") { - header("Authorization", "bearer $token") - contentType(ContentType.Application.Json) - setBody(GraphQLRequest(query)) - }.body() - - val weeks = response.data?.user?.contributionsCollection?.contributionCalendar?.weeks ?: emptyList() - val counts = weeks.flatMapIndexed { index, week -> - val days = week.contributionDays.map { it.contributionCount } - if (days.size < 7) { - if (index == 0) { - List(7 - days.size) { 0 } + days - } else { - days + List(7 - days.size) { 0 } - } - } else { - days - } + val contributions = GitHubFetcher.fetchContributions(username) + + if (contributions != null) { + preferencesManager.setContributions(Json.encodeToString(contributions)) + // Update widget + GithubWidget().updateAll(applicationContext) + Result.success() + } else { + Result.retry() } - - val sharedPrefs = applicationContext.getSharedPreferences("github_widget_prefs", Context.MODE_PRIVATE) - sharedPrefs.edit().putString("contributions", Json.encodeToString(counts)).apply() - - // Update widget - GithubWidget().updateAll(applicationContext) - - Result.success() } catch (e: Exception) { e.printStackTrace() Result.retry() - } finally { - client.close() } } @@ -139,3 +61,4 @@ class WidgetWorker(appContext: Context, workerParams: WorkerParameters) : } } } + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..01377de --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,32 @@ + + + + + + + +