Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] Add timeline widget for android #196

Merged
merged 18 commits into from
Nov 27, 2024
Merged
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
6 changes: 6 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,19 @@ android {
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}

flutter {
source = "../.."
}

dependencies {
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
Expand Down
79 changes: 54 additions & 25 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,49 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that

<!--
io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
In most cases you can leave this as-is, but you if you want to provide
additional functionality it is fine to subclass or reimplement
FlutterApplication and put your custom class here. -->
<uses-permission
android:name="android.permission.INTERNET"/>
FlutterApplication and put your custom class here.
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name="${applicationName}"
android:label="OTL"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="true">
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="OTL">
<receiver
android:name=".TimetableWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_widget_info" />
</receiver>
<!-- <receiver-->
<!-- android:name=".NextLectureWidget"-->
<!-- android:exported="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
<!-- </intent-filter>-->

<!-- <meta-data-->
<!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/next_lecture_widget_info" />-->
<!-- </receiver>-->

<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="com.kuku.channel_talk_flutter.PushInterceptService"
android:exported="true"
>

<service
android:name="com.kuku.channel_talk_flutter.PushInterceptService"
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!--
Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
android:value="2" />
</application>

</manifest>
48 changes: 48 additions & 0 deletions android/app/src/main/java/org/sparcs/otlplus/NextLectureWidget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.sparcs.otlplus

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews
import org.sparcs.otlplus.api.NextLectureData

/**
* Implementation of App Widget functionality.
*/

class NextLectureWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateNextLectureWidget(context, appWidgetManager, appWidgetId)
}
}

override fun onEnabled(context: Context) {
// Enter relevant functionality for when the first widget is created
}

override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}

internal fun updateNextLectureWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
// Construct the RemoteViews object
RemoteViews(context.packageName, R.layout.next_lecture_widget).let {
it.setTextViewText(R.id.nextLectureDate, NextLectureData.nextLectureDate)
it.setTextViewText(R.id.nextLectureName, NextLectureData.nextLectureName)
it.setTextViewText(R.id.nextLecturePlace, NextLectureData.nextLecturePlace)
it.setTextViewText(R.id.nextLectureProfessor, NextLectureData.nextLectureProfessor)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, it)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.sparcs.otlplus

import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences

class SharedPreferenceUpdateListener(context: Context) {
private val sharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)

private val appWidgetManager = AppWidgetManager.getInstance(context)
private val componentName = ComponentName(context, TimetableWidget::class.java)

private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
val intent = Intent(context, TimetableWidget::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetManager.getAppWidgetIds(componentName))
}
context.sendBroadcast(intent)
}

fun register() {
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
}

fun unregister() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
}
}
116 changes: 116 additions & 0 deletions android/app/src/main/java/org/sparcs/otlplus/TimetableWidget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.sparcs.otlplus

import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.util.TypedValue
import android.widget.RemoteViews
import org.sparcs.otlplus.api.ApiLoader
import org.sparcs.otlplus.api.Lecture
import org.sparcs.otlplus.api.LocalTime
import org.sparcs.otlplus.api.TimetableData
import org.sparcs.otlplus.api.WeekDays
import org.sparcs.otlplus.constants.BlockColor

val timeTableColumns = listOf(
R.id.time_table_column_1,
R.id.time_table_column_2,
R.id.time_table_column_3,
R.id.time_table_column_4,
R.id.time_table_column_5,
)

data class TimeTableElement(
val length: Float,
val lecture: Lecture?
)

/**
* Implementation of App Widget functionality.
*/
class TimetableWidget : AppWidgetProvider() {
private val CHANNEL = "https://otl.sparcs.org"

override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
val apiLoader = ApiLoader(context)
val sessionUrl = "$CHANNEL/session/info"

apiLoader.get(sessionUrl) { dataString ->
// println(dataString)
val timetableData = TimetableData(dataString)

for (appWidgetId in appWidgetIds) {
updateTimetableWidget(context, appWidgetManager, appWidgetId, timetableData)
}
}
}
}

