Skip to content

Commit

Permalink
Add Android Homescreen widget (#214)
Browse files Browse the repository at this point in the history
* ⚗️ Add prayer time widget layout

* 🐛 Fix incorrect id format

* ➕ Add home_widget to update data from Dart side

* ✨ Make widget update at midnight

* ✨ Complete horizontal widget #9

* 💄 Add vertical layout widget

* ✨ Vertical layout widget functionality

* 🍱 Update widget preview images

* ✨ (Widget) Add debug page to retrievd saved widget data

start recording widget last updated timestamp

* 🌐 Update widget description with translations

* ✨ (Homescreen widget) Handle id stored data is out of date

Seperate xml preview layout

* 🔥 Remove unused imports
  • Loading branch information
iqfareez authored Dec 26, 2023
1 parent 2a6d33c commit 29aee0b
Show file tree
Hide file tree
Showing 37 changed files with 1,473 additions and 22 deletions.
9 changes: 6 additions & 3 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ if (keystorePropertiesFile.exists()) {
}

android {
compileSdkVersion 33
compileSdkVersion 34

lintOptions {
disable 'InvalidPackage'
Expand Down Expand Up @@ -73,6 +73,9 @@ android {
signingConfig signingConfigs.release
}
}
buildFeatures {
viewBinding true
}
}

flutter {
Expand All @@ -83,7 +86,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
// https://github.com/flutter/flutter/issues/110658
implementation "androidx.window:window:1.0.0"
implementation "androidx.window:window:1.0.0"
implementation 'androidx.window:window-java:1.0.0'

implementation 'com.google.code.gson:gson:2.10.1'
}
48 changes: 39 additions & 9 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="live.iqfareez.waktusolatmalaysia">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
Expand All @@ -17,11 +18,34 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<receiver
android:name=".SolatVerticalWidget"
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/solat_vertical_widget_info" />
</receiver>
<receiver
android:name=".SolatHorizontalWidget"
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/solat_horizontal_widget_info" />
</receiver>

<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
Expand All @@ -32,19 +56,24 @@
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Notification receiver -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
</activity> <!-- Notification receiver -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<!-- AdMob App ID -->
Expand All @@ -57,4 +86,5 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package live.iqfareez.waktusolatmalaysia

import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetPlugin
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone


private const val ACTION_SCHEDULED_UPDATE = "live.iqfareez.waktusolatmalaysia.SCHEDULED_UPDATE"
private const val LOG_TAG = "MPT_Widget_Horizontal"

/**
* Implementation of App Widget functionality.
*/
class SolatHorizontalWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
val widgetData = HomeWidgetPlugin.getData(context)
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
Log.i(LOG_TAG, "onUpdate: SolatHorizontalWidget called")
updateAppWidget(context, appWidgetManager, appWidgetId, widgetData, R.layout.solat_horizontal_widget)
}

scheduleNextUpdate(context);
}

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
}

override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent);
if (intent.action.equals(ACTION_SCHEDULED_UPDATE)) {
val manager = AppWidgetManager.getInstance(context)
val ids =
manager.getAppWidgetIds(ComponentName(context, SolatHorizontalWidget::class.java))
onUpdate(context, manager, ids)
}
}
}

internal fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
widgetData: SharedPreferences,
layoutId: Int
) {
// Construct the RemoteViews object
val views = RemoteViews(context.packageName, layoutId)

val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pendingIntent = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_IMMUTABLE
)

// Set the click listener for the widget
views.setOnClickPendingIntent(android.R.id.background, pendingIntent)

// Parse the JSON in SharedPreferences
val prayerData = widgetData.getString("prayer_data", null);

// If data not available, display outdated layout
if (prayerData == null) {
views.setViewVisibility(R.id.outdated_text, View.VISIBLE);
views.setViewVisibility(R.id.prayer_layout, View.GONE);
return;
}

val parsed = JSONObject(prayerData)

// if the data is outdated (the month & year doesn't match), show outdated layout
if (!isDateValid("${parsed.get("month")}-${parsed.get("year")}")) {
Log.i(LOG_TAG, "updateAppWidget: Data ${parsed.get("month")}-${parsed.get("year")} is invalid");
views.setViewVisibility(R.id.outdated_text, View.VISIBLE);
views.setViewVisibility(R.id.prayer_layout, View.GONE);
return;
}

