From 6977c844772c6dc7313aee134b124f4c53229528 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:02:19 +0000 Subject: [PATCH 01/19] Refactor to Jetpack Compose, Hilt, and Room --- app/build.gradle | 48 +- app/src/main/AndroidManifest.xml | 1 + .../com/example/theloop/DashboardAdapter.java | 283 ------ .../com/example/theloop/DayAheadWidget.java | 52 -- .../com/example/theloop/DayAheadWidget.kt | 71 ++ .../com/example/theloop/MainActivity.java | 862 ------------------ .../java/com/example/theloop/MainActivity.kt | 39 + .../java/com/example/theloop/MainViewModel.kt | 404 +++----- .../com/example/theloop/TheLoopApplication.kt | 18 + .../com/example/theloop/WidgetUpdateWorker.kt | 162 ++-- .../example/theloop/data/local/AppDatabase.kt | 17 + .../theloop/data/local/dao/ArticleDao.kt | 23 + .../data/local/dao/CalendarEventDao.kt | 20 + .../theloop/data/local/dao/WeatherDao.kt | 20 + .../data/local/entity/ArticleEntity.kt | 13 + .../data/local/entity/CalendarEventEntity.kt | 14 + .../data/local/entity/WeatherEntity.kt | 11 + .../data/repository/CalendarRepository.kt | 92 ++ .../data/repository/FunFactRepository.kt | 21 + .../theloop/data/repository/NewsRepository.kt | 69 ++ .../data/repository/WeatherRepository.kt | 55 ++ .../com/example/theloop/di/DatabaseModule.kt | 38 + .../com/example/theloop/di/NetworkModule.kt | 71 ++ .../com/example/theloop/models/Article.java | 28 - .../com/example/theloop/models/Article.kt | 9 + .../example/theloop/models/CalendarEvent.java | 43 - .../example/theloop/models/CalendarEvent.kt | 10 + .../theloop/models/CurrentWeather.java | 28 - .../example/theloop/models/CurrentWeather.kt | 9 + .../example/theloop/models/DailyWeather.java | 36 - .../example/theloop/models/DailyWeather.kt | 10 + .../theloop/models/FunFactResponse.java | 26 - .../example/theloop/models/FunFactResponse.kt | 9 + .../example/theloop/models/NewsResponse.java | 40 - .../example/theloop/models/NewsResponse.kt | 14 + .../theloop/models/WeatherResponse.java | 35 - .../example/theloop/models/WeatherResponse.kt | 10 + .../java/com/example/theloop/ui/HomeScreen.kt | 99 ++ .../theloop/ui/components/CalendarCard.kt | 50 + .../example/theloop/ui/components/NewsCard.kt | 32 + .../theloop/ui/components/WeatherCard.kt | 58 ++ .../com/example/theloop/ui/theme/Theme.kt | 49 + .../java/com/example/theloop/ui/theme/Type.kt | 24 + .../com/example/theloop/utils/AppUtils.java | 88 -- .../com/example/theloop/utils/AppUtils.kt | 66 ++ app/src/main/res/layout/activity_main.xml | 14 - app/src/main/res/layout/card_calendar.xml | 61 -- app/src/main/res/layout/card_day_ahead.xml | 52 -- app/src/main/res/layout/card_fun_fact.xml | 33 - app/src/main/res/layout/card_headlines.xml | 118 --- app/src/main/res/layout/card_weather.xml | 137 --- .../main/res/layout/item_calendar_event.xml | 52 -- .../main/res/layout/item_daily_forecast.xml | 50 - app/src/main/res/layout/item_headline.xml | 26 - .../main/res/layout/item_settings_footer.xml | 18 - app/src/main/res/values/colors.xml | 11 - .../example/theloop/utils/AppUtilsTest.java | 62 -- .../com/example/theloop/utils/AppUtilsTest.kt | 55 ++ settings.gradle | 2 + 59 files changed, 1336 insertions(+), 2532 deletions(-) delete mode 100644 app/src/main/java/com/example/theloop/DashboardAdapter.java delete mode 100644 app/src/main/java/com/example/theloop/DayAheadWidget.java create mode 100644 app/src/main/java/com/example/theloop/DayAheadWidget.kt delete mode 100644 app/src/main/java/com/example/theloop/MainActivity.java create mode 100644 app/src/main/java/com/example/theloop/MainActivity.kt create mode 100644 app/src/main/java/com/example/theloop/TheLoopApplication.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/AppDatabase.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/dao/CalendarEventDao.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/dao/WeatherDao.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/entity/ArticleEntity.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/entity/CalendarEventEntity.kt create mode 100644 app/src/main/java/com/example/theloop/data/local/entity/WeatherEntity.kt create mode 100644 app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt create mode 100644 app/src/main/java/com/example/theloop/data/repository/FunFactRepository.kt create mode 100644 app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt create mode 100644 app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt create mode 100644 app/src/main/java/com/example/theloop/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/example/theloop/di/NetworkModule.kt delete mode 100644 app/src/main/java/com/example/theloop/models/Article.java create mode 100644 app/src/main/java/com/example/theloop/models/Article.kt delete mode 100644 app/src/main/java/com/example/theloop/models/CalendarEvent.java create mode 100644 app/src/main/java/com/example/theloop/models/CalendarEvent.kt delete mode 100644 app/src/main/java/com/example/theloop/models/CurrentWeather.java create mode 100644 app/src/main/java/com/example/theloop/models/CurrentWeather.kt delete mode 100644 app/src/main/java/com/example/theloop/models/DailyWeather.java create mode 100644 app/src/main/java/com/example/theloop/models/DailyWeather.kt delete mode 100644 app/src/main/java/com/example/theloop/models/FunFactResponse.java create mode 100644 app/src/main/java/com/example/theloop/models/FunFactResponse.kt delete mode 100644 app/src/main/java/com/example/theloop/models/NewsResponse.java create mode 100644 app/src/main/java/com/example/theloop/models/NewsResponse.kt delete mode 100644 app/src/main/java/com/example/theloop/models/WeatherResponse.java create mode 100644 app/src/main/java/com/example/theloop/models/WeatherResponse.kt create mode 100644 app/src/main/java/com/example/theloop/ui/HomeScreen.kt create mode 100644 app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt create mode 100644 app/src/main/java/com/example/theloop/ui/components/NewsCard.kt create mode 100644 app/src/main/java/com/example/theloop/ui/components/WeatherCard.kt create mode 100644 app/src/main/java/com/example/theloop/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/theloop/ui/theme/Type.kt delete mode 100644 app/src/main/java/com/example/theloop/utils/AppUtils.java create mode 100644 app/src/main/java/com/example/theloop/utils/AppUtils.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/card_calendar.xml delete mode 100644 app/src/main/res/layout/card_day_ahead.xml delete mode 100644 app/src/main/res/layout/card_fun_fact.xml delete mode 100644 app/src/main/res/layout/card_headlines.xml delete mode 100644 app/src/main/res/layout/card_weather.xml delete mode 100644 app/src/main/res/layout/item_calendar_event.xml delete mode 100644 app/src/main/res/layout/item_daily_forecast.xml delete mode 100644 app/src/main/res/layout/item_headline.xml delete mode 100644 app/src/main/res/layout/item_settings_footer.xml delete mode 100644 app/src/main/res/values/colors.xml delete mode 100644 app/src/test/java/com/example/theloop/utils/AppUtilsTest.java create mode 100644 app/src/test/java/com/example/theloop/utils/AppUtilsTest.kt diff --git a/app/build.gradle b/app/build.gradle index 616d61f..7d93b41 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,8 @@ plugins { - id 'org.jetbrains.kotlin.android' version '1.9.10' id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' version '1.9.10' + id 'com.google.dagger.hilt.android' } android { @@ -18,6 +20,14 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.3" + } + buildTypes { release { minifyEnabled false @@ -75,6 +85,38 @@ dependencies { implementation 'com.google.android.gms:play-services-location:21.0.1' // ViewModel and LiveData - implementation 'androidx.lifecycle:lifecycle-viewmodel:2.6.2' - implementation 'androidx.lifecycle:lifecycle-livedata:2.6.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0" + + // Compose + def composeBom = platform('androidx.compose:compose-bom:2024.02.01') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.compose.material:material-icons-extended' + + // Hilt + implementation "com.google.dagger:hilt-android:2.51.1" + kapt "com.google.dagger:hilt-compiler:2.51.1" + implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' + implementation 'androidx.hilt:hilt-work:1.2.0' + kapt 'androidx.hilt:hilt-compiler:1.2.0' + + // Room + def room_version = "2.6.1" + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-ktx:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + // Coil + implementation("io.coil-kt:coil-compose:2.6.0") + + // DataStore + implementation "androidx.datastore:datastore-preferences:1.0.0" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 42761eb..ca9f2dd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ { - - static final int TYPE_HEADER = 0; - static final int TYPE_WEATHER = 1; - static final int TYPE_HEADLINES = 2; - static final int TYPE_CALENDAR = 3; - static final int TYPE_FUN_FACT = 4; - static final int TYPE_HEALTH = 5; - static final int TYPE_UK_NEWS = 6; - static final int TYPE_FOOTER = 7; - - // Callbacks for data binding - public interface Binder { - void bindHeader(HeaderViewHolder holder); - void bindWeather(WeatherViewHolder holder); - void bindHeadlines(HeadlinesViewHolder holder); - void bindUkNews(HeadlinesViewHolder holder); - void bindCalendar(CalendarViewHolder holder); - void bindFunFact(FunFactViewHolder holder); - void bindHealth(HealthViewHolder holder); - void bindFooter(FooterViewHolder holder); - } - - private final Binder binder; - private final String[] sectionOrder; // E.g., {"headlines", "calendar", "fun_fact", "health"} - - public DashboardAdapter(Binder binder, String[] sectionOrder) { - this.binder = binder; - this.sectionOrder = sectionOrder; - } - - @Override - public int getItemViewType(int position) { - if (position == 0) return TYPE_HEADER; - if (position == 1) return TYPE_WEATHER; - - if (position == getItemCount() - 1) return TYPE_FOOTER; - - // Remaining positions map to sectionOrder - String section = sectionOrder[position - 2]; - return switch (section) { - case MainActivity.SECTION_HEADLINES -> TYPE_HEADLINES; - case MainActivity.SECTION_UK_NEWS -> TYPE_UK_NEWS; - case MainActivity.SECTION_CALENDAR -> TYPE_CALENDAR; - case MainActivity.SECTION_FUN_FACT -> TYPE_FUN_FACT; - case MainActivity.SECTION_HEALTH -> TYPE_HEALTH; - default -> -1; // Should not happen - }; - } - - @Override - public int getItemCount() { - return 2 + sectionOrder.length + 1; // Header + Weather + Sections + Footer - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - return switch (viewType) { - case TYPE_HEADER -> new HeaderViewHolder(inflater.inflate(R.layout.card_day_ahead, parent, false)); - case TYPE_WEATHER -> new WeatherViewHolder(inflater.inflate(R.layout.card_weather, parent, false)); - case TYPE_HEADLINES -> new HeadlinesViewHolder(inflater.inflate(R.layout.card_headlines, parent, false)); - case TYPE_UK_NEWS -> new HeadlinesViewHolder(inflater.inflate(R.layout.card_headlines, parent, false)); - case TYPE_CALENDAR -> new CalendarViewHolder(inflater.inflate(R.layout.card_calendar, parent, false)); - case TYPE_FUN_FACT -> new FunFactViewHolder(inflater.inflate(R.layout.card_fun_fact, parent, false)); - case TYPE_HEALTH -> new HealthViewHolder(inflater.inflate(R.layout.card_health, parent, false)); - case TYPE_FOOTER -> new FooterViewHolder(inflater.inflate(R.layout.item_settings_footer, parent, false)); - default -> throw new IllegalArgumentException("Invalid view type"); - }; - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - if (holder instanceof HeaderViewHolder headerHolder) { - binder.bindHeader(headerHolder); - } else if (holder instanceof WeatherViewHolder weatherHolder) { - binder.bindWeather(weatherHolder); - } else if (holder instanceof HeadlinesViewHolder headlinesHolder) { - if (holder.getItemViewType() == TYPE_UK_NEWS) { - binder.bindUkNews(headlinesHolder); - } else { - binder.bindHeadlines(headlinesHolder); - } - } else if (holder instanceof CalendarViewHolder calendarHolder) { - binder.bindCalendar(calendarHolder); - } else if (holder instanceof FunFactViewHolder funFactHolder) { - binder.bindFunFact(funFactHolder); - } else if (holder instanceof HealthViewHolder healthHolder) { - binder.bindHealth(healthHolder); - } else if (holder instanceof FooterViewHolder footerHolder) { - binder.bindFooter(footerHolder); - } else { - throw new IllegalStateException("Unhandled ViewHolder type: " + holder.getClass().getName()); - } - } - - // ViewHolders - static class HeaderViewHolder extends RecyclerView.ViewHolder { - final TextView greeting; - final TextView summary; - final ImageButton playButton; - - HeaderViewHolder(View v) { - super(v); - greeting = v.findViewById(R.id.day_ahead_greeting); - summary = v.findViewById(R.id.day_ahead_summary); - playButton = v.findViewById(R.id.day_ahead_play_button); - } - } - - static class WeatherViewHolder extends RecyclerView.ViewHolder { - final ProgressBar progressBar; - final TextView errorText; - final LinearLayout contentLayout; - final ImageView icon; - final TextView temp; - final TextView conditions; - final TextView highLow; - final LinearLayout forecastContainer; - final ForecastDayViewHolder[] forecastViews; - final TextView location; - final ImageView settingsIcon; - - static class ForecastDayViewHolder { - final View parent; - final TextView day; - final ImageView icon; - final TextView high; - final TextView low; - - ForecastDayViewHolder(View v) { - parent = v; - day = v.findViewById(R.id.forecast_day); - icon = v.findViewById(R.id.forecast_icon); - high = v.findViewById(R.id.forecast_high); - low = v.findViewById(R.id.forecast_low); - } - } - - WeatherViewHolder(View v) { - super(v); - progressBar = v.findViewById(R.id.weather_progress_bar); - errorText = v.findViewById(R.id.weather_error_text); - contentLayout = v.findViewById(R.id.weather_content_layout); - icon = v.findViewById(R.id.weather_icon); - temp = v.findViewById(R.id.current_temp); - conditions = v.findViewById(R.id.current_conditions); - highLow = v.findViewById(R.id.high_low_temp); - forecastContainer = v.findViewById(R.id.daily_forecast_container); - location = v.findViewById(R.id.weather_location); - settingsIcon = v.findViewById(R.id.weather_settings_icon); - forecastViews = new ForecastDayViewHolder[] { - new ForecastDayViewHolder(v.findViewById(R.id.forecast_day_1)), - new ForecastDayViewHolder(v.findViewById(R.id.forecast_day_2)), - new ForecastDayViewHolder(v.findViewById(R.id.forecast_day_3)), - new ForecastDayViewHolder(v.findViewById(R.id.forecast_day_4)), - new ForecastDayViewHolder(v.findViewById(R.id.forecast_day_5)) - }; - } - } - - static class HeadlinesViewHolder extends RecyclerView.ViewHolder { - final ProgressBar progressBar; - final TextView errorText; - final TextView cardTitle; - final LinearLayout container; - final ChipGroup chipGroup; - final HeadlineItemViewHolder[] headlineViews; - - static class HeadlineItemViewHolder { - final View parent; - final TextView title; - final TextView source; - - HeadlineItemViewHolder(View v) { - parent = v; - title = v.findViewById(R.id.headline_title); - source = v.findViewById(R.id.headline_source_time); - } - } - - HeadlinesViewHolder(View v) { - super(v); - progressBar = v.findViewById(R.id.headlines_progress_bar); - errorText = v.findViewById(R.id.headlines_error_text); - cardTitle = v.findViewById(R.id.headlines_card_title); - container = v.findViewById(R.id.headlines_container); - chipGroup = v.findViewById(R.id.headlines_category_chips); - headlineViews = new HeadlineItemViewHolder[] { - new HeadlineItemViewHolder(v.findViewById(R.id.headline_1)), - new HeadlineItemViewHolder(v.findViewById(R.id.headline_2)), - new HeadlineItemViewHolder(v.findViewById(R.id.headline_3)) - }; - } - } - - static class CalendarViewHolder extends RecyclerView.ViewHolder { - final TextView permissionDeniedText; - final TextView noEventsText; - final TextView errorText; - final LinearLayout eventsContainer; - final CalendarEventItemViewHolder[] eventViews; - - static class CalendarEventItemViewHolder { - final View parent; - final TextView title; - final TextView time; - final TextView location; - final TextView owner; - - CalendarEventItemViewHolder(View v) { - parent = v; - title = v.findViewById(R.id.event_title); - time = v.findViewById(R.id.event_time); - location = v.findViewById(R.id.event_location); - owner = v.findViewById(R.id.event_owner); - } - } - - CalendarViewHolder(View v) { - super(v); - permissionDeniedText = v.findViewById(R.id.calendar_permission_denied_text); - noEventsText = v.findViewById(R.id.calendar_no_events_text); - errorText = v.findViewById(R.id.calendar_error_text); - eventsContainer = v.findViewById(R.id.calendar_events_container); - eventViews = new CalendarEventItemViewHolder[] { - new CalendarEventItemViewHolder(v.findViewById(R.id.calendar_event_1)), - new CalendarEventItemViewHolder(v.findViewById(R.id.calendar_event_2)), - new CalendarEventItemViewHolder(v.findViewById(R.id.calendar_event_3)) - }; - } - } - - static class FunFactViewHolder extends RecyclerView.ViewHolder { - final TextView funFactText; - - FunFactViewHolder(View v) { - super(v); - funFactText = v.findViewById(R.id.fun_fact_text); - } - } - - static class HealthViewHolder extends RecyclerView.ViewHolder { - final TextView stepsCount; - final TextView permissionButton; - final TextView errorText; - final LinearLayout contentLayout; - - HealthViewHolder(View v) { - super(v); - stepsCount = v.findViewById(R.id.health_steps_count); - permissionButton = v.findViewById(R.id.health_permission_button); - errorText = v.findViewById(R.id.health_error_text); - contentLayout = v.findViewById(R.id.health_content_layout); - } - } - - static class FooterViewHolder extends RecyclerView.ViewHolder { - final TextView settingsLink; - - FooterViewHolder(View v) { - super(v); - settingsLink = v.findViewById(R.id.settings_link); - } - } -} diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.java b/app/src/main/java/com/example/theloop/DayAheadWidget.java deleted file mode 100644 index a078ba6..0000000 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.example.theloop; - -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.Context; -import android.content.SharedPreferences; -import android.widget.RemoteViews; - -import com.example.theloop.models.WeatherResponse; -import com.example.theloop.utils.AppConstants; -import com.example.theloop.utils.AppUtils; -import com.google.gson.Gson; - -import java.util.Locale; - -public class DayAheadWidget extends AppWidgetProvider { - - private static final Gson gson = new Gson(); - - static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, - int appWidgetId) { - - SharedPreferences prefs = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE); - String summary = prefs.getString(AppConstants.KEY_SUMMARY_CACHE, context.getString(R.string.widget_default_summary)); - String weatherJson = prefs.getString(AppConstants.WEATHER_CACHE_KEY, null); - - RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_day_ahead); - views.setTextViewText(R.id.widget_summary, summary); - - if (weatherJson != null) { - try { - WeatherResponse weather = gson.fromJson(weatherJson, WeatherResponse.class); - if (weather != null && weather.getCurrent() != null) { - views.setTextViewText(R.id.widget_temp, String.format(Locale.getDefault(), "%.0f°", weather.getCurrent().getTemperature())); - views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(weather.getCurrent().getWeatherCode())); - } - } catch (com.google.gson.JsonSyntaxException e) { - android.util.Log.e("DayAheadWidget", "Error parsing weather JSON from cache", e); - } - } - - appWidgetManager.updateAppWidget(appWidgetId, views); - } - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - // There may be multiple widgets active, so update all of them - for (int appWidgetId : appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId); - } - } -} diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt new file mode 100644 index 0000000..24bb478 --- /dev/null +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -0,0 +1,71 @@ +package com.example.theloop + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.widget.RemoteViews +import com.example.theloop.data.local.dao.WeatherDao +import com.example.theloop.models.WeatherResponse +import com.example.theloop.utils.AppConstants +import com.example.theloop.utils.AppUtils +import com.google.gson.Gson +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class DayAheadWidget : AppWidgetProvider() { + + @Inject + lateinit var weatherDao: WeatherDao + + @Inject + lateinit var gson: Gson + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + val pendingResult = goAsync() + + CoroutineScope(Dispatchers.IO).launch { + try { + val weatherEntity = weatherDao.getWeather() + val summary = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) + .getString(AppConstants.KEY_SUMMARY_CACHE, context.getString(R.string.widget_default_summary)) + + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId, weatherEntity?.json, summary, gson) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + pendingResult.finish() + } + } + } + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + weatherJson: String?, + summary: String?, + gson: Gson + ) { + val views = RemoteViews(context.packageName, R.layout.widget_day_ahead) + views.setTextViewText(R.id.widget_summary, summary) + + if (weatherJson != null) { + try { + val weather = gson.fromJson(weatherJson, WeatherResponse::class.java) + val current = weather.current + views.setTextViewText(R.id.widget_temp, "%.0f°".format(current.temperature)) + views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(current.weatherCode)) + } catch (e: Exception) { + e.printStackTrace() + } + } + + appWidgetManager.updateAppWidget(appWidgetId, views) + } +} diff --git a/app/src/main/java/com/example/theloop/MainActivity.java b/app/src/main/java/com/example/theloop/MainActivity.java deleted file mode 100644 index 44a244d..0000000 --- a/app/src/main/java/com/example/theloop/MainActivity.java +++ /dev/null @@ -1,862 +0,0 @@ -package com.example.theloop; - -import android.Manifest; -import android.appwidget.AppWidgetManager; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.database.Cursor; -import android.location.Address; -import android.location.Geocoder; -import android.location.Location; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.Uri; -import android.os.Bundle; -import android.provider.CalendarContract; -import android.speech.tts.TextToSpeech; -import android.text.TextUtils; -import android.util.Log; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.health.connect.client.HealthConnectClient; -import androidx.health.connect.client.permission.HealthPermission; -import androidx.health.connect.client.records.StepsRecord; -import androidx.health.connect.client.request.AggregateRequest; -import androidx.health.connect.client.time.TimeRangeFilter; -import androidx.health.connect.client.PermissionController; -import androidx.lifecycle.ViewModelProvider; -import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.PeriodicWorkRequest; -import androidx.work.WorkManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.example.theloop.models.Article; -import com.example.theloop.models.CalendarEvent; -import com.example.theloop.models.NewsResponse; -import com.example.theloop.models.WeatherResponse; -import com.example.theloop.utils.AppConstants; -import com.example.theloop.utils.AppUtils; -import com.example.theloop.health.HealthConnectHelper; -import com.google.android.gms.location.FusedLocationProviderClient; -import com.google.android.gms.location.LocationServices; -import com.google.gson.Gson; - -import java.util.Arrays; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import kotlin.jvm.JvmClassMappingKt; - -public class MainActivity extends AppCompatActivity implements DashboardAdapter.Binder, TextToSpeech.OnInitListener { - - private static final String TAG = "MainActivity"; - private static final int CALENDAR_PERMISSION_REQUEST_CODE = 100; - private static final int LOCATION_PERMISSION_REQUEST_CODE = 101; - private static final int HEALTH_PERMISSION_REQUEST_CODE = 102; - - public static final String SECTION_HEADLINES = "headlines"; - public static final String SECTION_UK_NEWS = "uk_news"; - public static final String SECTION_CALENDAR = "calendar"; - public static final String SECTION_FUN_FACT = "fun_fact"; - public static final String SECTION_HEALTH = "health"; - - private static final int POSITION_HEADER = 0; - private static final int POSITION_WEATHER = 1; - - // UK News moved to front (after weather, before headlines) - private static final String DEFAULT_SECTION_ORDER = SECTION_UK_NEWS + "," + SECTION_HEADLINES + "," + SECTION_CALENDAR + "," + SECTION_FUN_FACT + "," + SECTION_HEALTH; - - private static final String[] CALENDAR_PROJECTION = new String[]{ - CalendarContract.Events._ID, CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART, - CalendarContract.Events.DTEND, CalendarContract.Events.EVENT_LOCATION - }; - - private MainViewModel viewModel; - private FusedLocationProviderClient fusedLocationProviderClient; - private int selectedNewsCategory = R.id.chip_us; - private NewsResponse cachedNewsResponse; - private Runnable onLocationPermissionGranted; - private DashboardAdapter adapter; - private RecyclerView recyclerView; - private TextToSpeech textToSpeech; - private boolean isTtsReady = false; - private boolean weatherError = false; - private boolean newsError = false; - private boolean isFetchingNews = false; - private HealthConnectClient healthConnectClient; - private HealthConnectHelper healthConnectHelper; - private final androidx.activity.result.ActivityResultLauncher> healthPermissionLauncher = - registerForActivityResult( - androidx.health.connect.client.PermissionController.createRequestPermissionResultContract(), - granted -> { - if (granted.contains(HealthPermission.getReadPermission(JvmClassMappingKt.getKotlinClass(StepsRecord.class)))) { - fetchHealthData(); - } else { - healthPermissionDenied = true; - if (adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_HEALTH)); - } - } - }); - - private final androidx.activity.result.ActivityResultLauncher requestCalendarPermissionLauncher = - registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), isGranted -> { - if (isGranted) { - viewModel.loadCalendarData(); - } else { - int calendarPosition = findPositionForSection(SECTION_CALENDAR); - if (calendarPosition != -1) { - adapter.notifyItemChanged(calendarPosition); - } - } - }); - - private final androidx.activity.result.ActivityResultLauncher requestLocationPermissionLauncher = - registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), isGranted -> { - if (onLocationPermissionGranted != null) { - onLocationPermissionGranted.run(); - onLocationPermissionGranted = null; - } - - if (isGranted) { - fetchLocationAndThenWeatherData(); - } else { - Toast.makeText(this, "Location permission denied. Using default location.", Toast.LENGTH_SHORT).show(); - } - }); - - private String cachedLocationName; - private WeatherResponse latestWeather; - private List cachedForecastDates; - private List latestEvents; - private int totalEventCount = 0; - private boolean calendarQueryError = false; - private String generatedSummary; - private String funFactText; - private long stepsToday = -1; - private boolean healthPermissionDenied = false; - - // Cache fields for performance - private Map sectionPositions; - private String currentTempUnit; - private String currentUserName; - private List ukSources; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - viewModel = new ViewModelProvider(this).get(MainViewModel.class); - fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this); - - SharedPreferences prefs = getSharedPreferences(AppConstants.PREFS_NAME, MODE_PRIVATE); - boolean onboardingCompleted = prefs.getBoolean(AppConstants.KEY_ONBOARDING_COMPLETED, false); - - if (!onboardingCompleted) { - startActivity(new Intent(this, OnboardingActivity.class)); - finish(); - return; - } - - // Initialize cached values - currentTempUnit = prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT); - currentUserName = prefs.getString(AppConstants.KEY_USER_NAME, ""); - ukSources = new ArrayList<>(Arrays.asList(getResources().getStringArray(R.array.uk_news_sources))); - - initHealthConnect(); - textToSpeech = new TextToSpeech(this, this); - - observeViewModel(); - - viewModel.getLocationName().observe(this, name -> { - cachedLocationName = name; - if (adapter != null) adapter.notifyItemChanged(POSITION_WEATHER); - }); - - setupRecyclerView(); - refreshData(); - - androidx.work.Constraints constraints = new androidx.work.Constraints.Builder() - .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .build(); - - PeriodicWorkRequest widgetWorkRequest = new PeriodicWorkRequest.Builder(WidgetUpdateWorker.class, 30, java.util.concurrent.TimeUnit.MINUTES) - .setConstraints(constraints) - .build(); - WorkManager.getInstance(this).enqueueUniquePeriodicWork( - "widget_update", - ExistingPeriodicWorkPolicy.KEEP, - widgetWorkRequest); - } - - @Override - protected void onResume() { - super.onResume(); - // Refresh preferences that might have changed in SettingsActivity - SharedPreferences prefs = getSharedPreferences(AppConstants.PREFS_NAME, MODE_PRIVATE); - String newUnit = prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT); - if (!newUnit.equals(currentTempUnit)) { - currentTempUnit = newUnit; - if (latestWeather != null) { - // Trigger re-bind - if (adapter != null) adapter.notifyItemChanged(POSITION_WEATHER); - // Also re-fetch to get correct unit data from API if needed, - // but API call is needed only if we want server-side conversion or if we just convert locally. - // Current implementation fetches with unit parameter. - fetchLocationAndThenWeatherData(); - } - } - } - - private void observeViewModel() { - viewModel.getLatestWeather().observe(this, weather -> { - latestWeather = weather; - if (weather != null && weather.getDaily() != null) { - cachedForecastDates = AppUtils.formatForecastDates(weather.getDaily().getTime()); - } else { - cachedForecastDates = null; - } - if (adapter != null) adapter.notifyItemChanged(POSITION_WEATHER); - }); - - viewModel.getCachedNewsResponse().observe(this, news -> { - isFetchingNews = false; - cachedNewsResponse = news; - if (adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_HEADLINES)); - adapter.notifyItemChanged(findPositionForSection(SECTION_UK_NEWS)); - } - }); - - viewModel.getFunFactText().observe(this, fact -> { - funFactText = fact; - if (adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_FUN_FACT)); - } - }); - - viewModel.getCalendarEvents().observe(this, events -> { - latestEvents = events; - if (adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_CALENDAR)); - } - }); - - viewModel.getTotalEventCount().observe(this, count -> { - totalEventCount = count; - }); - - viewModel.getCalendarQueryError().observe(this, isError -> { - calendarQueryError = isError; - if (adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_CALENDAR)); - } - }); - - viewModel.getSummary().observe(this, summary -> { - generatedSummary = summary; - if (adapter != null) adapter.notifyItemChanged(POSITION_HEADER); - updateWidget(); - }); - - viewModel.getWeatherError().observe(this, error -> { - weatherError = error; - if (error && adapter != null) adapter.notifyItemChanged(POSITION_WEATHER); - }); - - viewModel.getNewsError().observe(this, error -> { - isFetchingNews = false; - newsError = error; - if (error && adapter != null) { - adapter.notifyItemChanged(findPositionForSection(SECTION_HEADLINES)); - adapter.notifyItemChanged(findPositionForSection(SECTION_UK_NEWS)); - } - }); - } - - private void initHealthConnect() { - if (HealthConnectClient.getSdkStatus(this) == HealthConnectClient.SDK_AVAILABLE) { - healthConnectClient = HealthConnectClient.getOrCreate(this); - healthConnectHelper = new HealthConnectHelper(this); - } - } - - private void setupRecyclerView() { - recyclerView = findViewById(R.id.dashboard_recycler_view); - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - - SharedPreferences prefs = getSharedPreferences(AppConstants.PREFS_NAME, MODE_PRIVATE); - String order = prefs.getString(AppConstants.KEY_SECTION_ORDER, DEFAULT_SECTION_ORDER); - - // Ensure UK News is present for existing users - if (!order.contains(SECTION_UK_NEWS)) { - order = SECTION_UK_NEWS + "," + order; - prefs.edit().putString(AppConstants.KEY_SECTION_ORDER, order).apply(); - } - - String[] sections = order.split(","); - - // OPTIMIZATION: Cache positions to avoid repeated array searches and string splitting - sectionPositions = new HashMap<>(); - for (int i = 0; i < sections.length; i++) { - sectionPositions.put(sections[i], i + 2); // +2 for Header and Weather - } - - adapter = new DashboardAdapter(this, sections); - recyclerView.setAdapter(adapter); - } - - private void refreshData() { - fetchLocationAndThenWeatherData(); - fetchNewsDataForSummary(); - loadCalendarDataForSummary(); - fetchFunFact(); - fetchHealthData(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (textToSpeech != null) { - textToSpeech.stop(); - textToSpeech.shutdown(); - } - if (healthConnectHelper != null) { - healthConnectHelper.cancel(); - } - } - - @Override - public void onInit(int status) { - if (status == TextToSpeech.SUCCESS) { - int result = textToSpeech.setLanguage(Locale.getDefault()); - if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { - Log.e(TAG, "TTS Language not supported"); - } else { - isTtsReady = true; - } - } else { - Log.e(TAG, "TTS Initialization failed"); - } - } - - private void speakSummary() { - if (!isTtsReady || TextUtils.isEmpty(generatedSummary)) return; - textToSpeech.speak(generatedSummary, TextToSpeech.QUEUE_FLUSH, null, null); - } - - @Override - public void bindHeader(DashboardAdapter.HeaderViewHolder holder) { - holder.greeting.setText(getGreeting()); - if (generatedSummary != null) { - holder.summary.setText(generatedSummary); - } else { - holder.summary.setText(getString(R.string.checking_your_day)); - } - holder.playButton.setOnClickListener(v -> speakSummary()); - } - - @Override - public void bindWeather(DashboardAdapter.WeatherViewHolder holder) { - holder.settingsIcon.setOnClickListener(v -> startActivity(new Intent(this, SettingsActivity.class))); - - if (latestWeather != null) { - holder.progressBar.setVisibility(View.GONE); - holder.errorText.setVisibility(View.GONE); - holder.contentLayout.setVisibility(View.VISIBLE); - populateWeatherCard(holder, latestWeather); - updateLocationName(holder); - } else if (weatherError) { - holder.progressBar.setVisibility(View.GONE); - holder.contentLayout.setVisibility(View.GONE); - holder.errorText.setVisibility(View.VISIBLE); - } else { - loadWeatherFromCache(holder); - } - } - - @Override - public void bindHeadlines(DashboardAdapter.HeadlinesViewHolder holder) { - if (holder.cardTitle != null) holder.cardTitle.setText("Top Headlines"); - holder.chipGroup.setVisibility(View.VISIBLE); - holder.chipGroup.setOnCheckedChangeListener((group, checkedId) -> { - if (checkedId == View.NO_ID) return; - selectedNewsCategory = checkedId; - if (cachedNewsResponse != null) { - displayNewsForCategory(holder, cachedNewsResponse); - } else { - fetchNewsData(holder); - } - }); - - if (cachedNewsResponse == null) { - if (newsError) { - holder.progressBar.setVisibility(View.GONE); - holder.errorText.setVisibility(View.VISIBLE); - } else { - holder.progressBar.setVisibility(View.VISIBLE); - holder.errorText.setVisibility(View.GONE); - fetchNewsData(holder); - } - } else { - holder.progressBar.setVisibility(View.GONE); - holder.errorText.setVisibility(View.GONE); - displayNewsForCategory(holder, cachedNewsResponse); - } - } - - @Override - public void bindUkNews(DashboardAdapter.HeadlinesViewHolder holder) { - if (holder.cardTitle != null) holder.cardTitle.setText("UK News"); - holder.chipGroup.setVisibility(View.GONE); - - if (cachedNewsResponse == null) { - if (newsError) { - holder.progressBar.setVisibility(View.GONE); - holder.errorText.setVisibility(View.VISIBLE); - } else { - holder.progressBar.setVisibility(View.VISIBLE); - holder.errorText.setVisibility(View.GONE); - // Re-use fetch logic - fetchNewsData(null); // Just trigger fetch - } - } else { - holder.progressBar.setVisibility(View.GONE); - holder.errorText.setVisibility(View.GONE); - displayUkNews(holder, cachedNewsResponse); - } - } - - @Override - public void bindCalendar(DashboardAdapter.CalendarViewHolder holder) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) { - holder.permissionDeniedText.setVisibility(View.VISIBLE); - holder.eventsContainer.setVisibility(View.GONE); - holder.errorText.setVisibility(View.GONE); - holder.noEventsText.setVisibility(View.GONE); - holder.permissionDeniedText.setOnClickListener(v -> - requestCalendarPermissionLauncher.launch(Manifest.permission.READ_CALENDAR) - ); - } else { - holder.permissionDeniedText.setVisibility(View.GONE); - if (calendarQueryError) { - holder.errorText.setVisibility(View.VISIBLE); - holder.eventsContainer.setVisibility(View.GONE); - holder.noEventsText.setVisibility(View.GONE); - } else if (latestEvents != null) { - populateCalendarCard(holder, latestEvents); - } else { - loadCalendarDataForSummary(); - } - } - } - - @Override - public void bindFunFact(DashboardAdapter.FunFactViewHolder holder) { - if (funFactText != null) { - holder.funFactText.setText(funFactText); - } else { - holder.funFactText.setText(getString(R.string.loading_fun_fact)); - } - } - - @Override - public void bindHealth(DashboardAdapter.HealthViewHolder holder) { - if (healthConnectClient == null) { - holder.errorText.setText(getString(R.string.health_connect_not_available)); - holder.errorText.setVisibility(View.VISIBLE); - holder.contentLayout.setVisibility(View.GONE); - holder.permissionButton.setVisibility(View.GONE); - return; - } - - if (stepsToday >= 0) { - holder.contentLayout.setVisibility(View.VISIBLE); - holder.stepsCount.setText(String.valueOf(stepsToday)); - holder.permissionButton.setVisibility(View.GONE); - holder.errorText.setVisibility(View.GONE); - } else if (healthPermissionDenied) { - holder.contentLayout.setVisibility(View.GONE); - holder.permissionButton.setVisibility(View.VISIBLE); - holder.permissionButton.setText(getString(R.string.health_permission_denied_button)); - holder.permissionButton.setOnClickListener(v -> { - Intent intent = new Intent("androidx.health.connect.client.action.HEALTH_CONNECT_SETTINGS"); - try { - startActivity(intent); - } catch (Exception e) { - // Try opening the Health Connect app on Play Store if not found - try { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.google.android.apps.healthdata"))); - } catch (Exception ex) { - Toast.makeText(this, getString(R.string.health_settings_error), Toast.LENGTH_LONG).show(); - } - } - }); - } else { - holder.contentLayout.setVisibility(View.GONE); - holder.permissionButton.setVisibility(View.VISIBLE); - holder.permissionButton.setOnClickListener(v -> checkHealthPermissionsAndFetch()); - } - } - - @Override - public void bindFooter(DashboardAdapter.FooterViewHolder holder) { - holder.settingsLink.setOnClickListener(v -> startActivity(new Intent(this, SettingsActivity.class))); - } - - - private void fetchLocationAndThenWeatherData() { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED - && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - fetchWeatherData(AppConstants.DEFAULT_LATITUDE, AppConstants.DEFAULT_LONGITUDE); - return; - } - fusedLocationProviderClient.getLastLocation() - .addOnSuccessListener(this, location -> { - if (location != null) { - getSharedPreferences(AppConstants.PREFS_NAME, MODE_PRIVATE).edit() - .putString(AppConstants.KEY_LATITUDE, String.valueOf(location.getLatitude())) - .putString(AppConstants.KEY_LONGITUDE, String.valueOf(location.getLongitude())) - .apply(); - - if (Geocoder.isPresent()) { - viewModel.fetchLocationName(location); - } else { - cachedLocationName = getString(R.string.unknown_location); - if (adapter != null) adapter.notifyItemChanged(POSITION_WEATHER); - } - - fetchWeatherData(location.getLatitude(), location.getLongitude()); - } else { - fetchWeatherData(AppConstants.DEFAULT_LATITUDE, AppConstants.DEFAULT_LONGITUDE); - } - }) - .addOnFailureListener(this, e -> { - Log.e(TAG, "Failed to get location.", e); - fetchWeatherData(AppConstants.DEFAULT_LATITUDE, AppConstants.DEFAULT_LONGITUDE); - }); - } - - private boolean isNetworkAvailable() { - ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - android.net.NetworkCapabilities caps = cm.getNetworkCapabilities(cm.getActiveNetwork()); - return caps != null && caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET); - } - - private void fetchWeatherData(double latitude, double longitude) { - if (!isNetworkAvailable()) { - viewModel.loadWeatherFromCache(); - return; - } - viewModel.fetchWeatherData(latitude, longitude); - } - - private void loadWeatherFromCache(DashboardAdapter.WeatherViewHolder holder) { - if (latestWeather == null) { - viewModel.loadWeatherFromCache(); - } - } - - private void populateWeatherCard(DashboardAdapter.WeatherViewHolder holder, WeatherResponse weather) { - String unit = currentTempUnit; - String tempSymbol = unit.equals("celsius") ? "°C" : "°F"; - - // OPTIMIZATION: Use Math.round() and concatenation instead of String.format() for performance - holder.temp.setText(Math.round(weather.getCurrent().getTemperature()) + tempSymbol); - holder.conditions.setText(getString(AppUtils.getWeatherDescription(weather.getCurrent().getWeatherCode()))); - holder.icon.setImageResource(AppUtils.getWeatherIconResource(weather.getCurrent().getWeatherCode())); - - com.example.theloop.models.DailyWeather daily = weather.getDaily(); - if (daily != null && daily.getTime() != null) { - int minSize = Math.min(daily.getTime().size(), - Math.min(daily.getTemperatureMax().size(), daily.getWeatherCode().size())); - int daysToShow = Math.min(holder.forecastViews.length, minSize); - - if (minSize > 0) { - double maxTemp = daily.getTemperatureMax().get(0); - double minTemp = daily.getTemperatureMin().get(0); - holder.highLow.setText(getString(R.string.weather_high_prefix) + Math.round(maxTemp) + tempSymbol + " " + getString(R.string.weather_low_prefix) + Math.round(minTemp) + tempSymbol); - } - - for (int i = 0; i < holder.forecastViews.length; i++) { - DashboardAdapter.WeatherViewHolder.ForecastDayViewHolder dailyHolder = holder.forecastViews[i]; - if (i < daysToShow) { - dailyHolder.parent.setVisibility(View.VISIBLE); - TextView dayText = dailyHolder.day; - ImageView icon = dailyHolder.icon; - TextView high = dailyHolder.high; - TextView low = dailyHolder.low; - - if (cachedForecastDates != null && i < cachedForecastDates.size()) { - dayText.setText(cachedForecastDates.get(i)); - } else { - dayText.setText("-"); - } - - icon.setImageResource(AppUtils.getWeatherIconResource(daily.getWeatherCode().get(i))); - high.setText(Math.round(daily.getTemperatureMax().get(i)) + tempSymbol); - low.setText(Math.round(daily.getTemperatureMin().get(i)) + tempSymbol); - } else { - dailyHolder.parent.setVisibility(View.GONE); - } - } - } - } - - private void updateLocationName(DashboardAdapter.WeatherViewHolder holder) { - if (holder == null || holder.location == null) return; - if (cachedLocationName != null) { - holder.location.setText(cachedLocationName); - } else { - holder.location.setText(R.string.unknown_location); - } - } - - - private void fetchNewsData(DashboardAdapter.HeadlinesViewHolder holder) { - if (isFetchingNews) return; - isFetchingNews = true; - if (holder != null) holder.progressBar.setVisibility(View.VISIBLE); - viewModel.fetchNewsData(); - } - - private void fetchNewsDataForSummary() { - viewModel.fetchNewsData(); - } - - private void displayNewsForCategory(DashboardAdapter.HeadlinesViewHolder holder, NewsResponse response) { - if (holder == null) return; - List
articles = switch (selectedNewsCategory) { - case R.id.chip_business -> response.getBusiness(); - case R.id.chip_entertainment -> response.getEntertainment(); - case R.id.chip_health -> response.getHealth(); - case R.id.chip_science -> response.getScience(); - case R.id.chip_sports -> response.getSports(); - case R.id.chip_technology -> response.getTechnology(); - case R.id.chip_world -> response.getWorld(); - default -> response.getUs(); - }; - populateHeadlines(holder, articles); - } - - private void displayUkNews(DashboardAdapter.HeadlinesViewHolder holder, NewsResponse response) { - if (holder == null) return; - - List
allArticles = new ArrayList<>(); - if (response.getWorld() != null) allArticles.addAll(response.getWorld()); - if (response.getBusiness() != null) allArticles.addAll(response.getBusiness()); - if (response.getSports() != null) allArticles.addAll(response.getSports()); - - List
ukArticles = new ArrayList<>(); - for (Article a : allArticles) { - for (String source : ukSources) { - if (a.getSource() != null && a.getSource().contains(source)) { - ukArticles.add(a); - break; - } - } - } - - if (ukArticles.isEmpty() && response.getWorld() != null) { - ukArticles = response.getWorld(); - } - - populateHeadlines(holder, ukArticles); - } - - private void populateHeadlines(DashboardAdapter.HeadlinesViewHolder holder, List
articles) { - if (articles != null && !articles.isEmpty()) { - holder.errorText.setVisibility(View.GONE); - for (int i = 0; i < holder.headlineViews.length; i++) { - DashboardAdapter.HeadlinesViewHolder.HeadlineItemViewHolder itemHolder = holder.headlineViews[i]; - if (i < articles.size()) { - Article article = articles.get(i); - itemHolder.parent.setVisibility(View.VISIBLE); - TextView title = itemHolder.title; - TextView sourceTextView = itemHolder.source; - title.setText(article.getTitle()); - sourceTextView.setText(article.getSource()); - itemHolder.parent.setOnClickListener(v -> { - if (article.getUrl() != null) { - try { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(article.getUrl()))); - } catch (android.content.ActivityNotFoundException e) { - Toast.makeText(this, "No browser found to open link.", Toast.LENGTH_SHORT).show(); - Log.e(TAG, "Failed to open article link", e); - } - } else { - Toast.makeText(this, "Article link unavailable.", Toast.LENGTH_SHORT).show(); - } - }); - } else { - itemHolder.parent.setVisibility(View.GONE); - } - } - } else { - holder.errorText.setVisibility(View.VISIBLE); - // Hide all items - for (DashboardAdapter.HeadlinesViewHolder.HeadlineItemViewHolder itemHolder : holder.headlineViews) { - itemHolder.parent.setVisibility(View.GONE); - } - } - } - - private void loadCalendarDataForSummary() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) { - return; - } - viewModel.loadCalendarData(); - } - - private void populateCalendarCard(DashboardAdapter.CalendarViewHolder holder, List events) { - holder.errorText.setVisibility(View.GONE); - if (events.isEmpty()) { - holder.noEventsText.setVisibility(View.VISIBLE); - holder.eventsContainer.setVisibility(View.GONE); - } else { - holder.noEventsText.setVisibility(View.GONE); - holder.eventsContainer.setVisibility(View.VISIBLE); - for (int i = 0; i < holder.eventViews.length; i++) { - DashboardAdapter.CalendarViewHolder.CalendarEventItemViewHolder itemHolder = holder.eventViews[i]; - if (i < events.size()) { - CalendarEvent event = events.get(i); - itemHolder.parent.setVisibility(View.VISIBLE); - TextView title = itemHolder.title; - TextView time = itemHolder.time; - TextView loc = itemHolder.location; - TextView owner = itemHolder.owner; - - title.setText(event.getTitle()); - time.setText(AppUtils.formatEventTime(this, event.getStartTime(), event.getEndTime())); - - if (!TextUtils.isEmpty(event.getLocation())) { - loc.setText(event.getLocation()); - loc.setVisibility(View.VISIBLE); - } else loc.setVisibility(View.GONE); - - if (!TextUtils.isEmpty(event.getOwnerName())) { - owner.setText(event.getOwnerName()); - owner.setVisibility(View.VISIBLE); - } else { - owner.setVisibility(View.GONE); - } - - itemHolder.parent.setOnClickListener(v -> { - Uri uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, event.getId()); - Intent intent = new Intent(Intent.ACTION_VIEW).setData(uri); - try { - startActivity(intent); - } catch (android.content.ActivityNotFoundException e) { - Log.e(TAG, "Cannot open calendar event", e); - Toast.makeText(MainActivity.this, "No app found to open calendar event.", Toast.LENGTH_SHORT).show(); - } - }); - } else { - itemHolder.parent.setVisibility(View.GONE); - } - } - } - } - - private void fetchFunFact() { - viewModel.fetchFunFact(); - } - - private void checkHealthPermissionsAndFetch() { - Set permissions = new HashSet<>(); - permissions.add(HealthPermission.getReadPermission(JvmClassMappingKt.getKotlinClass(StepsRecord.class))); - healthPermissionLauncher.launch(permissions); - } - - private void fetchHealthData() { - if (healthConnectClient == null || healthConnectHelper == null) return; - - healthConnectHelper.fetchStepsToday(new HealthConnectHelper.StepsCallback() { - @Override - public void onStepsFetched(long steps) { - stepsToday = steps; - adapter.notifyItemChanged(findPositionForSection(SECTION_HEALTH)); - } - - @Override - public void onError(Exception e) { - Log.e(TAG, "Health error", e); - adapter.notifyItemChanged(findPositionForSection(SECTION_HEALTH)); - } - }); - } - - private void updateWidget() { - Intent intent = new Intent(this, DayAheadWidget.class); - intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); - int[] ids = AppWidgetManager.getInstance(getApplication()).getAppWidgetIds(new ComponentName(getApplication(), DayAheadWidget.class)); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids); - sendBroadcast(intent); - } - - private int findPositionForSection(String section) { - if (sectionPositions != null) { - Integer position = sectionPositions.get(section); - if (position != null) { - return position; - } - } - - // Fallback for edge cases (e.g. before setupRecyclerView or if map missing) - String order = getSharedPreferences(AppConstants.PREFS_NAME, MODE_PRIVATE).getString(AppConstants.KEY_SECTION_ORDER, DEFAULT_SECTION_ORDER); - String[] sections = order.split(","); - for (int i=0; i= 0 && timeOfDay < 12) return "Good morning"; - else if (timeOfDay >= 12 && timeOfDay < 17) return "Good afternoon"; - else return "Good evening"; - } - - String getGreeting() { - String userName = currentUserName; - String greeting = getTimeBasedGreeting(); - - if (!TextUtils.isEmpty(userName)) { - return greeting + ", " + userName; - } - return greeting; - } -} diff --git a/app/src/main/java/com/example/theloop/MainActivity.kt b/app/src/main/java/com/example/theloop/MainActivity.kt new file mode 100644 index 0000000..bc0c1b6 --- /dev/null +++ b/app/src/main/java/com/example/theloop/MainActivity.kt @@ -0,0 +1,39 @@ +package com.example.theloop + +import android.Manifest +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import com.example.theloop.ui.HomeScreen +import com.example.theloop.ui.theme.TheLoopTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + // Handle permissions granted/denied. ViewModel might reload if needed. + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Request permissions + requestPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.READ_CALENDAR + ) + ) + + setContent { + TheLoopTheme { + HomeScreen() + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 9f887c2..2aec048 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -1,309 +1,167 @@ package com.example.theloop -import android.app.Application import android.content.Context -import android.content.SharedPreferences -import android.database.Cursor -import android.location.Address import android.location.Geocoder -import android.location.Location -import android.net.Uri -import android.provider.CalendarContract -import android.util.Log -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.theloop.data.repository.CalendarRepository +import com.example.theloop.data.repository.FunFactRepository +import com.example.theloop.data.repository.NewsRepository +import com.example.theloop.data.repository.WeatherRepository +import com.example.theloop.models.Article import com.example.theloop.models.CalendarEvent -import com.example.theloop.models.FunFactResponse -import com.example.theloop.models.NewsResponse import com.example.theloop.models.WeatherResponse -import com.example.theloop.network.FunFactApiService -import com.example.theloop.network.FunFactRetrofitClient -import com.example.theloop.network.NewsApiService -import com.example.theloop.network.NewsRetrofitClient -import com.example.theloop.network.RetrofitClient -import com.example.theloop.network.WeatherApiService import com.example.theloop.utils.AppConstants -import com.google.gson.Gson +import com.example.theloop.utils.SummaryUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import retrofit2.Response -import java.util.Calendar - -class MainViewModel(application: Application) : AndroidViewModel(application) { - - private val TAG = "MainViewModel" - private val gson = Gson() - - private val _latestWeather = MutableLiveData() - val latestWeather: LiveData = _latestWeather - - private val _cachedNewsResponse = MutableLiveData() - val cachedNewsResponse: LiveData = _cachedNewsResponse - - private val _funFactText = MutableLiveData() - val funFactText: LiveData = _funFactText - - private val _calendarEvents = MutableLiveData>() - val calendarEvents: LiveData> = _calendarEvents - - private val _totalEventCount = MutableLiveData(0) - val totalEventCount: LiveData = _totalEventCount - - private val _calendarQueryError = MutableLiveData(false) - val calendarQueryError: LiveData = _calendarQueryError - - private val _locationName = MutableLiveData() - val locationName: LiveData = _locationName - - private val _weatherError = MutableLiveData(false) - val weatherError: LiveData = _weatherError - - private val _newsError = MutableLiveData(false) - val newsError: LiveData = _newsError - - private val _summary = androidx.lifecycle.MediatorLiveData() - val summary: LiveData = _summary - - private val CALENDAR_PROJECTION = arrayOf( - CalendarContract.Events._ID, CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART, - CalendarContract.Events.DTEND, CalendarContract.Events.EVENT_LOCATION, CalendarContract.Events.CALENDAR_DISPLAY_NAME - ) - - fun fetchLocationName(location: Location) { - val geocoder = Geocoder(getApplication(), java.util.Locale.getDefault()) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - geocoder.getFromLocation(location.latitude, location.longitude, 1) { addresses -> - processGeocoderAddresses(addresses) - } - } else { - viewModelScope.launch(Dispatchers.IO) { - try { - val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) - processGeocoderAddresses(addresses) - } catch (e: Exception) { - Log.e(TAG, "Failed to get location name from geocoder", e) - processGeocoderAddresses(null) - } - } - } - } - - private fun processGeocoderAddresses(addresses: List
?) { - val unknown = getApplication().getString(R.string.unknown_location) - if (!addresses.isNullOrEmpty()) { - val address = addresses[0] - val city = address.locality - val district = address.subAdminArea - val sb = StringBuilder() - if (!city.isNullOrEmpty()) sb.append(city) - else if (!district.isNullOrEmpty()) sb.append(district) - else sb.append(unknown) - _locationName.postValue(sb.toString()) - } else { - _locationName.postValue(unknown) +import javax.inject.Inject + +data class MainUiState( + val weather: WeatherResponse? = null, + val newsArticles: List
= emptyList(), + val calendarEvents: List = emptyList(), + val funFact: String? = null, + val summary: String? = null, + val locationName: String = "Loading...", + val isLoading: Boolean = false, + val userGreeting: String = "", + val tempUnit: String = "celsius" +) + +@HiltViewModel +class MainViewModel @Inject constructor( + private val weatherRepo: WeatherRepository, + private val newsRepo: NewsRepository, + private val calendarRepo: CalendarRepository, + private val funFactRepo: FunFactRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _locationName = MutableStateFlow("Loading...") + private val _funFact = MutableStateFlow(null) + + private val prefs = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) + private val _tempUnit = MutableStateFlow(prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: "celsius") + private val _userName = MutableStateFlow(prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User") + + val weather = weatherRepo.weatherData + val news = newsRepo.getArticles("US") // Default to US or General + val events = calendarRepo.events + + val uiState: StateFlow = combine( + weather, + news, + events, + _funFact, + _locationName, + _userName, + _tempUnit + ) { weatherData, newsData, calendarData, funFactData, locationName, userName, tempUnit -> + + val summaryText = if (weatherData != null) { + SummaryUtils.generateSummary( + context, + weatherData, + calendarData, + calendarData.size, + newsData.firstOrNull(), + userName, + false + ) + } else null + + MainUiState( + weather = weatherData, + newsArticles = newsData, + calendarEvents = calendarData, + funFact = funFactData, + summary = summaryText, + locationName = locationName, + userGreeting = "${SummaryUtils.getTimeBasedGreeting()}, $userName", + tempUnit = tempUnit + ) + }.onEach { state -> + if (state.summary != null) { + prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, state.summary).apply() } - } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MainUiState() + ) init { - val updateSummary = { - val weather = _latestWeather.value - val events = _calendarEvents.value - val totalEvents = _totalEventCount.value ?: 0 - val news = _cachedNewsResponse.value - val calendarError = _calendarQueryError.value ?: false - val userName = getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .getString(AppConstants.KEY_USER_NAME, "User") ?: "User" - - val topHeadline = news?.us?.firstOrNull() - - if (weather != null) { - val summaryText = com.example.theloop.utils.SummaryUtils.generateSummary( - getApplication(), - weather, - events, - totalEvents, - topHeadline, - userName, - calendarError - ) - _summary.postValue(summaryText) - saveSummaryToCache(summaryText) - } - } - - _summary.addSource(_latestWeather) { updateSummary() } - _summary.addSource(_calendarEvents) { updateSummary() } - _summary.addSource(_cachedNewsResponse) { updateSummary() } - _summary.addSource(_totalEventCount) { updateSummary() } + refreshAll() } - fun fetchWeatherData(latitude: Double, longitude: Double) { - val prefs = getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - val unit = prefs.getString(AppConstants.KEY_TEMP_UNIT, null) ?: getApplication().resources.getStringArray(R.array.temp_units_values)[0] + fun refreshAll() { + val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) + val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) - viewModelScope.launch { - try { - val apiService = RetrofitClient.getClient().create(WeatherApiService::class.java) - val response = apiService.getWeather(latitude, longitude, "temperature_2m,weather_code", "weather_code,temperature_2m_max,temperature_2m_min", unit, "auto") - if (response.isSuccessful && response.body() != null) { - _weatherError.postValue(false) - _latestWeather.postValue(response.body()) - saveToCache(AppConstants.WEATHER_CACHE_KEY, response.body()) - } else { - Log.e(TAG, "Weather API response not successful: " + response.code()) - loadWeatherFromCache() - } - } catch (e: Exception) { - Log.e(TAG, "Weather failed", e) - loadWeatherFromCache() - } - } + val lat = try { latStr?.toDouble() ?: AppConstants.DEFAULT_LATITUDE } catch (e: Exception) { AppConstants.DEFAULT_LATITUDE } + val lon = try { lonStr?.toDouble() ?: AppConstants.DEFAULT_LONGITUDE } catch (e: Exception) { AppConstants.DEFAULT_LONGITUDE } + + fetchWeather(lat, lon) + fetchNews() + fetchCalendar() + fetchFunFact() } - fun loadWeatherFromCache() { - val prefs = getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - val cachedJson = prefs.getString(AppConstants.WEATHER_CACHE_KEY, null) - if (cachedJson != null) { - try { - val weather = gson.fromJson(cachedJson, WeatherResponse::class.java) - _latestWeather.postValue(weather) - _weatherError.postValue(false) - } catch (e: Exception) { - Log.e(TAG, "Failed to load weather from cache", e) - _weatherError.postValue(true) - } - } else { - _weatherError.postValue(true) - } + fun fetchWeather(lat: Double, lon: Double) { + viewModelScope.launch { + fetchLocationName(lat, lon) + weatherRepo.refresh(lat, lon, _tempUnit.value) + } } - fun fetchNewsData() { + fun fetchNews() { viewModelScope.launch { - try { - val apiService = NewsRetrofitClient.getClient().create(NewsApiService::class.java) - val response = apiService.getNewsFeed() - if (response.isSuccessful && response.body() != null) { - _newsError.postValue(false) - _cachedNewsResponse.postValue(response.body()) - saveToCache(AppConstants.NEWS_CACHE_KEY, response.body()) - } else { - Log.e(TAG, "News API response not successful: " + response.code()) - loadNewsFromCache() - } - } catch (e: Exception) { - Log.e(TAG, "News API call failed.", e) - loadNewsFromCache() - } + newsRepo.refreshNews() } } - fun loadNewsFromCache() { - val prefs = getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - val cachedJson = prefs.getString(AppConstants.NEWS_CACHE_KEY, null) - if (cachedJson != null) { - try { - val news = gson.fromJson(cachedJson, NewsResponse::class.java) - _cachedNewsResponse.postValue(news) - _newsError.postValue(false) - } catch (e: Exception) { - Log.e(TAG, "Failed to load news from cache", e) - _newsError.postValue(true) - } - } else { - _newsError.postValue(true) + fun fetchCalendar() { + viewModelScope.launch { + calendarRepo.refreshEvents() } } fun fetchFunFact() { viewModelScope.launch { - try { - val api = FunFactRetrofitClient.client.create(FunFactApiService::class.java) - val response = api.getRandomFact("en") - val fact = response.body()?.text - if (response.isSuccessful && fact != null) { - //noinspection NullSafeMutableLiveData - _funFactText.postValue(fact) - } else { - loadFallbackFunFact() - } - } catch (e: Exception) { - loadFallbackFunFact() + val fact = funFactRepo.getFunFact() + if (fact != null) { + _funFact.value = fact + } else { + _funFact.value = context.getString(R.string.fun_fact_fallback) } } } - fun loadFallbackFunFact() { - try { - val facts = getApplication().resources.getStringArray(R.array.fun_facts) - val idx = Calendar.getInstance().get(Calendar.DAY_OF_YEAR) % facts.size - _funFactText.postValue(facts[idx]) - } catch (e: Exception) { - _funFactText.postValue(getApplication().getString(R.string.fun_fact_fallback)) - } - } - - fun loadCalendarData() { - viewModelScope.launch(Dispatchers.IO) { - _calendarQueryError.postValue(false) - val events = ArrayList() - try { - val contentResolver = getApplication().contentResolver - val uri = CalendarContract.Events.CONTENT_URI - val now = System.currentTimeMillis() - val cal = Calendar.getInstance() - cal.timeInMillis = now - cal.add(Calendar.HOUR_OF_DAY, 24) - val end = cal.timeInMillis - - val selection = CalendarContract.Events.DTSTART + " >= ? AND " + CalendarContract.Events.DTSTART + " <= ?" - val selectionArgs = arrayOf(now.toString(), end.toString()) - val sort = CalendarContract.Events.DTSTART + " ASC" - - contentResolver.query(uri, CALENDAR_PROJECTION, selection, selectionArgs, sort)?.use { cursor -> - _totalEventCount.postValue(cursor.count) - val idIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events._ID) - val titleIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.TITLE) - val startIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTSTART) - val endIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTEND) - val locIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.EVENT_LOCATION) - val ownerIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_DISPLAY_NAME) - - while (cursor.moveToNext() && events.size < 3) { - events.add(CalendarEvent( - cursor.getLong(idIdx), - cursor.getString(titleIdx), - cursor.getLong(startIdx), - cursor.getLong(endIdx), - cursor.getString(locIdx), - cursor.getString(ownerIdx) - )) - } - } ?: run { - _totalEventCount.postValue(0) - } - _calendarEvents.postValue(events) - } catch (e: Exception) { - Log.e(TAG, "Cal error", e) - _calendarQueryError.postValue(true) - _calendarEvents.postValue(emptyList()) - } + private fun fetchLocationName(lat: Double, lon: Double) { + if (Geocoder.isPresent()) { + viewModelScope.launch(Dispatchers.IO) { + try { + val geocoder = Geocoder(context, java.util.Locale.getDefault()) + @Suppress("DEPRECATION") + val addresses = geocoder.getFromLocation(lat, lon, 1) + if (!addresses.isNullOrEmpty()) { + val address = addresses[0] + val name = address.locality ?: address.subAdminArea ?: "Unknown Location" + _locationName.value = name + } + } catch (e: Exception) { + _locationName.value = "Unknown Location" + } + } } } - - fun saveSummaryToCache(summary: String) { - getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() - } - - private fun saveToCache(key: String, data: Any?) { - getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .edit().putString(key, gson.toJson(data)).apply() - } - - override fun onCleared() { - super.onCleared() - // Coroutines are cancelled by viewModelScope - } } diff --git a/app/src/main/java/com/example/theloop/TheLoopApplication.kt b/app/src/main/java/com/example/theloop/TheLoopApplication.kt new file mode 100644 index 0000000..15e4c67 --- /dev/null +++ b/app/src/main/java/com/example/theloop/TheLoopApplication.kt @@ -0,0 +1,18 @@ +package com.example.theloop + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class TheLoopApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} diff --git a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt index eb18fc5..7ec654f 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -1,126 +1,86 @@ package com.example.theloop import android.content.Context -import android.util.Log +import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.example.theloop.models.Article -import com.example.theloop.models.CalendarEvent -import com.example.theloop.models.NewsResponse -import com.example.theloop.network.RetrofitClient -import com.example.theloop.network.WeatherApiService +import com.example.theloop.data.repository.CalendarRepository +import com.example.theloop.data.repository.NewsRepository +import com.example.theloop.data.repository.WeatherRepository import com.example.theloop.utils.AppConstants import com.example.theloop.utils.SummaryUtils -import com.google.gson.Gson -import java.io.IOException +import com.example.theloop.DayAheadWidget +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first import java.util.ArrayList -class WidgetUpdateWorker(appContext: Context, workerParams: WorkerParameters) : - CoroutineWorker(appContext, workerParams) { - - private val TAG = "WidgetUpdateWorker" - private val gson = Gson() +@HiltWorker +class WidgetUpdateWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val weatherRepo: WeatherRepository, + private val newsRepo: NewsRepository, + private val calendarRepo: CalendarRepository +) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - Log.d(TAG, "Fetching weather for widget update...") - val prefs = applicationContext.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - val latStr = prefs.getString(AppConstants.KEY_LATITUDE, null) val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, null) + val unit = prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: AppConstants.DEFAULT_TEMP_UNIT + val userName = prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User" if (latStr == null || lonStr == null) { - Log.w(TAG, "No location available for widget update. Skipping weather fetch.") return Result.success() } - val lat: Double - val lon: Double - try { - lat = latStr.toDouble() - lon = lonStr.toDouble() - } catch (e: NumberFormatException) { - Log.w(TAG, "Could not parse lat/lon from SharedPreferences", e) - return Result.failure() - } - - // Fetch Weather - try { - val unit = prefs.getString(AppConstants.KEY_TEMP_UNIT, null) ?: AppConstants.DEFAULT_TEMP_UNIT - - val apiService = RetrofitClient.getClient().create(WeatherApiService::class.java) - val response = apiService.getWeather( - lat, lon, - "temperature_2m,weather_code", - "weather_code,temperature_2m_max,temperature_2m_min", - unit, - "auto" - ) - - if (response.isSuccessful && response.body() != null) { - // Save to cache - val json = gson.toJson(response.body()) - prefs.edit().putString(AppConstants.WEATHER_CACHE_KEY, json).apply() - - // Generate Summary - try { - val newsJson = prefs.getString(AppConstants.NEWS_CACHE_KEY, null) - var topHeadline: Article? = null - if (newsJson != null) { - try { - val news = gson.fromJson(newsJson, NewsResponse::class.java) - if (news != null && news.us != null && news.us.isNotEmpty()) { - topHeadline = news.us[0] - } - } catch (e: Exception) { - Log.e(TAG, "Failed to parse news cache", e) - } - } - - // Calendar logic skipped for background worker (using empty list) - val events = ArrayList() - val totalEvents = 0 - val calendarError = false - val userName = prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User" - - val summary = SummaryUtils.generateSummary( - applicationContext, - response.body(), - events, - totalEvents, - topHeadline, - userName, - calendarError - ) - - prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() - - } catch (e: Exception) { - Log.e(TAG, "Failed to generate summary in worker", e) - } + return try { + val lat = latStr.toDouble() + val lon = lonStr.toDouble() + + // Update Data (saves to DB) + weatherRepo.refresh(lat, lon, unit) + try { newsRepo.refreshNews() } catch (e: Exception) {} + try { calendarRepo.refreshEvents() } catch (e: Exception) {} + + // Get latest data for summary + val weather = weatherRepo.weatherData.first() + val news = newsRepo.getArticles("US").first() + val events = calendarRepo.events.first() + + // Generate Summary + val summary = if (weather != null) { + SummaryUtils.generateSummary( + applicationContext, + weather, + events, + events.size, + news.firstOrNull(), + userName, + false + ) + } else null + + if (summary != null) { + prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() + } - // Trigger widget update explicitly - val intent = android.content.Intent(applicationContext, DayAheadWidget::class.java) - intent.setAction(android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE) - val ids = android.appwidget.AppWidgetManager.getInstance(applicationContext) - .getAppWidgetIds(android.content.ComponentName(applicationContext, DayAheadWidget::class.java)) - intent.putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - applicationContext.sendBroadcast(intent) + // Trigger widget update + val intent = Intent(applicationContext, DayAheadWidget::class.java) + intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + val ids = AppWidgetManager.getInstance(applicationContext) + .getAppWidgetIds(ComponentName(applicationContext, DayAheadWidget::class.java)) + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + applicationContext.sendBroadcast(intent) - return Result.success() - } else { - Log.w(TAG, "Widget weather fetch failed with code: " + response.code()) - if (response.code() >= 500) { - return Result.retry() - } - return Result.failure() - } - } catch (e: IOException) { - Log.e(TAG, "Widget update failed", e) - return Result.retry() + Result.success() } catch (e: Exception) { - Log.e(TAG, "Widget update failed with unexpected exception", e) - return Result.failure() + e.printStackTrace() + Result.retry() } } } diff --git a/app/src/main/java/com/example/theloop/data/local/AppDatabase.kt b/app/src/main/java/com/example/theloop/data/local/AppDatabase.kt new file mode 100644 index 0000000..f5b1396 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/AppDatabase.kt @@ -0,0 +1,17 @@ +package com.example.theloop.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.example.theloop.data.local.dao.ArticleDao +import com.example.theloop.data.local.dao.CalendarEventDao +import com.example.theloop.data.local.dao.WeatherDao +import com.example.theloop.data.local.entity.ArticleEntity +import com.example.theloop.data.local.entity.CalendarEventEntity +import com.example.theloop.data.local.entity.WeatherEntity + +@Database(entities = [WeatherEntity::class, ArticleEntity::class, CalendarEventEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun weatherDao(): WeatherDao + abstract fun articleDao(): ArticleDao + abstract fun calendarEventDao(): CalendarEventDao +} diff --git a/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt b/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt new file mode 100644 index 0000000..29cf4e1 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt @@ -0,0 +1,23 @@ +package com.example.theloop.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.theloop.data.local.entity.ArticleEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ArticleDao { + @Query("SELECT * FROM articles WHERE category = :category") + fun getArticlesByCategory(category: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertArticles(articles: List) + + @Query("DELETE FROM articles WHERE category = :category") + suspend fun deleteArticlesByCategory(category: String) + + @Query("DELETE FROM articles") + suspend fun clearAll() +} diff --git a/app/src/main/java/com/example/theloop/data/local/dao/CalendarEventDao.kt b/app/src/main/java/com/example/theloop/data/local/dao/CalendarEventDao.kt new file mode 100644 index 0000000..c0be63f --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/dao/CalendarEventDao.kt @@ -0,0 +1,20 @@ +package com.example.theloop.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.theloop.data.local.entity.CalendarEventEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CalendarEventDao { + @Query("SELECT * FROM calendar_events ORDER BY startTime ASC") + fun getEventsFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEvents(events: List) + + @Query("DELETE FROM calendar_events") + suspend fun clearAll() +} diff --git a/app/src/main/java/com/example/theloop/data/local/dao/WeatherDao.kt b/app/src/main/java/com/example/theloop/data/local/dao/WeatherDao.kt new file mode 100644 index 0000000..ff0460b --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/dao/WeatherDao.kt @@ -0,0 +1,20 @@ +package com.example.theloop.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.theloop.data.local.entity.WeatherEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface WeatherDao { + @Query("SELECT * FROM weather WHERE id = 0") + fun getWeatherFlow(): Flow + + @Query("SELECT * FROM weather WHERE id = 0") + suspend fun getWeather(): WeatherEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertWeather(weather: WeatherEntity) +} diff --git a/app/src/main/java/com/example/theloop/data/local/entity/ArticleEntity.kt b/app/src/main/java/com/example/theloop/data/local/entity/ArticleEntity.kt new file mode 100644 index 0000000..b0b9954 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/entity/ArticleEntity.kt @@ -0,0 +1,13 @@ +package com.example.theloop.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "articles") +data class ArticleEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val title: String, + val source: String?, + val url: String?, + val category: String +) diff --git a/app/src/main/java/com/example/theloop/data/local/entity/CalendarEventEntity.kt b/app/src/main/java/com/example/theloop/data/local/entity/CalendarEventEntity.kt new file mode 100644 index 0000000..b4f0f9c --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/entity/CalendarEventEntity.kt @@ -0,0 +1,14 @@ +package com.example.theloop.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "calendar_events") +data class CalendarEventEntity( + @PrimaryKey val id: Long, + val title: String, + val startTime: Long, + val endTime: Long, + val location: String?, + val ownerName: String? +) diff --git a/app/src/main/java/com/example/theloop/data/local/entity/WeatherEntity.kt b/app/src/main/java/com/example/theloop/data/local/entity/WeatherEntity.kt new file mode 100644 index 0000000..0b04378 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/entity/WeatherEntity.kt @@ -0,0 +1,11 @@ +package com.example.theloop.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "weather") +data class WeatherEntity( + @PrimaryKey val id: Int = 0, + val json: String, + val lastUpdated: Long +) diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt new file mode 100644 index 0000000..ca5d0e5 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -0,0 +1,92 @@ +package com.example.theloop.data.repository + +import android.content.Context +import android.provider.CalendarContract +import com.example.theloop.data.local.dao.CalendarEventDao +import com.example.theloop.data.local.entity.CalendarEventEntity +import com.example.theloop.models.CalendarEvent +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.util.Calendar +import javax.inject.Inject + +class CalendarRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val dao: CalendarEventDao +) { + val events: Flow> = dao.getEventsFlow().map { entities -> + entities.map { + CalendarEvent(it.id, it.title, it.startTime, it.endTime, it.location, it.ownerName) + } + } + + suspend fun refreshEvents() { + withContext(Dispatchers.IO) { + try { + if (androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.READ_CALENDAR + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + return@withContext + } + + val events = ArrayList() + val contentResolver = context.contentResolver + val uri = CalendarContract.Events.CONTENT_URI + val now = System.currentTimeMillis() + val cal = Calendar.getInstance() + cal.timeInMillis = now + cal.add(Calendar.HOUR_OF_DAY, 24) + val end = cal.timeInMillis + + val selection = + CalendarContract.Events.DTSTART + " >= ? AND " + CalendarContract.Events.DTSTART + " <= ?" + val selectionArgs = arrayOf(now.toString(), end.toString()) + val sort = CalendarContract.Events.DTSTART + " ASC" + + val projection = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.CALENDAR_DISPLAY_NAME + ) + + contentResolver.query(uri, projection, selection, selectionArgs, sort)?.use { cursor -> + val idIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events._ID) + val titleIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.TITLE) + val startIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTSTART) + val endIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTEND) + val locIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.EVENT_LOCATION) + val ownerIdx = + cursor.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_DISPLAY_NAME) + + while (cursor.moveToNext() && events.size < 5) { // Limit to 5 + events.add( + CalendarEventEntity( + id = cursor.getLong(idIdx), + title = cursor.getString(titleIdx) ?: "No Title", + startTime = cursor.getLong(startIdx), + endTime = cursor.getLong(endIdx), + location = cursor.getString(locIdx), + ownerName = cursor.getString(ownerIdx) + ) + ) + } + } + + dao.clearAll() + if (events.isNotEmpty()) { + dao.insertEvents(events) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/data/repository/FunFactRepository.kt b/app/src/main/java/com/example/theloop/data/repository/FunFactRepository.kt new file mode 100644 index 0000000..9ee66eb --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/FunFactRepository.kt @@ -0,0 +1,21 @@ +package com.example.theloop.data.repository + +import com.example.theloop.network.FunFactApiService +import javax.inject.Inject + +class FunFactRepository @Inject constructor( + private val api: FunFactApiService +) { + suspend fun getFunFact(): String? { + return try { + val response = api.getRandomFact("en") + if (response.isSuccessful) { + response.body()?.text + } else { + null + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt new file mode 100644 index 0000000..73bbec9 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -0,0 +1,69 @@ +package com.example.theloop.data.repository + +import com.example.theloop.data.local.dao.ArticleDao +import com.example.theloop.data.local.entity.ArticleEntity +import com.example.theloop.models.Article +import com.example.theloop.network.NewsApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class NewsRepository @Inject constructor( + private val api: NewsApiService, + private val dao: ArticleDao +) { + fun getArticles(category: String): Flow> { + return dao.getArticlesByCategory(category).map { entities -> + entities.map { it.toArticle() } + } + } + + suspend fun refreshNews() { + withContext(Dispatchers.IO) { + try { + val response = api.getNewsFeed() + if (response.isSuccessful) { + val news = response.body() + if (news != null) { + val allArticles = mutableListOf() + // Map each category + news.business?.let { allArticles.addAll(it.map { a -> a.toEntity("Business") }) } + news.entertainment?.let { allArticles.addAll(it.map { a -> a.toEntity("Entertainment") }) } + news.health?.let { allArticles.addAll(it.map { a -> a.toEntity("Health") }) } + news.science?.let { allArticles.addAll(it.map { a -> a.toEntity("Science") }) } + news.sports?.let { allArticles.addAll(it.map { a -> a.toEntity("Sports") }) } + news.technology?.let { allArticles.addAll(it.map { a -> a.toEntity("Technology") }) } + news.us?.let { allArticles.addAll(it.map { a -> a.toEntity("US") }) } + news.world?.let { allArticles.addAll(it.map { a -> a.toEntity("World") }) } + + if (allArticles.isNotEmpty()) { + dao.clearAll() + dao.insertArticles(allArticles) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun Article.toEntity(category: String): ArticleEntity { + return ArticleEntity( + title = this.title ?: "", + source = this.source, + url = this.url, + category = category + ) + } + + private fun ArticleEntity.toArticle(): Article { + return Article( + title = this.title, + source = this.source, + url = this.url + ) + } +} diff --git a/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..8f50dd2 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt @@ -0,0 +1,55 @@ +package com.example.theloop.data.repository + +import com.example.theloop.data.local.dao.WeatherDao +import com.example.theloop.data.local.entity.WeatherEntity +import com.example.theloop.models.WeatherResponse +import com.example.theloop.network.WeatherApiService +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class WeatherRepository @Inject constructor( + private val api: WeatherApiService, + private val dao: WeatherDao, + private val gson: Gson +) { + val weatherData: Flow = dao.getWeatherFlow().map { entity -> + if (entity != null) { + try { + gson.fromJson(entity.json, WeatherResponse::class.java) + } catch (e: Exception) { + null + } + } else { + null + } + } + + suspend fun refresh(lat: Double, lon: Double, unit: String) { + withContext(Dispatchers.IO) { + try { + // Ensure timezone is valid, "auto" works for Open-Meteo + val response = api.getWeather( + lat, + lon, + "temperature_2m,weather_code", + "weather_code,temperature_2m_max,temperature_2m_min", + unit, + "auto" + ) + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + val json = gson.toJson(body) + dao.insertWeather(WeatherEntity(id = 0, json = json, lastUpdated = System.currentTimeMillis())) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/di/DatabaseModule.kt b/app/src/main/java/com/example/theloop/di/DatabaseModule.kt new file mode 100644 index 0000000..27b7001 --- /dev/null +++ b/app/src/main/java/com/example/theloop/di/DatabaseModule.kt @@ -0,0 +1,38 @@ +package com.example.theloop.di + +import android.content.Context +import androidx.room.Room +import com.example.theloop.data.local.AppDatabase +import com.example.theloop.data.local.dao.ArticleDao +import com.example.theloop.data.local.dao.CalendarEventDao +import com.example.theloop.data.local.dao.WeatherDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "theloop_db" + ).build() + } + + @Provides + fun provideWeatherDao(db: AppDatabase): WeatherDao = db.weatherDao() + + @Provides + fun provideArticleDao(db: AppDatabase): ArticleDao = db.articleDao() + + @Provides + fun provideCalendarEventDao(db: AppDatabase): CalendarEventDao = db.calendarEventDao() +} diff --git a/app/src/main/java/com/example/theloop/di/NetworkModule.kt b/app/src/main/java/com/example/theloop/di/NetworkModule.kt new file mode 100644 index 0000000..f6496e1 --- /dev/null +++ b/app/src/main/java/com/example/theloop/di/NetworkModule.kt @@ -0,0 +1,71 @@ +package com.example.theloop.di + +import com.example.theloop.network.FunFactApiService +import com.example.theloop.network.NewsApiService +import com.example.theloop.network.WeatherApiService +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideGson(): Gson = Gson() + + @Provides + @Singleton + @Named("WeatherRetrofit") + fun provideWeatherRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api.open-meteo.com/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + @Named("NewsRetrofit") + fun provideNewsRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://ok.surf/api/v1/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + @Named("FunFactRetrofit") + fun provideFunFactRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://uselessfacts.jsph.pl/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideWeatherApiService(@Named("WeatherRetrofit") retrofit: Retrofit): WeatherApiService { + return retrofit.create(WeatherApiService::class.java) + } + + @Provides + @Singleton + fun provideNewsApiService(@Named("NewsRetrofit") retrofit: Retrofit): NewsApiService { + return retrofit.create(NewsApiService::class.java) + } + + @Provides + @Singleton + fun provideFunFactApiService(@Named("FunFactRetrofit") retrofit: Retrofit): FunFactApiService { + return retrofit.create(FunFactApiService::class.java) + } +} diff --git a/app/src/main/java/com/example/theloop/models/Article.java b/app/src/main/java/com/example/theloop/models/Article.java deleted file mode 100644 index fdd0715..0000000 --- a/app/src/main/java/com/example/theloop/models/Article.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.theloop.models; - -import com.google.gson.annotations.SerializedName; - -public class Article { - - @SerializedName("source") - private String source; - - @SerializedName("title") - private String title; - - @SerializedName("link") - private String url; - - // Getters - public String getSource() { - return source; - } - - public String getTitle() { - return title; - } - - public String getUrl() { - return url; - } -} diff --git a/app/src/main/java/com/example/theloop/models/Article.kt b/app/src/main/java/com/example/theloop/models/Article.kt new file mode 100644 index 0000000..fae67ab --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/Article.kt @@ -0,0 +1,9 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class Article( + @SerializedName("source") val source: String?, + @SerializedName("title") val title: String?, + @SerializedName("link") val url: String? +) diff --git a/app/src/main/java/com/example/theloop/models/CalendarEvent.java b/app/src/main/java/com/example/theloop/models/CalendarEvent.java deleted file mode 100644 index d4a5892..0000000 --- a/app/src/main/java/com/example/theloop/models/CalendarEvent.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.theloop.models; - -public class CalendarEvent { - private final long id; - private final String title; - private final long startTime; - private final long endTime; - private final String location; - private final String ownerName; - - public CalendarEvent(long id, String title, long startTime, long endTime, String location, String ownerName) { - this.id = id; - this.title = title; - this.startTime = startTime; - this.endTime = endTime; - this.location = location; - this.ownerName = ownerName; - } - - public long getId() { - return id; - } - - public String getOwnerName() { - return ownerName; - } - - public String getTitle() { - return title; - } - - public long getStartTime() { - return startTime; - } - - public long getEndTime() { - return endTime; - } - - public String getLocation() { - return location; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/theloop/models/CalendarEvent.kt b/app/src/main/java/com/example/theloop/models/CalendarEvent.kt new file mode 100644 index 0000000..5175d25 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/CalendarEvent.kt @@ -0,0 +1,10 @@ +package com.example.theloop.models + +data class CalendarEvent( + val id: Long, + val title: String, + val startTime: Long, + val endTime: Long, + val location: String?, + val ownerName: String? +) diff --git a/app/src/main/java/com/example/theloop/models/CurrentWeather.java b/app/src/main/java/com/example/theloop/models/CurrentWeather.java deleted file mode 100644 index c4ac5e9..0000000 --- a/app/src/main/java/com/example/theloop/models/CurrentWeather.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.theloop.models; - -import com.google.gson.annotations.SerializedName; - -public class CurrentWeather { - - @SerializedName("time") - private String time; - - @SerializedName("temperature_2m") - private double temperature; - - @SerializedName("weather_code") - private int weatherCode; - - // Getters - public String getTime() { - return time; - } - - public double getTemperature() { - return temperature; - } - - public int getWeatherCode() { - return weatherCode; - } -} diff --git a/app/src/main/java/com/example/theloop/models/CurrentWeather.kt b/app/src/main/java/com/example/theloop/models/CurrentWeather.kt new file mode 100644 index 0000000..46fc470 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/CurrentWeather.kt @@ -0,0 +1,9 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class CurrentWeather( + @SerializedName("temperature_2m") val temperature: Double, + @SerializedName("weather_code") val weatherCode: Int, + @SerializedName("time") val time: String +) diff --git a/app/src/main/java/com/example/theloop/models/DailyWeather.java b/app/src/main/java/com/example/theloop/models/DailyWeather.java deleted file mode 100644 index 15bcb4c..0000000 --- a/app/src/main/java/com/example/theloop/models/DailyWeather.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.example.theloop.models; - -import com.google.gson.annotations.SerializedName; -import java.util.List; - -public class DailyWeather { - - @SerializedName("time") - private List time; - - @SerializedName("weather_code") - private List weatherCode; - - @SerializedName("temperature_2m_max") - private List temperatureMax; - - @SerializedName("temperature_2m_min") - private List temperatureMin; - - // Getters - public List getTime() { - return time; - } - - public List getWeatherCode() { - return weatherCode; - } - - public List getTemperatureMax() { - return temperatureMax; - } - - public List getTemperatureMin() { - return temperatureMin; - } -} diff --git a/app/src/main/java/com/example/theloop/models/DailyWeather.kt b/app/src/main/java/com/example/theloop/models/DailyWeather.kt new file mode 100644 index 0000000..9a93a24 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/DailyWeather.kt @@ -0,0 +1,10 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class DailyWeather( + @SerializedName("time") val time: List, + @SerializedName("weather_code") val weatherCode: List, + @SerializedName("temperature_2m_max") val temperatureMax: List, + @SerializedName("temperature_2m_min") val temperatureMin: List +) diff --git a/app/src/main/java/com/example/theloop/models/FunFactResponse.java b/app/src/main/java/com/example/theloop/models/FunFactResponse.java deleted file mode 100644 index 75199fb..0000000 --- a/app/src/main/java/com/example/theloop/models/FunFactResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.theloop.models; - -import com.google.gson.annotations.SerializedName; - -public class FunFactResponse { - @SerializedName("id") - private String id; - - @SerializedName("text") - private String text; - - @SerializedName("source") - private String source; - - public String getId() { - return id; - } - - public String getText() { - return text; - } - - public String getSource() { - return source; - } -} diff --git a/app/src/main/java/com/example/theloop/models/FunFactResponse.kt b/app/src/main/java/com/example/theloop/models/FunFactResponse.kt new file mode 100644 index 0000000..07af045 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/FunFactResponse.kt @@ -0,0 +1,9 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class FunFactResponse( + @SerializedName("id") val id: String, + @SerializedName("text") val text: String, + @SerializedName("source") val source: String +) diff --git a/app/src/main/java/com/example/theloop/models/NewsResponse.java b/app/src/main/java/com/example/theloop/models/NewsResponse.java deleted file mode 100644 index 766366e..0000000 --- a/app/src/main/java/com/example/theloop/models/NewsResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.theloop.models; - -import java.util.List; -import com.google.gson.annotations.SerializedName; - -public class NewsResponse { - - @SerializedName("Business") - private List
business; - - @SerializedName("Entertainment") - private List
entertainment; - - @SerializedName("Health") - private List
health; - - @SerializedName("Science") - private List
science; - - @SerializedName("Sports") - private List
sports; - - @SerializedName("Technology") - private List
technology; - - @SerializedName("US") - private List
us; - - @SerializedName("World") - private List
world; - - public List
getBusiness() { return business; } - public List
getEntertainment() { return entertainment; } - public List
getHealth() { return health; } - public List
getScience() { return science; } - public List
getSports() { return sports; } - public List
getTechnology() { return technology; } - public List
getUs() { return us; } - public List
getWorld() { return world; } -} diff --git a/app/src/main/java/com/example/theloop/models/NewsResponse.kt b/app/src/main/java/com/example/theloop/models/NewsResponse.kt new file mode 100644 index 0000000..bcb1a25 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/NewsResponse.kt @@ -0,0 +1,14 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class NewsResponse( + @SerializedName("Business") val business: List
?, + @SerializedName("Entertainment") val entertainment: List
?, + @SerializedName("Health") val health: List
?, + @SerializedName("Science") val science: List
?, + @SerializedName("Sports") val sports: List
?, + @SerializedName("Technology") val technology: List
?, + @SerializedName("US") val us: List
?, + @SerializedName("World") val world: List
? +) diff --git a/app/src/main/java/com/example/theloop/models/WeatherResponse.java b/app/src/main/java/com/example/theloop/models/WeatherResponse.java deleted file mode 100644 index a9f0d9f..0000000 --- a/app/src/main/java/com/example/theloop/models/WeatherResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.theloop.models; - -import com.google.gson.annotations.SerializedName; - -public class WeatherResponse { - - @SerializedName("latitude") - private double latitude; - - @SerializedName("longitude") - private double longitude; - - @SerializedName("current") - private CurrentWeather current; - - @SerializedName("daily") - private DailyWeather daily; - - // Getters - public double getLatitude() { - return latitude; - } - - public double getLongitude() { - return longitude; - } - - public CurrentWeather getCurrent() { - return current; - } - - public DailyWeather getDaily() { - return daily; - } -} diff --git a/app/src/main/java/com/example/theloop/models/WeatherResponse.kt b/app/src/main/java/com/example/theloop/models/WeatherResponse.kt new file mode 100644 index 0000000..a87e067 --- /dev/null +++ b/app/src/main/java/com/example/theloop/models/WeatherResponse.kt @@ -0,0 +1,10 @@ +package com.example.theloop.models + +import com.google.gson.annotations.SerializedName + +data class WeatherResponse( + @SerializedName("latitude") val latitude: Double, + @SerializedName("longitude") val longitude: Double, + @SerializedName("current") val current: CurrentWeather, + @SerializedName("daily") val daily: DailyWeather? +) diff --git a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt new file mode 100644 index 0000000..6499671 --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -0,0 +1,99 @@ +package com.example.theloop.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.theloop.MainViewModel +import com.example.theloop.ui.components.CalendarCard +import com.example.theloop.ui.components.NewsCard +import com.example.theloop.ui.components.WeatherCard + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + viewModel: MainViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + Scaffold( + modifier = modifier + ) { padding -> + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(padding) + ) { + item { + Text( + text = state.userGreeting, + style = MaterialTheme.typography.displayLarge + ) + } + if (state.summary != null) { + item { + Text( + text = state.summary ?: "", + style = MaterialTheme.typography.bodyLarge + ) + } + } + + item { + WeatherCard( + weather = state.weather, + locationName = state.locationName, + tempUnit = state.tempUnit, + onClick = { viewModel.openWeatherSettings() } + ) + } + + if (state.calendarEvents.isNotEmpty()) { + item { + CalendarCard(events = state.calendarEvents) + } + } + + if (state.funFact != null) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Did you know?", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + Text(state.funFact ?: "") + } + } + } + } + + item { + Text("Headlines", style = MaterialTheme.typography.headlineSmall) + } + + items(state.newsArticles) { article -> + NewsCard( + article = article, + onClick = { + article.url?.let { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + try { + context.startActivity(intent) + } catch (e: Exception) { + // Handle exception + } + } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt b/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt new file mode 100644 index 0000000..2fd5b3c --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt @@ -0,0 +1,50 @@ +package com.example.theloop.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.theloop.models.CalendarEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun CalendarCard( + events: List, + modifier: Modifier = Modifier +) { + ElevatedCard(modifier = modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(text = "Agenda", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + if (events.isEmpty()) { + Text("No upcoming events") + } else { + events.forEach { event -> + EventItem(event) + HorizontalDivider() + } + } + } + } +} + +@Composable +fun EventItem(event: CalendarEvent) { + Row(Modifier.padding(vertical = 8.dp)) { + val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + Text( + text = timeFormat.format(Date(event.startTime)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.width(60.dp) + ) + Column { + Text(text = event.title, style = MaterialTheme.typography.bodyLarge) + if (!event.location.isNullOrEmpty()) { + Text(text = event.location, style = MaterialTheme.typography.bodySmall) + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/ui/components/NewsCard.kt b/app/src/main/java/com/example/theloop/ui/components/NewsCard.kt new file mode 100644 index 0000000..595a36f --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/components/NewsCard.kt @@ -0,0 +1,32 @@ +package com.example.theloop.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.theloop.models.Article + +@Composable +fun NewsCard( + article: Article, + onClick: () -> Unit +) { + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = article.title ?: "No Title", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = article.source ?: "Unknown Source", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + } +} diff --git a/app/src/main/java/com/example/theloop/ui/components/WeatherCard.kt b/app/src/main/java/com/example/theloop/ui/components/WeatherCard.kt new file mode 100644 index 0000000..8ba3340 --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/components/WeatherCard.kt @@ -0,0 +1,58 @@ +package com.example.theloop.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.theloop.models.WeatherResponse +import com.example.theloop.utils.AppUtils +import com.example.theloop.R + +@Composable +fun WeatherCard( + weather: WeatherResponse?, + locationName: String, + tempUnit: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + ElevatedCard( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + if (weather == null) { + Box(Modifier.padding(16.dp)) { + Text("Loading Weather...") + } + } else { + val current = weather.current + val description = stringResource(AppUtils.getWeatherDescription(current.weatherCode)) + val iconRes = AppUtils.getWeatherIconResource(current.weatherCode) + val tempSymbol = if (tempUnit == "celsius") "°C" else "°F" + + Column(Modifier.padding(16.dp)) { + Text(text = locationName, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = iconRes), + contentDescription = description, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "${Math.round(current.temperature)}$tempSymbol", + style = MaterialTheme.typography.displayMedium + ) + Spacer(modifier = Modifier.width(16.dp)) + Text(text = description, style = MaterialTheme.typography.bodyLarge) + } + } + } + } +} diff --git a/app/src/main/java/com/example/theloop/ui/theme/Theme.kt b/app/src/main/java/com/example/theloop/ui/theme/Theme.kt new file mode 100644 index 0000000..364fa92 --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package com.example.theloop.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme() +private val LightColorScheme = lightColorScheme() + +@Composable +fun TheLoopTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/example/theloop/ui/theme/Type.kt b/app/src/main/java/com/example/theloop/ui/theme/Type.kt new file mode 100644 index 0000000..2075204 --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/theme/Type.kt @@ -0,0 +1,24 @@ +package com.example.theloop.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, // Expressive large greeting + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/java/com/example/theloop/utils/AppUtils.java b/app/src/main/java/com/example/theloop/utils/AppUtils.java deleted file mode 100644 index 4bb5423..0000000 --- a/app/src/main/java/com/example/theloop/utils/AppUtils.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.example.theloop.utils; - -import android.content.Context; -import android.text.format.DateUtils; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import com.example.theloop.R; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; - - -public final class AppUtils { - - private static final String TAG = "AppUtils"; - - private static final DateTimeFormatter WEATHER_DATE_INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US); - private static final DateTimeFormatter WEATHER_DATE_DAY_FORMAT = DateTimeFormatter.ofPattern("EEE d", Locale.getDefault()); - - private AppUtils() { - // This class is not meant to be instantiated. - } - - /** - * Pre-formats forecast dates to avoid repeated parsing during RecyclerView binding. - * - * @param rawDates List of date strings in "yyyy-MM-dd" format. - * @return List of formatted date strings (e.g., "Mon 1"), or "-" on error. - */ - public static List formatForecastDates(List rawDates) { - if (rawDates == null) { - return Collections.emptyList(); - } - - List formatted = new ArrayList<>(rawDates.size()); - for (String raw : rawDates) { - try { - LocalDate date = LocalDate.parse(raw, WEATHER_DATE_INPUT_FORMAT); - formatted.add(date.format(WEATHER_DATE_DAY_FORMAT)); - } catch (DateTimeParseException e) { - formatted.add("-"); - } - } - return formatted; - } - - @StringRes - public static int getWeatherDescription(int weatherCode) { - return switch (weatherCode) { - case 0 -> R.string.weather_clear_sky; - case 1 -> R.string.weather_mainly_clear; - case 2 -> R.string.weather_partly_cloudy; - case 3 -> R.string.weather_overcast; - case 45, 48 -> R.string.weather_fog; - case 51, 53, 55 -> R.string.weather_drizzle; - case 61, 63, 65 -> R.string.weather_rain; - case 71, 73, 75 -> R.string.weather_snow_fall; - case 80, 81, 82 -> R.string.weather_rain_showers; - case 95 -> R.string.weather_thunderstorm; - case 96, 99 -> R.string.weather_thunderstorm_with_hail; - default -> R.string.weather_unknown; - }; - } - - @DrawableRes - public static int getWeatherIconResource(int weatherCode) { - return switch (weatherCode) { - case 0 -> R.drawable.ic_weather_sunny; - case 1, 2 -> R.drawable.ic_weather_partly_cloudy; - case 3 -> R.drawable.ic_weather_cloudy; - case 45, 48 -> R.drawable.ic_weather_foggy; - case 51, 53, 55, 61, 63, 65, 80, 81, 82 -> R.drawable.ic_weather_rainy; - case 71, 73, 75, 85, 86 -> R.drawable.ic_weather_snowy; - case 95, 96, 99 -> R.drawable.ic_weather_thunderstorm; - default -> R.drawable.ic_weather_cloudy; - }; - } - - public static String formatEventTime(@NonNull Context context, long startTime, long endTime) { - return DateUtils.formatDateRange(context, startTime, endTime, DateUtils.FORMAT_SHOW_TIME); - } -} diff --git a/app/src/main/java/com/example/theloop/utils/AppUtils.kt b/app/src/main/java/com/example/theloop/utils/AppUtils.kt new file mode 100644 index 0000000..ec46de0 --- /dev/null +++ b/app/src/main/java/com/example/theloop/utils/AppUtils.kt @@ -0,0 +1,66 @@ +package com.example.theloop.utils + +import android.content.Context +import android.text.format.DateUtils +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.example.theloop.R +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Locale + +object AppUtils { + private val WEATHER_DATE_INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US) + private val WEATHER_DATE_DAY_FORMAT = DateTimeFormatter.ofPattern("EEE d", Locale.getDefault()) + + fun formatForecastDates(rawDates: List?): List { + if (rawDates == null) { + return emptyList() + } + return rawDates.map { raw -> + try { + val date = LocalDate.parse(raw, WEATHER_DATE_INPUT_FORMAT) + date.format(WEATHER_DATE_DAY_FORMAT) + } catch (e: DateTimeParseException) { + "-" + } + } + } + + @StringRes + fun getWeatherDescription(weatherCode: Int): Int { + return when (weatherCode) { + 0 -> R.string.weather_clear_sky + 1 -> R.string.weather_mainly_clear + 2 -> R.string.weather_partly_cloudy + 3 -> R.string.weather_overcast + 45, 48 -> R.string.weather_fog + 51, 53, 55 -> R.string.weather_drizzle + 61, 63, 65 -> R.string.weather_rain + 71, 73, 75 -> R.string.weather_snow_fall + 80, 81, 82 -> R.string.weather_rain_showers + 95 -> R.string.weather_thunderstorm + 96, 99 -> R.string.weather_thunderstorm_with_hail + else -> R.string.weather_unknown + } + } + + @DrawableRes + fun getWeatherIconResource(weatherCode: Int): Int { + return when (weatherCode) { + 0 -> R.drawable.ic_weather_sunny + 1, 2 -> R.drawable.ic_weather_partly_cloudy + 3 -> R.drawable.ic_weather_cloudy + 45, 48 -> R.drawable.ic_weather_foggy + 51, 53, 55, 61, 63, 65, 80, 81, 82 -> R.drawable.ic_weather_rainy + 71, 73, 75, 85, 86 -> R.drawable.ic_weather_snowy + 95, 96, 99 -> R.drawable.ic_weather_thunderstorm + else -> R.drawable.ic_weather_cloudy + } + } + + fun formatEventTime(context: Context, startTime: Long, endTime: Long): String { + return DateUtils.formatDateRange(context, startTime, endTime, DateUtils.FORMAT_SHOW_TIME) + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 35010bc..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/app/src/main/res/layout/card_calendar.xml b/app/src/main/res/layout/card_calendar.xml deleted file mode 100644 index cb4dea5..0000000 --- a/app/src/main/res/layout/card_calendar.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_day_ahead.xml b/app/src/main/res/layout/card_day_ahead.xml deleted file mode 100644 index 438c1a4..0000000 --- a/app/src/main/res/layout/card_day_ahead.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_fun_fact.xml b/app/src/main/res/layout/card_fun_fact.xml deleted file mode 100644 index 7cafd45..0000000 --- a/app/src/main/res/layout/card_fun_fact.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_headlines.xml b/app/src/main/res/layout/card_headlines.xml deleted file mode 100644 index 4f36ca2..0000000 --- a/app/src/main/res/layout/card_headlines.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_weather.xml b/app/src/main/res/layout/card_weather.xml deleted file mode 100644 index 8afc2ef..0000000 --- a/app/src/main/res/layout/card_weather.xml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_calendar_event.xml b/app/src/main/res/layout/item_calendar_event.xml deleted file mode 100644 index 0059f59..0000000 --- a/app/src/main/res/layout/item_calendar_event.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_daily_forecast.xml b/app/src/main/res/layout/item_daily_forecast.xml deleted file mode 100644 index 8cb5b28..0000000 --- a/app/src/main/res/layout/item_daily_forecast.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_headline.xml b/app/src/main/res/layout/item_headline.xml deleted file mode 100644 index 104da83..0000000 --- a/app/src/main/res/layout/item_headline.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_footer.xml b/app/src/main/res/layout/item_settings_footer.xml deleted file mode 100644 index e366421..0000000 --- a/app/src/main/res/layout/item_settings_footer.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index 71d8e85..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - #263238 - diff --git a/app/src/test/java/com/example/theloop/utils/AppUtilsTest.java b/app/src/test/java/com/example/theloop/utils/AppUtilsTest.java deleted file mode 100644 index 208bf89..0000000 --- a/app/src/test/java/com/example/theloop/utils/AppUtilsTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.example.theloop.utils; - -import android.content.Context; -import androidx.test.core.app.ApplicationProvider; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; -import static org.junit.Assert.*; - -import com.example.theloop.R; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = {28}) -public class AppUtilsTest { - - private Context context = ApplicationProvider.getApplicationContext(); - - @Test - public void getWeatherDescription_mapsCodesCorrectly() { - assertEquals(R.string.weather_clear_sky, AppUtils.getWeatherDescription(0)); - assertEquals(R.string.weather_partly_cloudy, AppUtils.getWeatherDescription(2)); - assertEquals(R.string.weather_fog, AppUtils.getWeatherDescription(45)); - assertEquals(R.string.weather_rain, AppUtils.getWeatherDescription(61)); - assertEquals(R.string.weather_snow_fall, AppUtils.getWeatherDescription(75)); - assertEquals(R.string.weather_thunderstorm, AppUtils.getWeatherDescription(95)); - assertEquals(R.string.weather_thunderstorm_with_hail, AppUtils.getWeatherDescription(96)); - assertEquals(R.string.weather_thunderstorm_with_hail, AppUtils.getWeatherDescription(99)); - assertEquals(R.string.weather_unknown, AppUtils.getWeatherDescription(1000)); - } - - @Test - public void getWeatherIconResource_mapsCodesToCorrectDrawables() { - assertEquals(R.drawable.ic_weather_sunny, AppUtils.getWeatherIconResource(0)); - assertEquals(R.drawable.ic_weather_partly_cloudy, AppUtils.getWeatherIconResource(2)); - assertEquals(R.drawable.ic_weather_cloudy, AppUtils.getWeatherIconResource(3)); - assertEquals(R.drawable.ic_weather_foggy, AppUtils.getWeatherIconResource(45)); - assertEquals(R.drawable.ic_weather_rainy, AppUtils.getWeatherIconResource(61)); - assertEquals(R.drawable.ic_weather_snowy, AppUtils.getWeatherIconResource(71)); - assertEquals(R.drawable.ic_weather_thunderstorm, AppUtils.getWeatherIconResource(95)); - assertEquals(R.drawable.ic_weather_cloudy, AppUtils.getWeatherIconResource(999)); // Default case - } - - @Test - public void formatForecastDates_formatsDatesCorrectly() { - List rawDates = Arrays.asList("2023-10-25", "2023-10-26", "invalid-date"); - List formatted = AppUtils.formatForecastDates(rawDates); - - assertEquals(3, formatted.size()); - - // Verify success cases dynamically to avoid locale-dependent failures - DateTimeFormatter testFormatter = DateTimeFormatter.ofPattern("EEE d", Locale.getDefault()); - assertEquals(LocalDate.parse("2023-10-25").format(testFormatter), formatted.get(0)); - assertEquals(LocalDate.parse("2023-10-26").format(testFormatter), formatted.get(1)); - assertEquals("-", formatted.get(2)); - } -} diff --git a/app/src/test/java/com/example/theloop/utils/AppUtilsTest.kt b/app/src/test/java/com/example/theloop/utils/AppUtilsTest.kt new file mode 100644 index 0000000..f53b033 --- /dev/null +++ b/app/src/test/java/com/example/theloop/utils/AppUtilsTest.kt @@ -0,0 +1,55 @@ +package com.example.theloop.utils + +import androidx.test.core.app.ApplicationProvider +import com.example.theloop.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) // Adjusted SDK for Robolectric compatibility +class AppUtilsTest { + + @Test + fun getWeatherDescription_mapsCodesCorrectly() { + assertEquals(R.string.weather_clear_sky, AppUtils.getWeatherDescription(0)) + assertEquals(R.string.weather_partly_cloudy, AppUtils.getWeatherDescription(2)) + assertEquals(R.string.weather_fog, AppUtils.getWeatherDescription(45)) + assertEquals(R.string.weather_rain, AppUtils.getWeatherDescription(61)) + assertEquals(R.string.weather_snow_fall, AppUtils.getWeatherDescription(75)) + assertEquals(R.string.weather_thunderstorm, AppUtils.getWeatherDescription(95)) + assertEquals(R.string.weather_thunderstorm_with_hail, AppUtils.getWeatherDescription(96)) + assertEquals(R.string.weather_thunderstorm_with_hail, AppUtils.getWeatherDescription(99)) + assertEquals(R.string.weather_unknown, AppUtils.getWeatherDescription(1000)) + } + + @Test + fun getWeatherIconResource_mapsCodesToCorrectDrawables() { + assertEquals(R.drawable.ic_weather_sunny, AppUtils.getWeatherIconResource(0)) + assertEquals(R.drawable.ic_weather_partly_cloudy, AppUtils.getWeatherIconResource(2)) + assertEquals(R.drawable.ic_weather_cloudy, AppUtils.getWeatherIconResource(3)) + assertEquals(R.drawable.ic_weather_foggy, AppUtils.getWeatherIconResource(45)) + assertEquals(R.drawable.ic_weather_rainy, AppUtils.getWeatherIconResource(61)) + assertEquals(R.drawable.ic_weather_snowy, AppUtils.getWeatherIconResource(71)) + assertEquals(R.drawable.ic_weather_thunderstorm, AppUtils.getWeatherIconResource(95)) + assertEquals(R.drawable.ic_weather_cloudy, AppUtils.getWeatherIconResource(999)) // Default case + } + + @Test + fun formatForecastDates_formatsDatesCorrectly() { + val rawDates = listOf("2023-10-25", "2023-10-26", "invalid-date") + val formatted = AppUtils.formatForecastDates(rawDates) + + assertEquals(3, formatted.size) + + val testFormatter = DateTimeFormatter.ofPattern("EEE d", Locale.getDefault()) + assertEquals(LocalDate.parse("2023-10-25").format(testFormatter), formatted[0]) + assertEquals(LocalDate.parse("2023-10-26").format(testFormatter), formatted[1]) + assertEquals("-", formatted[2]) + } +} diff --git a/settings.gradle b/settings.gradle index aceb280..f17a051 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,8 @@ pluginManagement { plugins { id 'com.android.application' version '8.3.2' apply false id 'com.android.library' version '8.3.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.10' apply false + id 'com.google.dagger.hilt.android' version '2.51.1' apply false } } From 930db66f3d1a9c2d55e05e404445b6f68e619b33 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:07:08 +0000 Subject: [PATCH 02/19] Update app/src/main/java/com/example/theloop/ui/HomeScreen.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/ui/HomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt index 6499671..ed01186 100644 --- a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -53,7 +53,7 @@ fun HomeScreen( weather = state.weather, locationName = state.locationName, tempUnit = state.tempUnit, - onClick = { viewModel.openWeatherSettings() } + onClick = { /* TODO: Implement navigation to settings */ } ) } From 7a4d79e0f6d5e1bb73c989bbdff114f68e147d08 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:07:17 +0000 Subject: [PATCH 03/19] Update app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt index 7ec654f..e3f5b01 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -44,8 +44,8 @@ class WidgetUpdateWorker @AssistedInject constructor( // Update Data (saves to DB) weatherRepo.refresh(lat, lon, unit) - try { newsRepo.refreshNews() } catch (e: Exception) {} - try { calendarRepo.refreshEvents() } catch (e: Exception) {} + try { newsRepo.refreshNews() } catch (e: Exception) { android.util.Log.e("WidgetUpdateWorker", "Failed to refresh news", e) } + try { calendarRepo.refreshEvents() } catch (e: Exception) { android.util.Log.e("WidgetUpdateWorker", "Failed to refresh calendar", e) } // Get latest data for summary val weather = weatherRepo.weatherData.first() From 32d9ba5a83f332b023d9095b3eda957391eb8735 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:07:25 +0000 Subject: [PATCH 04/19] Update app/src/main/java/com/example/theloop/DayAheadWidget.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/DayAheadWidget.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index 24bb478..4f3575b 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -37,7 +37,7 @@ class DayAheadWidget : AppWidgetProvider() { updateAppWidget(context, appWidgetManager, appWidgetId, weatherEntity?.json, summary, gson) } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("DayAheadWidget", "Error in onUpdate", e) } finally { pendingResult.finish() } From ecb0bd7d0a060b6dd4adbca8e6ec4da0d2a9596c Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:07:53 +0000 Subject: [PATCH 05/19] Update app/src/main/java/com/example/theloop/MainViewModel.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/MainViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 2aec048..25d90e3 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -158,9 +158,9 @@ class MainViewModel @Inject constructor( val name = address.locality ?: address.subAdminArea ?: "Unknown Location" _locationName.value = name } - } catch (e: Exception) { - _locationName.value = "Unknown Location" - } + } catch (e: Exception) { + _locationName.value = "Unknown Location" + } } } } From 07902c5ff27a20a78b88372326f0f7cde099bdd0 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:08:18 +0000 Subject: [PATCH 06/19] Update app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt index e3f5b01..e0e4ae2 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -79,7 +79,7 @@ class WidgetUpdateWorker @AssistedInject constructor( Result.success() } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("WidgetUpdateWorker", "Widget update failed", e) Result.retry() } } From bbe79e98b89a9688da12b921c98f01ca28bb14d7 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:08:29 +0000 Subject: [PATCH 07/19] Update app/src/main/java/com/example/theloop/ui/HomeScreen.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/ui/HomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt index ed01186..2510346 100644 --- a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -88,7 +88,7 @@ fun HomeScreen( try { context.startActivity(intent) } catch (e: Exception) { - // Handle exception + android.widget.Toast.makeText(context, "Could not open link", android.widget.Toast.LENGTH_SHORT).show() } } } From 2ecb8a4da1e2e2e21bda87ebad9aba49b47d94a3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:40:29 +0000 Subject: [PATCH 08/19] Fix permissions handling, date formatting, and logging - Implemented permission result handling in MainActivity to refresh data or show feedback. - Replaced hardcoded SimpleDateFormat with DateUtils in CalendarCard to respect user time format preference. - Added TODO in MainViewModel regarding hardcoded news category. - Replaced e.printStackTrace() with Log.e() in repositories for better error tracking. - Added TAG constants to repositories. --- .../java/com/example/theloop/MainActivity.kt | 18 ++++++++++++++++++ .../java/com/example/theloop/MainViewModel.kt | 1 + .../data/repository/CalendarRepository.kt | 7 ++++++- .../theloop/data/repository/NewsRepository.kt | 7 ++++++- .../data/repository/WeatherRepository.kt | 7 ++++++- .../theloop/ui/components/CalendarCard.kt | 12 ++++++------ 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/example/theloop/MainActivity.kt b/app/src/main/java/com/example/theloop/MainActivity.kt index bc0c1b6..85c4f65 100644 --- a/app/src/main/java/com/example/theloop/MainActivity.kt +++ b/app/src/main/java/com/example/theloop/MainActivity.kt @@ -2,7 +2,9 @@ package com.example.theloop import android.Manifest import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import com.example.theloop.ui.HomeScreen @@ -12,10 +14,26 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> // Handle permissions granted/denied. ViewModel might reload if needed. + val anyGranted = permissions.any { it.value } + val anyDenied = permissions.any { !it.value } + + if (anyGranted) { + viewModel.refreshAll() + } + + if (anyDenied) { + Toast.makeText( + this, + "Permissions denied. Some features (Weather, Calendar) may be unavailable.", + Toast.LENGTH_LONG + ).show() + } } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 25d90e3..f106bde 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -54,6 +54,7 @@ class MainViewModel @Inject constructor( private val _userName = MutableStateFlow(prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User") val weather = weatherRepo.weatherData + // TODO: Re-implement category selection (regression). Hardcoded to "US" for now. val news = newsRepo.getArticles("US") // Default to US or General val events = calendarRepo.events diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index ca5d0e5..6f721dd 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -2,6 +2,7 @@ package com.example.theloop.data.repository import android.content.Context import android.provider.CalendarContract +import android.util.Log import com.example.theloop.data.local.dao.CalendarEventDao import com.example.theloop.data.local.entity.CalendarEventEntity import com.example.theloop.models.CalendarEvent @@ -85,8 +86,12 @@ class CalendarRepository @Inject constructor( dao.insertEvents(events) } } catch (e: Exception) { - e.printStackTrace() + Log.e(TAG, "Error refreshing events", e) } } } + + companion object { + private const val TAG = "CalendarRepository" + } } diff --git a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt index 73bbec9..0d58e0b 100644 --- a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -1,5 +1,6 @@ package com.example.theloop.data.repository +import android.util.Log import com.example.theloop.data.local.dao.ArticleDao import com.example.theloop.data.local.entity.ArticleEntity import com.example.theloop.models.Article @@ -45,11 +46,15 @@ class NewsRepository @Inject constructor( } } } catch (e: Exception) { - e.printStackTrace() + Log.e(TAG, "Error refreshing news", e) } } } + companion object { + private const val TAG = "NewsRepository" + } + private fun Article.toEntity(category: String): ArticleEntity { return ArticleEntity( title = this.title ?: "", diff --git a/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt index 8f50dd2..44a21eb 100644 --- a/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt @@ -1,5 +1,6 @@ package com.example.theloop.data.repository +import android.util.Log import com.example.theloop.data.local.dao.WeatherDao import com.example.theloop.data.local.entity.WeatherEntity import com.example.theloop.models.WeatherResponse @@ -48,8 +49,12 @@ class WeatherRepository @Inject constructor( } } } catch (e: Exception) { - e.printStackTrace() + Log.e(TAG, "Error refreshing weather", e) } } } + + companion object { + private const val TAG = "WeatherRepository" + } } diff --git a/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt b/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt index 2fd5b3c..8000c55 100644 --- a/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt +++ b/app/src/main/java/com/example/theloop/ui/components/CalendarCard.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import android.text.format.DateUtils import com.example.theloop.models.CalendarEvent -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun CalendarCard( @@ -34,11 +33,12 @@ fun CalendarCard( @Composable fun EventItem(event: CalendarEvent) { Row(Modifier.padding(vertical = 8.dp)) { - val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val context = LocalContext.current + val timeString = DateUtils.formatDateTime(context, event.startTime, DateUtils.FORMAT_SHOW_TIME) Text( - text = timeFormat.format(Date(event.startTime)), + text = timeString, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.width(60.dp) + modifier = Modifier.width(75.dp) ) Column { Text(text = event.title, style = MaterialTheme.typography.bodyLarge) From 9fc1842008b8fedcf3a68955a55cde25a5403b94 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:38:02 +0000 Subject: [PATCH 09/19] Fix permissions, formatting, logging, and data safety - Implemented permission result handling in MainActivity. - Replaced hardcoded date format with DateUtils in CalendarCard. - Re-implemented dynamic news category selection in MainViewModel. - Added isLoading state to MainViewModel. - Replaced e.printStackTrace() with Log.e() in repositories. - Updated repositories to return Boolean success/failure status. - Implemented transactional replaceAll in ArticleDao/NewsRepository to prevent data loss. - Implemented retry logic in WidgetUpdateWorker based on refresh success. - Created missing colors.xml to fix build failure. - Implemented settings navigation in HomeScreen. --- .../java/com/example/theloop/MainViewModel.kt | 90 +++++++++++-------- .../com/example/theloop/WidgetUpdateWorker.kt | 12 ++- .../theloop/data/local/dao/ArticleDao.kt | 7 ++ .../data/repository/CalendarRepository.kt | 8 +- .../theloop/data/repository/NewsRepository.kt | 10 ++- .../data/repository/WeatherRepository.kt | 7 +- .../java/com/example/theloop/ui/HomeScreen.kt | 7 +- app/src/main/res/values/colors.xml | 4 + 8 files changed, 93 insertions(+), 52 deletions(-) create mode 100644 app/src/main/res/values/colors.xml diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index f106bde..67c8d83 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -20,9 +20,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject data class MainUiState( @@ -48,14 +51,18 @@ class MainViewModel @Inject constructor( private val _locationName = MutableStateFlow("Loading...") private val _funFact = MutableStateFlow(null) + private val _isLoading = MutableStateFlow(false) + private val _newsCategory = MutableStateFlow("US") private val prefs = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) private val _tempUnit = MutableStateFlow(prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: "celsius") private val _userName = MutableStateFlow(prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User") val weather = weatherRepo.weatherData - // TODO: Re-implement category selection (regression). Hardcoded to "US" for now. - val news = newsRepo.getArticles("US") // Default to US or General + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + val news = _newsCategory.flatMapLatest { category -> + newsRepo.getArticles(category) + } val events = calendarRepo.events val uiState: StateFlow = combine( @@ -65,8 +72,9 @@ class MainViewModel @Inject constructor( _funFact, _locationName, _userName, - _tempUnit - ) { weatherData, newsData, calendarData, funFactData, locationName, userName, tempUnit -> + _tempUnit, + _isLoading + ) { weatherData, newsData, calendarData, funFactData, locationName, userName, tempUnit, isLoading -> val summaryText = if (weatherData != null) { SummaryUtils.generateSummary( @@ -88,7 +96,8 @@ class MainViewModel @Inject constructor( summary = summaryText, locationName = locationName, userGreeting = "${SummaryUtils.getTimeBasedGreeting()}, $userName", - tempUnit = tempUnit + tempUnit = tempUnit, + isLoading = isLoading ) }.onEach { state -> if (state.summary != null) { @@ -101,55 +110,60 @@ class MainViewModel @Inject constructor( ) init { - refreshAll() + viewModelScope.launch { + refreshAll() + } } fun refreshAll() { - val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) - val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) - - val lat = try { latStr?.toDouble() ?: AppConstants.DEFAULT_LATITUDE } catch (e: Exception) { AppConstants.DEFAULT_LATITUDE } - val lon = try { lonStr?.toDouble() ?: AppConstants.DEFAULT_LONGITUDE } catch (e: Exception) { AppConstants.DEFAULT_LONGITUDE } + viewModelScope.launch { + _isLoading.value = true + val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) + val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) + + val lat = try { latStr?.toDouble() ?: AppConstants.DEFAULT_LATITUDE } catch (e: Exception) { AppConstants.DEFAULT_LATITUDE } + val lon = try { lonStr?.toDouble() ?: AppConstants.DEFAULT_LONGITUDE } catch (e: Exception) { AppConstants.DEFAULT_LONGITUDE } + + val jobs = listOf( + launch { fetchWeather(lat, lon) }, + launch { fetchNews() }, + launch { fetchCalendar() }, + launch { fetchFunFact() } + ) + jobs.joinAll() + _isLoading.value = false + } + } - fetchWeather(lat, lon) - fetchNews() - fetchCalendar() - fetchFunFact() + fun setNewsCategory(category: String) { + _newsCategory.value = category } - fun fetchWeather(lat: Double, lon: Double) { - viewModelScope.launch { - fetchLocationName(lat, lon) - weatherRepo.refresh(lat, lon, _tempUnit.value) - } + suspend fun fetchWeather(lat: Double, lon: Double) { + fetchLocationName(lat, lon) + weatherRepo.refresh(lat, lon, _tempUnit.value) } - fun fetchNews() { - viewModelScope.launch { - newsRepo.refreshNews() - } + suspend fun fetchNews() { + newsRepo.refreshNews() } - fun fetchCalendar() { - viewModelScope.launch { - calendarRepo.refreshEvents() - } + suspend fun fetchCalendar() { + calendarRepo.refreshEvents() } - fun fetchFunFact() { - viewModelScope.launch { - val fact = funFactRepo.getFunFact() - if (fact != null) { - _funFact.value = fact - } else { - _funFact.value = context.getString(R.string.fun_fact_fallback) - } + suspend fun fetchFunFact() { + val fact = funFactRepo.getFunFact() + if (fact != null) { + _funFact.value = fact + } else { + _funFact.value = context.getString(R.string.fun_fact_fallback) } } - private fun fetchLocationName(lat: Double, lon: Double) { + private suspend fun fetchLocationName(lat: Double, lon: Double) { if (Geocoder.isPresent()) { - viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { try { val geocoder = Geocoder(context, java.util.Locale.getDefault()) @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt index e0e4ae2..0fec82f 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -43,9 +43,9 @@ class WidgetUpdateWorker @AssistedInject constructor( val lon = lonStr.toDouble() // Update Data (saves to DB) - weatherRepo.refresh(lat, lon, unit) - try { newsRepo.refreshNews() } catch (e: Exception) { android.util.Log.e("WidgetUpdateWorker", "Failed to refresh news", e) } - try { calendarRepo.refreshEvents() } catch (e: Exception) { android.util.Log.e("WidgetUpdateWorker", "Failed to refresh calendar", e) } + val weatherSuccess = weatherRepo.refresh(lat, lon, unit) + val newsSuccess = newsRepo.refreshNews() + val calendarSuccess = calendarRepo.refreshEvents() // Get latest data for summary val weather = weatherRepo.weatherData.first() @@ -77,7 +77,11 @@ class WidgetUpdateWorker @AssistedInject constructor( intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) applicationContext.sendBroadcast(intent) - Result.success() + if (!weatherSuccess || !newsSuccess || !calendarSuccess) { + Result.retry() + } else { + Result.success() + } } catch (e: Exception) { android.util.Log.e("WidgetUpdateWorker", "Widget update failed", e) Result.retry() diff --git a/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt b/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt index 29cf4e1..76a0b21 100644 --- a/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt +++ b/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.example.theloop.data.local.entity.ArticleEntity import kotlinx.coroutines.flow.Flow @@ -20,4 +21,10 @@ interface ArticleDao { @Query("DELETE FROM articles") suspend fun clearAll() + + @Transaction + suspend fun replaceAll(articles: List) { + clearAll() + insertArticles(articles) + } } diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index 6f721dd..380223a 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -24,15 +24,15 @@ class CalendarRepository @Inject constructor( } } - suspend fun refreshEvents() { - withContext(Dispatchers.IO) { + suspend fun refreshEvents(): Boolean { + return withContext(Dispatchers.IO) { try { if (androidx.core.content.ContextCompat.checkSelfPermission( context, android.Manifest.permission.READ_CALENDAR ) != android.content.pm.PackageManager.PERMISSION_GRANTED ) { - return@withContext + return@withContext true } val events = ArrayList() @@ -85,8 +85,10 @@ class CalendarRepository @Inject constructor( if (events.isNotEmpty()) { dao.insertEvents(events) } + return@withContext true } catch (e: Exception) { Log.e(TAG, "Error refreshing events", e) + return@withContext false } } } diff --git a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt index 0d58e0b..7e5dd3e 100644 --- a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -21,8 +21,8 @@ class NewsRepository @Inject constructor( } } - suspend fun refreshNews() { - withContext(Dispatchers.IO) { + suspend fun refreshNews(): Boolean { + return withContext(Dispatchers.IO) { try { val response = api.getNewsFeed() if (response.isSuccessful) { @@ -40,13 +40,15 @@ class NewsRepository @Inject constructor( news.world?.let { allArticles.addAll(it.map { a -> a.toEntity("World") }) } if (allArticles.isNotEmpty()) { - dao.clearAll() - dao.insertArticles(allArticles) + dao.replaceAll(allArticles) } + return@withContext true } } + return@withContext false } catch (e: Exception) { Log.e(TAG, "Error refreshing news", e) + return@withContext false } } } diff --git a/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt index 44a21eb..5ecf13c 100644 --- a/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt @@ -29,8 +29,8 @@ class WeatherRepository @Inject constructor( } } - suspend fun refresh(lat: Double, lon: Double, unit: String) { - withContext(Dispatchers.IO) { + suspend fun refresh(lat: Double, lon: Double, unit: String): Boolean { + return withContext(Dispatchers.IO) { try { // Ensure timezone is valid, "auto" works for Open-Meteo val response = api.getWeather( @@ -46,10 +46,13 @@ class WeatherRepository @Inject constructor( if (body != null) { val json = gson.toJson(body) dao.insertWeather(WeatherEntity(id = 0, json = json, lastUpdated = System.currentTimeMillis())) + return@withContext true } } + return@withContext false } catch (e: Exception) { Log.e(TAG, "Error refreshing weather", e) + return@withContext false } } } diff --git a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt index 2510346..41c912b 100644 --- a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -53,7 +53,12 @@ fun HomeScreen( weather = state.weather, locationName = state.locationName, tempUnit = state.tempUnit, - onClick = { /* TODO: Implement navigation to settings */ } + onClick = { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } ) } diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0163adf --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #F5F5F5 + From dbbfb8369eb71db70700fbc11851e098ea98eeb5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:26:20 +0000 Subject: [PATCH 10/19] Fix permissions, formatting, logging, and data safety - Implemented permission result handling in MainActivity. - Replaced hardcoded date format with DateUtils in CalendarCard. - Re-implemented dynamic news category selection in MainViewModel. - Added isLoading state to MainViewModel. - Fixed combine function signature in MainViewModel to handle 8 flows. - Replaced e.printStackTrace() with Log.e() in repositories. - Updated repositories to return Boolean success/failure status. - Implemented transactional replaceAll in ArticleDao/NewsRepository to prevent data loss. - Implemented retry logic in WidgetUpdateWorker based on refresh success. - Created missing colors.xml to fix build failure. - Implemented settings navigation in HomeScreen. --- .../main/java/com/example/theloop/MainViewModel.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 67c8d83..32923ba 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -74,7 +74,17 @@ class MainViewModel @Inject constructor( _userName, _tempUnit, _isLoading - ) { weatherData, newsData, calendarData, funFactData, locationName, userName, tempUnit, isLoading -> + ) { args -> + val weatherData = args[0] as? WeatherResponse + @Suppress("UNCHECKED_CAST") + val newsData = args[1] as List
+ @Suppress("UNCHECKED_CAST") + val calendarData = args[2] as List + val funFactData = args[3] as? String + val locationName = args[4] as String + val userName = args[5] as String + val tempUnit = args[6] as String + val isLoading = args[7] as Boolean val summaryText = if (weatherData != null) { SummaryUtils.generateSummary( From 15ddb3ef60a8b0f9ba750564345989cc09b3b0f8 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:31:35 +0000 Subject: [PATCH 11/19] Update app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../com/example/theloop/data/repository/CalendarRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index 380223a..04b83ef 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -32,8 +32,7 @@ class CalendarRepository @Inject constructor( android.Manifest.permission.READ_CALENDAR ) != android.content.pm.PackageManager.PERMISSION_GRANTED ) { - return@withContext true - } + return@withContext false val events = ArrayList() val contentResolver = context.contentResolver From 8408e80e895bf9e04484b9b85e566417d60a522f Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:31:42 +0000 Subject: [PATCH 12/19] Update app/src/main/java/com/example/theloop/DayAheadWidget.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/DayAheadWidget.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index 4f3575b..25ad473 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -62,7 +62,7 @@ class DayAheadWidget : AppWidgetProvider() { views.setTextViewText(R.id.widget_temp, "%.0f°".format(current.temperature)) views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(current.weatherCode)) } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("DayAheadWidget", "Error parsing weather JSON for widget", e) } } From 93d6bf7044d53c3bbb41a296c306d190c9942f68 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:53:14 +0000 Subject: [PATCH 13/19] Fix permissions, formatting, logging, and data safety - Implemented permission result handling in MainActivity. - Replaced hardcoded date format with DateUtils in CalendarCard. - Re-implemented dynamic news category selection in MainViewModel. - Added isLoading state to MainViewModel. - Fixed combine function signature in MainViewModel to handle 8 flows. - Replaced e.printStackTrace() with Log.e() in repositories. - Updated repositories to return Boolean success/failure status. - Implemented transactional replaceAll in ArticleDao/NewsRepository to prevent data loss. - Implemented retry logic in WidgetUpdateWorker based on refresh success. - Created missing colors.xml to fix build failure. - Implemented settings navigation in HomeScreen. - Fixed syntax error in CalendarRepository. --- app/src/main/java/com/example/theloop/DayAheadWidget.kt | 2 +- .../com/example/theloop/data/repository/CalendarRepository.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index 25ad473..4f3575b 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -62,7 +62,7 @@ class DayAheadWidget : AppWidgetProvider() { views.setTextViewText(R.id.widget_temp, "%.0f°".format(current.temperature)) views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(current.weatherCode)) } catch (e: Exception) { - android.util.Log.e("DayAheadWidget", "Error parsing weather JSON for widget", e) + e.printStackTrace() } } diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index 04b83ef..380223a 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -32,7 +32,8 @@ class CalendarRepository @Inject constructor( android.Manifest.permission.READ_CALENDAR ) != android.content.pm.PackageManager.PERMISSION_GRANTED ) { - return@withContext false + return@withContext true + } val events = ArrayList() val contentResolver = context.contentResolver From 824fb9d6d63ae59b25c303551005c28da091a3b8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:17:18 +0000 Subject: [PATCH 14/19] Fix permissions, formatting, logging, build issues, and code quality - Implemented permission result handling in MainActivity. - Replaced hardcoded date format with DateUtils in CalendarCard. - Re-implemented dynamic news category selection in MainViewModel. - Added isLoading state to MainViewModel. - Refactored MainViewModel combine logic to use chained calls for type safety. - Replaced e.printStackTrace() with Log.e() in repositories and DayAheadWidget. - Updated repositories to return Boolean success/failure status. - Implemented transactional replaceAll in ArticleDao/NewsRepository. - Implemented retry logic in WidgetUpdateWorker. - Created missing colors.xml to fix build failure. - Implemented settings navigation in HomeScreen. - Fixed CalendarRepository syntax error and extracted magic number. - Added @JvmStatic to AppUtils for Java interop. - Extracted Hilt versions in build.gradle. - Added null/blank title filtering in NewsRepository. --- app/build.gradle | 12 +++-- .../com/example/theloop/DayAheadWidget.kt | 2 +- .../java/com/example/theloop/MainViewModel.kt | 51 ++++++++----------- .../data/repository/CalendarRepository.kt | 3 +- .../theloop/data/repository/NewsRepository.kt | 16 +++--- .../com/example/theloop/utils/AppUtils.kt | 2 + 6 files changed, 40 insertions(+), 46 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7d93b41..77eb1be 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,11 +102,13 @@ dependencies { implementation 'androidx.compose.material:material-icons-extended' // Hilt - implementation "com.google.dagger:hilt-android:2.51.1" - kapt "com.google.dagger:hilt-compiler:2.51.1" - implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' - implementation 'androidx.hilt:hilt-work:1.2.0' - kapt 'androidx.hilt:hilt-compiler:1.2.0' + def hilt_version = "2.51.1" + def androidx_hilt_version = "1.2.0" + implementation "com.google.dagger:hilt-android:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + implementation "androidx.hilt:hilt-navigation-compose:$androidx_hilt_version" + implementation "androidx.hilt:hilt-work:$androidx_hilt_version" + kapt "androidx.hilt:hilt-compiler:$androidx_hilt_version" // Room def room_version = "2.6.1" diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index 4f3575b..a649b60 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -62,7 +62,7 @@ class DayAheadWidget : AppWidgetProvider() { views.setTextViewText(R.id.widget_temp, "%.0f°".format(current.temperature)) views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(current.weatherCode)) } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("DayAheadWidget", "Error parsing weather JSON", e) } } diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 32923ba..49ef6ed 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -66,45 +66,34 @@ class MainViewModel @Inject constructor( val events = calendarRepo.events val uiState: StateFlow = combine( - weather, - news, - events, - _funFact, - _locationName, - _userName, - _tempUnit, - _isLoading - ) { args -> - val weatherData = args[0] as? WeatherResponse - @Suppress("UNCHECKED_CAST") - val newsData = args[1] as List
- @Suppress("UNCHECKED_CAST") - val calendarData = args[2] as List - val funFactData = args[3] as? String - val locationName = args[4] as String - val userName = args[5] as String - val tempUnit = args[6] as String - val isLoading = args[7] as Boolean - - val summaryText = if (weatherData != null) { + weather, news, events, _funFact, _locationName + ) { weatherData, newsData, calendarData, funFactData, locationName -> + MainUiState( + weather = weatherData, + newsArticles = newsData, + calendarEvents = calendarData, + funFact = funFactData, + locationName = locationName + ) + }.combine( + combine(_userName, _tempUnit, _isLoading) { userName, tempUnit, isLoading -> + Triple(userName, tempUnit, isLoading) + } + ) { state, (userName, tempUnit, isLoading) -> + val summaryText = if (state.weather != null) { SummaryUtils.generateSummary( context, - weatherData, - calendarData, - calendarData.size, - newsData.firstOrNull(), + state.weather, + state.calendarEvents, + state.calendarEvents.size, + state.newsArticles.firstOrNull(), userName, false ) } else null - MainUiState( - weather = weatherData, - newsArticles = newsData, - calendarEvents = calendarData, - funFact = funFactData, + state.copy( summary = summaryText, - locationName = locationName, userGreeting = "${SummaryUtils.getTimeBasedGreeting()}, $userName", tempUnit = tempUnit, isLoading = isLoading diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index 380223a..de7fcbc 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -67,7 +67,7 @@ class CalendarRepository @Inject constructor( val ownerIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_DISPLAY_NAME) - while (cursor.moveToNext() && events.size < 5) { // Limit to 5 + while (cursor.moveToNext() && events.size < MAX_EVENTS_TO_FETCH) { events.add( CalendarEventEntity( id = cursor.getLong(idIdx), @@ -95,5 +95,6 @@ class CalendarRepository @Inject constructor( companion object { private const val TAG = "CalendarRepository" + private const val MAX_EVENTS_TO_FETCH = 5 } } diff --git a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt index 7e5dd3e..ff60df4 100644 --- a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -30,14 +30,14 @@ class NewsRepository @Inject constructor( if (news != null) { val allArticles = mutableListOf() // Map each category - news.business?.let { allArticles.addAll(it.map { a -> a.toEntity("Business") }) } - news.entertainment?.let { allArticles.addAll(it.map { a -> a.toEntity("Entertainment") }) } - news.health?.let { allArticles.addAll(it.map { a -> a.toEntity("Health") }) } - news.science?.let { allArticles.addAll(it.map { a -> a.toEntity("Science") }) } - news.sports?.let { allArticles.addAll(it.map { a -> a.toEntity("Sports") }) } - news.technology?.let { allArticles.addAll(it.map { a -> a.toEntity("Technology") }) } - news.us?.let { allArticles.addAll(it.map { a -> a.toEntity("US") }) } - news.world?.let { allArticles.addAll(it.map { a -> a.toEntity("World") }) } + news.business?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Business") }) } + news.entertainment?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Entertainment") }) } + news.health?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Health") }) } + news.science?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Science") }) } + news.sports?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Sports") }) } + news.technology?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Technology") }) } + news.us?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("US") }) } + news.world?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("World") }) } if (allArticles.isNotEmpty()) { dao.replaceAll(allArticles) diff --git a/app/src/main/java/com/example/theloop/utils/AppUtils.kt b/app/src/main/java/com/example/theloop/utils/AppUtils.kt index ec46de0..96c7649 100644 --- a/app/src/main/java/com/example/theloop/utils/AppUtils.kt +++ b/app/src/main/java/com/example/theloop/utils/AppUtils.kt @@ -28,6 +28,7 @@ object AppUtils { } } + @JvmStatic @StringRes fun getWeatherDescription(weatherCode: Int): Int { return when (weatherCode) { @@ -46,6 +47,7 @@ object AppUtils { } } + @JvmStatic @DrawableRes fun getWeatherIconResource(weatherCode: Int): Int { return when (weatherCode) { From 198052f6283a062b534c49d42a70bbe6776315eb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:42:48 +0000 Subject: [PATCH 15/19] Fix permissions, formatting, logging, build issues, and code quality - Implemented permission result handling in MainActivity. - Replaced hardcoded date format with DateUtils in CalendarCard. - Re-implemented dynamic news category selection in MainViewModel. - Added isLoading state to MainViewModel. - Refactored MainViewModel combine logic to use chained calls for type safety. - Replaced e.printStackTrace() with Log.e() in repositories and DayAheadWidget. - Updated repositories to return Boolean success/failure status. - Implemented transactional replaceAll in ArticleDao/NewsRepository. - Implemented retry logic in WidgetUpdateWorker. - Created missing colors.xml to fix build failure. - Implemented settings navigation in HomeScreen. - Fixed CalendarRepository syntax error and extracted magic number. - Added @JvmStatic to AppUtils for Java interop. - Extracted Hilt versions in build.gradle. - Added null/blank title filtering in NewsRepository. - Implemented location fetching in MainActivity and updated MainViewModel. - Updated Geocoder usage for API 33+. --- .../java/com/example/theloop/MainActivity.kt | 30 ++++++++++++++++++- .../java/com/example/theloop/MainViewModel.kt | 30 +++++++++++++++---- .../data/repository/CalendarRepository.kt | 1 + .../theloop/data/repository/NewsRepository.kt | 22 +++++++++----- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/example/theloop/MainActivity.kt b/app/src/main/java/com/example/theloop/MainActivity.kt index 85c4f65..3d30a9f 100644 --- a/app/src/main/java/com/example/theloop/MainActivity.kt +++ b/app/src/main/java/com/example/theloop/MainActivity.kt @@ -1,14 +1,17 @@ package com.example.theloop import android.Manifest +import android.content.pm.PackageManager import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.viewModels import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat import com.example.theloop.ui.HomeScreen import com.example.theloop.ui.theme.TheLoopTheme +import com.google.android.gms.location.LocationServices import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -24,7 +27,7 @@ class MainActivity : ComponentActivity() { val anyDenied = permissions.any { !it.value } if (anyGranted) { - viewModel.refreshAll() + fetchLocation() } if (anyDenied) { @@ -36,6 +39,31 @@ class MainActivity : ComponentActivity() { } } + private fun fetchLocation() { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + viewModel.refreshAll() + return + } + + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { + viewModel.updateLocation(location.latitude, location.longitude) + } else { + viewModel.refreshAll() + } + }.addOnFailureListener { + viewModel.refreshAll() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 49ef6ed..55ff409 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -134,6 +134,14 @@ class MainViewModel @Inject constructor( } } + fun updateLocation(lat: Double, lon: Double) { + prefs.edit() + .putString(AppConstants.KEY_LATITUDE, lat.toString()) + .putString(AppConstants.KEY_LONGITUDE, lon.toString()) + .apply() + refreshAll() + } + fun setNewsCategory(category: String) { _newsCategory.value = category } @@ -165,12 +173,22 @@ class MainViewModel @Inject constructor( withContext(Dispatchers.IO) { try { val geocoder = Geocoder(context, java.util.Locale.getDefault()) - @Suppress("DEPRECATION") - val addresses = geocoder.getFromLocation(lat, lon, 1) - if (!addresses.isNullOrEmpty()) { - val address = addresses[0] - val name = address.locality ?: address.subAdminArea ?: "Unknown Location" - _locationName.value = name + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + geocoder.getFromLocation(lat, lon, 1) { addresses -> + if (addresses.isNotEmpty()) { + val address = addresses[0] + val name = address.locality ?: address.subAdminArea ?: "Unknown Location" + _locationName.value = name + } + } + } else { + @Suppress("DEPRECATION") + val addresses = geocoder.getFromLocation(lat, lon, 1) + if (!addresses.isNullOrEmpty()) { + val address = addresses[0] + val name = address.locality ?: address.subAdminArea ?: "Unknown Location" + _locationName.value = name + } } } catch (e: Exception) { _locationName.value = "Unknown Location" diff --git a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt index de7fcbc..97d10dc 100644 --- a/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -32,6 +32,7 @@ class CalendarRepository @Inject constructor( android.Manifest.permission.READ_CALENDAR ) != android.content.pm.PackageManager.PERMISSION_GRANTED ) { + // Return true to prevent WidgetUpdateWorker from retrying indefinitely return@withContext true } diff --git a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt index ff60df4..eb3fc05 100644 --- a/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -30,14 +30,20 @@ class NewsRepository @Inject constructor( if (news != null) { val allArticles = mutableListOf() // Map each category - news.business?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Business") }) } - news.entertainment?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Entertainment") }) } - news.health?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Health") }) } - news.science?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Science") }) } - news.sports?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Sports") }) } - news.technology?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("Technology") }) } - news.us?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("US") }) } - news.world?.filter { !it.title.isNullOrBlank() }?.let { allArticles.addAll(it.map { a -> a.toEntity("World") }) } + fun addArticles(category: String, articles: List
?) { + articles?.filter { !it.title.isNullOrBlank() } + ?.map { it.toEntity(category) } + ?.let { allArticles.addAll(it) } + } + + addArticles("Business", news.business) + addArticles("Entertainment", news.entertainment) + addArticles("Health", news.health) + addArticles("Science", news.science) + addArticles("Sports", news.sports) + addArticles("Technology", news.technology) + addArticles("US", news.us) + addArticles("World", news.world) if (allArticles.isNotEmpty()) { dao.replaceAll(allArticles) From 0a419f90101fa44f342a08082346d3f6c8d4f7b4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:42:00 +0000 Subject: [PATCH 16/19] Fix race condition in MainViewModel and refactor DayAheadWidget - Updated `fetchLocationName` in `MainViewModel` to use `suspendCancellableCoroutine` for the Tiramisu `Geocoder` API, preventing a race condition where the coroutine scope would close before the callback fired. - Refactored `DayAheadWidget` to inject `WeatherRepository` instead of `WeatherDao` and `Gson`. - Simplified `DayAheadWidget.onUpdate` to fetch parsed weather data directly from the repository flow, removing data access and parsing logic from the UI component. --- .../com/example/theloop/DayAheadWidget.kt | 23 ++++++++----------- .../java/com/example/theloop/MainViewModel.kt | 17 ++++++++++---- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index a649b60..2ae9e88 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -4,14 +4,14 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.widget.RemoteViews -import com.example.theloop.data.local.dao.WeatherDao +import com.example.theloop.data.repository.WeatherRepository import com.example.theloop.models.WeatherResponse import com.example.theloop.utils.AppConstants import com.example.theloop.utils.AppUtils -import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -19,22 +19,19 @@ import javax.inject.Inject class DayAheadWidget : AppWidgetProvider() { @Inject - lateinit var weatherDao: WeatherDao - - @Inject - lateinit var gson: Gson + lateinit var weatherRepository: WeatherRepository override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { val pendingResult = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - val weatherEntity = weatherDao.getWeather() + val weather = weatherRepository.weatherData.first() val summary = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) .getString(AppConstants.KEY_SUMMARY_CACHE, context.getString(R.string.widget_default_summary)) for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId, weatherEntity?.json, summary, gson) + updateAppWidget(context, appWidgetManager, appWidgetId, weather, summary) } } catch (e: Exception) { android.util.Log.e("DayAheadWidget", "Error in onUpdate", e) @@ -48,21 +45,19 @@ class DayAheadWidget : AppWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, - weatherJson: String?, - summary: String?, - gson: Gson + weather: WeatherResponse?, + summary: String? ) { val views = RemoteViews(context.packageName, R.layout.widget_day_ahead) views.setTextViewText(R.id.widget_summary, summary) - if (weatherJson != null) { + if (weather != null) { try { - val weather = gson.fromJson(weatherJson, WeatherResponse::class.java) val current = weather.current views.setTextViewText(R.id.widget_temp, "%.0f°".format(current.temperature)) views.setImageViewResource(R.id.widget_weather_icon, AppUtils.getWeatherIconResource(current.weatherCode)) } catch (e: Exception) { - android.util.Log.e("DayAheadWidget", "Error parsing weather JSON", e) + android.util.Log.e("DayAheadWidget", "Error processing weather data", e) } } diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 55ff409..bc8d686 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -25,8 +25,10 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import javax.inject.Inject +import kotlin.coroutines.resume data class MainUiState( val weather: WeatherResponse? = null, @@ -174,11 +176,16 @@ class MainViewModel @Inject constructor( try { val geocoder = Geocoder(context, java.util.Locale.getDefault()) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - geocoder.getFromLocation(lat, lon, 1) { addresses -> - if (addresses.isNotEmpty()) { - val address = addresses[0] - val name = address.locality ?: address.subAdminArea ?: "Unknown Location" - _locationName.value = name + suspendCancellableCoroutine { cont -> + geocoder.getFromLocation(lat, lon, 1) { addresses -> + if (addresses.isNotEmpty()) { + val address = addresses[0] + val name = address.locality ?: address.subAdminArea ?: "Unknown Location" + _locationName.value = name + } + if (cont.isActive) { + cont.resume(Unit) + } } } } else { From 20ff9870f0adc655516661a821d1c6bfe7daec74 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:28:53 +0000 Subject: [PATCH 17/19] Refactor MainViewModel to use Repository pattern and fix race condition - Created `UserPreferencesRepository` to encapsulate SharedPreferences access and expose flows for user preferences. - Updated `MainViewModel` to inject `UserPreferencesRepository` instead of direct SharedPreferences usage. - Fixed race condition in `MainViewModel` Geocoder logic by implementing `GeocodeListener`. - Updated `HomeScreen` to launch `SettingsActivity` on weather card click. --- .../java/com/example/theloop/MainViewModel.kt | 44 ++++++------ .../repository/UserPreferencesRepository.kt | 68 +++++++++++++++++++ .../java/com/example/theloop/ui/HomeScreen.kt | 5 +- 3 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index bc8d686..3de5952 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -7,11 +7,11 @@ import androidx.lifecycle.viewModelScope import com.example.theloop.data.repository.CalendarRepository import com.example.theloop.data.repository.FunFactRepository import com.example.theloop.data.repository.NewsRepository +import com.example.theloop.data.repository.UserPreferencesRepository import com.example.theloop.data.repository.WeatherRepository import com.example.theloop.models.Article import com.example.theloop.models.CalendarEvent import com.example.theloop.models.WeatherResponse -import com.example.theloop.utils.AppConstants import com.example.theloop.utils.SummaryUtils import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -48,6 +49,7 @@ class MainViewModel @Inject constructor( private val newsRepo: NewsRepository, private val calendarRepo: CalendarRepository, private val funFactRepo: FunFactRepository, + private val userPrefsRepo: UserPreferencesRepository, @ApplicationContext private val context: Context ) : ViewModel() { @@ -56,10 +58,6 @@ class MainViewModel @Inject constructor( private val _isLoading = MutableStateFlow(false) private val _newsCategory = MutableStateFlow("US") - private val prefs = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - private val _tempUnit = MutableStateFlow(prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: "celsius") - private val _userName = MutableStateFlow(prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User") - val weather = weatherRepo.weatherData @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val news = _newsCategory.flatMapLatest { category -> @@ -78,7 +76,7 @@ class MainViewModel @Inject constructor( locationName = locationName ) }.combine( - combine(_userName, _tempUnit, _isLoading) { userName, tempUnit, isLoading -> + combine(userPrefsRepo.userName, userPrefsRepo.tempUnit, _isLoading) { userName, tempUnit, isLoading -> Triple(userName, tempUnit, isLoading) } ) { state, (userName, tempUnit, isLoading) -> @@ -102,7 +100,7 @@ class MainViewModel @Inject constructor( ) }.onEach { state -> if (state.summary != null) { - prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, state.summary).apply() + userPrefsRepo.saveSummary(state.summary) } }.stateIn( scope = viewModelScope, @@ -119,11 +117,7 @@ class MainViewModel @Inject constructor( fun refreshAll() { viewModelScope.launch { _isLoading.value = true - val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) - val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) - - val lat = try { latStr?.toDouble() ?: AppConstants.DEFAULT_LATITUDE } catch (e: Exception) { AppConstants.DEFAULT_LATITUDE } - val lon = try { lonStr?.toDouble() ?: AppConstants.DEFAULT_LONGITUDE } catch (e: Exception) { AppConstants.DEFAULT_LONGITUDE } + val (lat, lon) = userPrefsRepo.getLocationSync() val jobs = listOf( launch { fetchWeather(lat, lon) }, @@ -137,10 +131,7 @@ class MainViewModel @Inject constructor( } fun updateLocation(lat: Double, lon: Double) { - prefs.edit() - .putString(AppConstants.KEY_LATITUDE, lat.toString()) - .putString(AppConstants.KEY_LONGITUDE, lon.toString()) - .apply() + userPrefsRepo.updateLocation(lat, lon) refreshAll() } @@ -150,7 +141,7 @@ class MainViewModel @Inject constructor( suspend fun fetchWeather(lat: Double, lon: Double) { fetchLocationName(lat, lon) - weatherRepo.refresh(lat, lon, _tempUnit.value) + weatherRepo.refresh(lat, lon, userPrefsRepo.tempUnit.first()) } suspend fun fetchNews() { @@ -177,16 +168,21 @@ class MainViewModel @Inject constructor( val geocoder = Geocoder(context, java.util.Locale.getDefault()) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { suspendCancellableCoroutine { cont -> - geocoder.getFromLocation(lat, lon, 1) { addresses -> - if (addresses.isNotEmpty()) { - val address = addresses[0] - val name = address.locality ?: address.subAdminArea ?: "Unknown Location" - _locationName.value = name + val listener = object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: MutableList) { + if (addresses.isNotEmpty()) { + val address = addresses[0] + val name = address.locality ?: address.subAdminArea ?: "Unknown Location" + _locationName.value = name + } + if (cont.isActive) cont.resume(Unit) } - if (cont.isActive) { - cont.resume(Unit) + + override fun onError(errorMessage: String?) { + if (cont.isActive) cont.resume(Unit) } } + geocoder.getFromLocation(lat, lon, 1, listener) } } else { @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt b/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt new file mode 100644 index 0000000..9208fdd --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt @@ -0,0 +1,68 @@ +package com.example.theloop.data.repository + +import android.content.Context +import android.content.SharedPreferences +import com.example.theloop.utils.AppConstants +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserPreferencesRepository @Inject constructor( + @ApplicationContext private val context: Context +) { + private val prefs: SharedPreferences = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) + + private val prefsChangeFlow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + trySend(key) + } + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.onStart { emit(null) }.conflate() + + val tempUnit: Flow = prefsChangeFlow + .map { prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: "celsius" } + .distinctUntilChanged() + + val userName: Flow = prefsChangeFlow + .map { prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User" } + .distinctUntilChanged() + + val location: Flow> = prefsChangeFlow + .map { + val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) + val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) + val lat = latStr?.toDoubleOrNull() ?: AppConstants.DEFAULT_LATITUDE + val lon = lonStr?.toDoubleOrNull() ?: AppConstants.DEFAULT_LONGITUDE + lat to lon + } + .distinctUntilChanged() + + fun updateLocation(lat: Double, lon: Double) { + prefs.edit() + .putString(AppConstants.KEY_LATITUDE, lat.toString()) + .putString(AppConstants.KEY_LONGITUDE, lon.toString()) + .apply() + } + + fun saveSummary(summary: String) { + prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() + } + + // Helper to get current location synchronously if needed (though Flow is better) + fun getLocationSync(): Pair { + val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) + val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, AppConstants.DEFAULT_LONGITUDE.toString()) + val lat = latStr?.toDoubleOrNull() ?: AppConstants.DEFAULT_LATITUDE + val lon = lonStr?.toDoubleOrNull() ?: AppConstants.DEFAULT_LONGITUDE + return lat to lon + } +} diff --git a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt index 41c912b..0f75690 100644 --- a/app/src/main/java/com/example/theloop/ui/HomeScreen.kt +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.theloop.MainViewModel +import com.example.theloop.SettingsActivity import com.example.theloop.ui.components.CalendarCard import com.example.theloop.ui.components.NewsCard import com.example.theloop.ui.components.WeatherCard @@ -54,9 +55,7 @@ fun HomeScreen( locationName = state.locationName, tempUnit = state.tempUnit, onClick = { - val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } + val intent = Intent(context, SettingsActivity::class.java) context.startActivity(intent) } ) From 8311584363529516962ca07814ae920182c7d0f7 Mon Sep 17 00:00:00 2001 From: Harry Barnes <145344818+harrydbarnes@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:00:07 +0000 Subject: [PATCH 18/19] Update app/src/main/java/com/example/theloop/MainViewModel.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/theloop/MainViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/theloop/MainViewModel.kt b/app/src/main/java/com/example/theloop/MainViewModel.kt index 3de5952..5416a63 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -117,7 +117,7 @@ class MainViewModel @Inject constructor( fun refreshAll() { viewModelScope.launch { _isLoading.value = true - val (lat, lon) = userPrefsRepo.getLocationSync() + val (lat, lon) = userPrefsRepo.location.first() val jobs = listOf( launch { fetchWeather(lat, lon) }, From 087a5074040c72b03878f316820a74d0b140710a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:18:43 +0000 Subject: [PATCH 19/19] Refactor widget and worker to use UserPreferencesRepository Refactored DayAheadWidget and WidgetUpdateWorker to use UserPreferencesRepository instead of accessing SharedPreferences directly. This improves architectural consistency and centralizes data access logic. Changes: - Updated UserPreferencesRepository to expose summary flow and hasLocation method. - Updated DayAheadWidget to inject UserPreferencesRepository and collect summary flow. - Updated WidgetUpdateWorker to inject UserPreferencesRepository and use it for location check, preference retrieval, and summary saving. --- .../com/example/theloop/DayAheadWidget.kt | 7 +++++-- .../com/example/theloop/WidgetUpdateWorker.kt | 19 ++++++++----------- .../repository/UserPreferencesRepository.kt | 9 +++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/example/theloop/DayAheadWidget.kt b/app/src/main/java/com/example/theloop/DayAheadWidget.kt index 2ae9e88..b61ca13 100644 --- a/app/src/main/java/com/example/theloop/DayAheadWidget.kt +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.widget.RemoteViews +import com.example.theloop.data.repository.UserPreferencesRepository import com.example.theloop.data.repository.WeatherRepository import com.example.theloop.models.WeatherResponse import com.example.theloop.utils.AppConstants @@ -21,14 +22,16 @@ class DayAheadWidget : AppWidgetProvider() { @Inject lateinit var weatherRepository: WeatherRepository + @Inject + lateinit var userPreferencesRepository: UserPreferencesRepository + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { val pendingResult = goAsync() CoroutineScope(Dispatchers.IO).launch { try { val weather = weatherRepository.weatherData.first() - val summary = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .getString(AppConstants.KEY_SUMMARY_CACHE, context.getString(R.string.widget_default_summary)) + val summary = userPreferencesRepository.summary.first() for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId, weather, summary) diff --git a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt index 0fec82f..551e534 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.example.theloop.data.repository.CalendarRepository import com.example.theloop.data.repository.NewsRepository +import com.example.theloop.data.repository.UserPreferencesRepository import com.example.theloop.data.repository.WeatherRepository import com.example.theloop.utils.AppConstants import com.example.theloop.utils.SummaryUtils @@ -24,23 +25,19 @@ class WidgetUpdateWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters, private val weatherRepo: WeatherRepository, private val newsRepo: NewsRepository, - private val calendarRepo: CalendarRepository + private val calendarRepo: CalendarRepository, + private val userPreferencesRepository: UserPreferencesRepository ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - val prefs = applicationContext.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - val latStr = prefs.getString(AppConstants.KEY_LATITUDE, null) - val lonStr = prefs.getString(AppConstants.KEY_LONGITUDE, null) - val unit = prefs.getString(AppConstants.KEY_TEMP_UNIT, AppConstants.DEFAULT_TEMP_UNIT) ?: AppConstants.DEFAULT_TEMP_UNIT - val userName = prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User" - - if (latStr == null || lonStr == null) { + if (!userPreferencesRepository.hasLocation()) { return Result.success() } return try { - val lat = latStr.toDouble() - val lon = lonStr.toDouble() + val (lat, lon) = userPreferencesRepository.location.first() + val unit = userPreferencesRepository.tempUnit.first() + val userName = userPreferencesRepository.userName.first() // Update Data (saves to DB) val weatherSuccess = weatherRepo.refresh(lat, lon, unit) @@ -66,7 +63,7 @@ class WidgetUpdateWorker @AssistedInject constructor( } else null if (summary != null) { - prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() + userPreferencesRepository.saveSummary(summary) } // Trigger widget update diff --git a/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt b/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt index 9208fdd..8895ab6 100644 --- a/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt +++ b/app/src/main/java/com/example/theloop/data/repository/UserPreferencesRepository.kt @@ -2,6 +2,7 @@ package com.example.theloop.data.repository import android.content.Context import android.content.SharedPreferences +import com.example.theloop.R import com.example.theloop.utils.AppConstants import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose @@ -36,6 +37,10 @@ class UserPreferencesRepository @Inject constructor( .map { prefs.getString(AppConstants.KEY_USER_NAME, "User") ?: "User" } .distinctUntilChanged() + val summary: Flow = prefsChangeFlow + .map { prefs.getString(AppConstants.KEY_SUMMARY_CACHE, context.getString(R.string.widget_default_summary)) ?: context.getString(R.string.widget_default_summary) } + .distinctUntilChanged() + val location: Flow> = prefsChangeFlow .map { val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString()) @@ -57,6 +62,10 @@ class UserPreferencesRepository @Inject constructor( prefs.edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() } + fun hasLocation(): Boolean { + return prefs.contains(AppConstants.KEY_LATITUDE) && prefs.contains(AppConstants.KEY_LONGITUDE) + } + // Helper to get current location synchronously if needed (though Flow is better) fun getLocationSync(): Pair { val latStr = prefs.getString(AppConstants.KEY_LATITUDE, AppConstants.DEFAULT_LATITUDE.toString())