diff --git a/app/build.gradle b/app/build.gradle index 616d61f..77eb1be 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,40 @@ 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 + 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" + 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..2ae9e88 --- /dev/null +++ b/app/src/main/java/com/example/theloop/DayAheadWidget.kt @@ -0,0 +1,66 @@ +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.repository.WeatherRepository +import com.example.theloop.models.WeatherResponse +import com.example.theloop.utils.AppConstants +import com.example.theloop.utils.AppUtils +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 + +@AndroidEntryPoint +class DayAheadWidget : AppWidgetProvider() { + + @Inject + lateinit var weatherRepository: WeatherRepository + + 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)) + + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId, weather, summary) + } + } catch (e: Exception) { + android.util.Log.e("DayAheadWidget", "Error in onUpdate", e) + } finally { + pendingResult.finish() + } + } + } + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + weather: WeatherResponse?, + summary: String? + ) { + val views = RemoteViews(context.packageName, R.layout.widget_day_ahead) + views.setTextViewText(R.id.widget_summary, summary) + + if (weather != null) { + try { + 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 processing weather data", e) + } + } + + 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..3d30a9f --- /dev/null +++ b/app/src/main/java/com/example/theloop/MainActivity.kt @@ -0,0 +1,85 @@ +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 +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) { + fetchLocation() + } + + if (anyDenied) { + Toast.makeText( + this, + "Permissions denied. Some features (Weather, Calendar) may be unavailable.", + Toast.LENGTH_LONG + ).show() + } + } + + 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) + + // 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..5416a63 100644 --- a/app/src/main/java/com/example/theloop/MainViewModel.kt +++ b/app/src/main/java/com/example/theloop/MainViewModel.kt @@ -1,309 +1,202 @@ 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.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.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.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.joinAll 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) - } - } - } +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.resume + +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, + private val userPrefsRepo: UserPreferencesRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _locationName = MutableStateFlow("Loading...") + private val _funFact = MutableStateFlow(null) + private val _isLoading = MutableStateFlow(false) + private val _newsCategory = MutableStateFlow("US") + + val weather = weatherRepo.weatherData + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + val news = _newsCategory.flatMapLatest { category -> + newsRepo.getArticles(category) } - - 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) + val events = calendarRepo.events + + val uiState: StateFlow = combine( + weather, news, events, _funFact, _locationName + ) { weatherData, newsData, calendarData, funFactData, locationName -> + MainUiState( + weather = weatherData, + newsArticles = newsData, + calendarEvents = calendarData, + funFact = funFactData, + locationName = locationName + ) + }.combine( + combine(userPrefsRepo.userName, userPrefsRepo.tempUnit, _isLoading) { userName, tempUnit, isLoading -> + Triple(userName, tempUnit, isLoading) } - } - - 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) - } + ) { state, (userName, tempUnit, isLoading) -> + val summaryText = if (state.weather != null) { + SummaryUtils.generateSummary( + context, + state.weather, + state.calendarEvents, + state.calendarEvents.size, + state.newsArticles.firstOrNull(), + userName, + false + ) + } else null + + state.copy( + summary = summaryText, + userGreeting = "${SummaryUtils.getTimeBasedGreeting()}, $userName", + tempUnit = tempUnit, + isLoading = isLoading + ) + }.onEach { state -> + if (state.summary != null) { + userPrefsRepo.saveSummary(state.summary) } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MainUiState() + ) - _summary.addSource(_latestWeather) { updateSummary() } - _summary.addSource(_calendarEvents) { updateSummary() } - _summary.addSource(_cachedNewsResponse) { updateSummary() } - _summary.addSource(_totalEventCount) { updateSummary() } - } - - 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] - + init { 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() - } + refreshAll() } } - 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 fetchNewsData() { + fun refreshAll() { 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() - } + _isLoading.value = true + val (lat, lon) = userPrefsRepo.location.first() + + val jobs = listOf( + launch { fetchWeather(lat, lon) }, + launch { fetchNews() }, + launch { fetchCalendar() }, + launch { fetchFunFact() } + ) + jobs.joinAll() + _isLoading.value = false } } - 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 updateLocation(lat: Double, lon: Double) { + userPrefsRepo.updateLocation(lat, lon) + refreshAll() } - 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() - } - } + fun setNewsCategory(category: String) { + _newsCategory.value = category } - 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)) - } + suspend fun fetchWeather(lat: Double, lon: Double) { + fetchLocationName(lat, lon) + weatherRepo.refresh(lat, lon, userPrefsRepo.tempUnit.first()) } - 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()) - } - } + suspend fun fetchNews() { + newsRepo.refreshNews() } - fun saveSummaryToCache(summary: String) { - getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .edit().putString(AppConstants.KEY_SUMMARY_CACHE, summary).apply() + suspend fun fetchCalendar() { + calendarRepo.refreshEvents() } - private fun saveToCache(key: String, data: Any?) { - getApplication().getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE) - .edit().putString(key, gson.toJson(data)).apply() + suspend fun fetchFunFact() { + val fact = funFactRepo.getFunFact() + if (fact != null) { + _funFact.value = fact + } else { + _funFact.value = context.getString(R.string.fun_fact_fallback) + } } - override fun onCleared() { - super.onCleared() - // Coroutines are cancelled by viewModelScope + private suspend fun fetchLocationName(lat: Double, lon: Double) { + if (Geocoder.isPresent()) { + withContext(Dispatchers.IO) { + try { + val geocoder = Geocoder(context, java.util.Locale.getDefault()) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + suspendCancellableCoroutine { cont -> + 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) + } + + override fun onError(errorMessage: String?) { + if (cont.isActive) cont.resume(Unit) + } + } + geocoder.getFromLocation(lat, lon, 1, listener) + } + } 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/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..0fec82f 100644 --- a/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt +++ b/app/src/main/java/com/example/theloop/WidgetUpdateWorker.kt @@ -1,126 +1,90 @@ 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) + 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() + 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() + if (!weatherSuccess || !newsSuccess || !calendarSuccess) { + Result.retry() } else { - Log.w(TAG, "Widget weather fetch failed with code: " + response.code()) - if (response.code() >= 500) { - return Result.retry() - } - return Result.failure() + Result.success() } - } catch (e: IOException) { - Log.e(TAG, "Widget update failed", e) - return Result.retry() } catch (e: Exception) { - Log.e(TAG, "Widget update failed with unexpected exception", e) - return Result.failure() + android.util.Log.e("WidgetUpdateWorker", "Widget update failed", e) + 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..76a0b21 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/local/dao/ArticleDao.kt @@ -0,0 +1,30 @@ +package com.example.theloop.data.local.dao + +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 + +@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() + + @Transaction + suspend fun replaceAll(articles: List) { + clearAll() + insertArticles(articles) + } +} 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..97d10dc --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/CalendarRepository.kt @@ -0,0 +1,101 @@ +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 +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(): 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 true to prevent WidgetUpdateWorker from retrying indefinitely + return@withContext true + } + + 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 < MAX_EVENTS_TO_FETCH) { + 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) + } + return@withContext true + } catch (e: Exception) { + Log.e(TAG, "Error refreshing events", e) + return@withContext false + } + } + } + + 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/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..eb3fc05 --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/NewsRepository.kt @@ -0,0 +1,82 @@ +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 +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(): Boolean { + return withContext(Dispatchers.IO) { + try { + val response = api.getNewsFeed() + if (response.isSuccessful) { + val news = response.body() + if (news != null) { + val allArticles = mutableListOf() + // Map each category + 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) + } + return@withContext true + } + } + return@withContext false + } catch (e: Exception) { + Log.e(TAG, "Error refreshing news", e) + return@withContext false + } + } + } + + companion object { + private const val TAG = "NewsRepository" + } + + 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/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/data/repository/WeatherRepository.kt b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..5ecf13c --- /dev/null +++ b/app/src/main/java/com/example/theloop/data/repository/WeatherRepository.kt @@ -0,0 +1,63 @@ +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 +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): Boolean { + return 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())) + return@withContext true + } + } + return@withContext false + } catch (e: Exception) { + Log.e(TAG, "Error refreshing weather", e) + return@withContext false + } + } + } + + companion object { + private const val TAG = "WeatherRepository" + } +} 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..0f75690 --- /dev/null +++ b/app/src/main/java/com/example/theloop/ui/HomeScreen.kt @@ -0,0 +1,103 @@ +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.SettingsActivity +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 = { + val intent = Intent(context, SettingsActivity::class.java) + context.startActivity(intent) + } + ) + } + + 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) { + android.widget.Toast.makeText(context, "Could not open link", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } + } +} 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..8000c55 --- /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.platform.LocalContext +import androidx.compose.ui.unit.dp +import android.text.format.DateUtils +import com.example.theloop.models.CalendarEvent + +@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 context = LocalContext.current + val timeString = DateUtils.formatDateTime(context, event.startTime, DateUtils.FORMAT_SHOW_TIME) + Text( + text = timeString, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.width(75.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..96c7649 --- /dev/null +++ b/app/src/main/java/com/example/theloop/utils/AppUtils.kt @@ -0,0 +1,68 @@ +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) { + "-" + } + } + } + + @JvmStatic + @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 + } + } + + @JvmStatic + @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 index 71d8e85..0163adf 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,11 +1,4 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - #263238 + #F5F5F5 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 } }