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 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml
index c6d5115..296f6fc 100644
--- a/app/src/main/res/xml/widget_info.xml
+++ b/app/src/main/res/xml/widget_info.xml
@@ -8,4 +8,5 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:targetCellWidth="4"
- android:targetCellHeight="2" />
+ android:targetCellHeight="2"
+ android:configure="com.example.githubwidget.SettingsActivity" />