@SuppressLint("NewApi")
internal fun updateTimetableWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
timetableData: TimetableData,
) {
val views = RemoteViews(context.packageName, R.layout.timetable_widget)

for (timetableColumn in timeTableColumns) {
views.removeAllViews(timetableColumn)
}

val weekTimetable = createTimeTable(timetableData.lectures)

for ((weekday, dayTimetable) in weekTimetable.withIndex()) {
for (timeTableElement in dayTimetable) {
val blockView = when(timeTableElement.lecture) {
null -> RemoteViews(context.packageName, R.layout.blank_view)
else -> RemoteViews(context.packageName, BlockColor.getLayout(timeTableElement.lecture)).apply {
setTextViewText(R.id.timetable_block_lecture_name, timeTableElement.lecture.name)
}
}

blockView.setViewLayoutHeight(
R.id.timetable_block_root,
timeTableElement.length * 36,
TypedValue.COMPLEX_UNIT_DIP)

views.addView(timeTableColumns[weekday], blockView)
}
}

// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}

fun createTimeTable(lectures: List<Lecture>): List<List<TimeTableElement>> {
val timetable = List(5) { mutableListOf<TimeTableElement>() }

for (dayIndex in WeekDays.entries.toTypedArray().indices) {
if (dayIndex == 5) break
val day = WeekDays.entries[dayIndex]

val dailyLectures = lectures.flatMap { lecture ->
lecture.timeBlocks.filter { it.weekday == day }.map { it to lecture }
}.sortedBy { it.first.start.hoursFloat }

var currentTime = LocalTime(9, 0)

for ((timeBlock, lecture) in dailyLectures) {
if (timeBlock.start.hoursFloat > currentTime.hoursFloat) {
val freeTimeLength = timeBlock.start.hoursFloat - currentTime.hoursFloat
timetable[dayIndex].add(TimeTableElement(freeTimeLength, null))
}
val lectureLength = timeBlock.end.hoursFloat - timeBlock.start.hoursFloat
timetable[dayIndex].add(TimeTableElement(lectureLength, lecture))
currentTime = timeBlock.end
}
}

return timetable
}
50 changes: 50 additions & 0 deletions android/app/src/main/java/org/sparcs/otlplus/api/ApiLoader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.sparcs.otlplus.api

import android.content.Context
import okhttp3.*
import java.io.IOException
import java.util.concurrent.TimeUnit

class ApiLoader(context: Context) {
private val cookies = Cookies(context)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) // Connection timeout
.readTimeout(30, TimeUnit.SECONDS) // Read timeout
.writeTimeout(30, TimeUnit.SECONDS) // Write timeout
.build()

private val cookieHeader = cookies.header
private val csrfToken = cookies.token

fun get(url: String, then: (String) -> Unit) {
val request = Request.Builder()
.url(url)
.addHeader("cookie", cookieHeader ?: "")
.addHeader("X-CSRFToken", csrfToken ?: "")
.build()

// println("--------WIDGET: Call sent--------")
// println("Cookie: $cookieHeader")
// println("Token: $csrfToken")
// println("URL: $url")

client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
// println("--------WIDGET: Api call failed--------")
// e.printStackTrace()
}

override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
// println("--------WIDGET: Api call failed--------")
// println(response.body?.string())
return
}
// println("--------WIDGET: Got response--------")
val responseText = response.body?.string() ?: ""
// println(responseText)
then(responseText)
}
})
}
}
14 changes: 14 additions & 0 deletions android/app/src/main/java/org/sparcs/otlplus/api/Cookies.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.sparcs.otlplus.api

import android.content.Context

class Cookies(context: Context) {
var header: String?
var token: String?

init {
val sharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
header = sharedPreferences.getString("flutter.cookie_header", null)
token = sharedPreferences.getString("flutter.csrf_token", null)
}
}
Loading