Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">

<activity
android:name=".SettingsActivity"
android:exported="true">
<intent-filter>
<action android:name="com.example.githubwidget.SETTINGS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<receiver
android:name=".WidgetReceiver"
android:exported="true">
Expand Down
175 changes: 175 additions & 0 deletions app/src/main/kotlin/com/example/githubwidget/GitHubFetcher.kt
Original file line number Diff line number Diff line change
@@ -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<GraphQLError>? = 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<Week> = emptyList())

@Serializable
data class Week(val contributionDays: List<ContributionDay> = emptyList())

@Serializable
data class ContributionDay(val contributionCount: Int, val date: String)

suspend fun fetchContributions(username: String): List<Int>? {
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()
}
}
}
89 changes: 27 additions & 62 deletions app/src/main/kotlin/com/example/githubwidget/GithubWidget.kt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<Int> = 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)
)
}
}
}
}
}
}

Empty file.
Loading