Log.i(LOG_TAG, "updateAppWidget: Reading SP json ${parsed.get("zone")}, ${parsed.get("month")}-${parsed.get("year")} ")

val prayers = parsed.getJSONArray("prayers")

val calendar = Calendar.getInstance()
val todayIndex = calendar.get(Calendar.DAY_OF_WEEK) - 1;
val todayPrayer: JSONObject = prayers.get(todayIndex) as JSONObject;

val subuhTime = todayPrayer.getLong("fajr")
val zohorTime = todayPrayer.getLong("dhuhr")
val asarTime = todayPrayer.getLong("asr")
val maghribTime = todayPrayer.getLong("maghrib")
val isyakTime = todayPrayer.getLong("isha")

val gmt8TimeZone = TimeZone.getTimeZone("GMT+8")
val timeFormat = SimpleDateFormat("h:mm a")
timeFormat.timeZone = gmt8TimeZone

fun formatTime(timeInMillis: Long): String {
val date = Date(timeInMillis)
return timeFormat.format(date)
}

val formattedSubuhTime = formatTime(subuhTime)
val formattedZohorTime = formatTime(zohorTime)
val formattedAsarTime = formatTime(asarTime)
val formattedMaghribTime = formatTime(maghribTime)
val formattedIsyakTime = formatTime(isyakTime)

val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
dateFormat.timeZone = TimeZone.getTimeZone("Asia/Kuala_Lumpur") // Set Malaysia timezone
val formattedDate = dateFormat.format(Date())

val widgetTitle = "${parsed.get("zone")}: ${widgetData.getString("widget_title", null)}"

// Set content
views.setTextViewText(R.id.widget_date, formattedDate)

views.setTextViewText(
R.id.widget_title, widgetTitle
?: "Please open app to set widget data"
)
views.setTextViewText(
R.id.subuh_time, formattedSubuhTime
)
views.setTextViewText(
R.id.zuhur_time, formattedZohorTime
)
views.setTextViewText(
R.id.asar_time, formattedAsarTime
)
views.setTextViewText(
R.id.maghrib_time, formattedMaghribTime
)
views.setTextViewText(
R.id.isyak_time, formattedIsyakTime
)

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

// Credit to: https://stackoverflow.com/a/37901697/13617136
private fun scheduleNextUpdate(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// Substitute AppWidget for whatever you named your AppWidgetProvider subclass
val intent = Intent(context, SolatHorizontalWidget::class.java)
intent.setAction(ACTION_SCHEDULED_UPDATE)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)

// Get a calendar instance for midnight tomorrow.
val midnight: Calendar = Calendar.getInstance()
midnight.set(Calendar.HOUR_OF_DAY, 0)
midnight.set(Calendar.MINUTE, 0)
// Schedule one second after midnight, to be sure we are in the right day next time this
// method is called. Otherwise, we risk calling onUpdate multiple times within a few
// milliseconds
midnight.set(Calendar.SECOND, 1)
midnight.set(Calendar.MILLISECOND, 0)
midnight.add(Calendar.DAY_OF_YEAR, 1)

// For API 19 and later, set may fire the intent a little later to save battery,
// setExact ensures the intent goes off exactly at midnight.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
alarmManager[AlarmManager.RTC_WAKEUP, midnight.getTimeInMillis()] = pendingIntent
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
midnight.timeInMillis,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
midnight.timeInMillis,
pendingIntent
)
}
}
}
}

// Function to check the validity of the given month and year
fun isDateValid(jsonDate: String): Boolean {
// The YearMonth.parse only accepts month with title-case eg Jan, Feb etc.
fun toTitleCase(input: String): String {
return input.lowercase().replaceFirstChar { it.uppercase() }
}

val jsonFixed = toTitleCase(jsonDate);
return try {
// Parse the JSON date into YearMonth
val formatter = DateTimeFormatter.ofPattern("MMM-yyyy")
val date = YearMonth.parse(jsonFixed, formatter)

// Get the current month and year
val currentMonthYear = YearMonth.now()

// Check if the parsed date is after or equal to the current month and year
!date.isBefore(currentMonthYear) && !date.isAfter(currentMonthYear);
} catch (e: Exception) {
// Handle parsing or other exceptions
e.printStackTrace()
false
}
}
Loading

0 comments on commit 29aee0b

Please sign in to comment.