diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 58614b4..b246314 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,9 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.hilt) } android { @@ -56,6 +59,38 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive.navigation.suite) implementation(libs.compose.material.icons) + + // Networking + implementation(libs.retrofit) + implementation(libs.retrofit.kotlinx.serialization) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + + // DataStore + implementation(libs.androidx.datastore.preferences) + + // Location + implementation(libs.play.services.location) + + // Permissions + implementation(libs.accompanist.permissions) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + ksp(libs.kotlin.metadata.jvm) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c168b85..6a6862d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,12 @@ + + + + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Tailwinds") }, + actions = { + if (currentDestination == AppDestinations.HOME) { + IconButton(onClick = { homeViewModel.refresh() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload" + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { innerPadding -> + when (currentDestination) { + AppDestinations.HOME -> { + HomeScreen( + viewModel = homeViewModel, + modifier = Modifier.padding(innerPadding) + ) + } + AppDestinations.LOCATIONS -> { + LocationsScreen( + viewModel = locationsViewModel, + onOfficeSelected = { officeId -> + homeViewModel.onOfficeSelected(officeId) + currentDestination = AppDestinations.HOME + }, + modifier = Modifier.padding(innerPadding) + ) + } + AppDestinations.ABOUT -> { + ProfileScreen(modifier = Modifier.padding(innerPadding)) + } + } } } } - -enum class AppDestinations( - val label: String, - val icon: ImageVector, -) { - HOME("Home", Icons.Default.Home), - FAVORITES("Favorites", Icons.Default.Favorite), - PROFILE("Profile", Icons.Default.AccountBox), -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - TailwindsTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/net/trigeek/tailwinds/TailwindsApplication.kt b/app/src/main/java/net/trigeek/tailwinds/TailwindsApplication.kt new file mode 100644 index 0000000..9509ef9 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/TailwindsApplication.kt @@ -0,0 +1,7 @@ +package net.trigeek.tailwinds + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TailwindsApplication : Application() diff --git a/app/src/main/java/net/trigeek/tailwinds/data/model/AfdProduct.kt b/app/src/main/java/net/trigeek/tailwinds/data/model/AfdProduct.kt new file mode 100644 index 0000000..f61d394 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/model/AfdProduct.kt @@ -0,0 +1,32 @@ +package net.trigeek.tailwinds.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class AfdProductListResponse( + @SerialName("@context") val context: JsonElement? = null, + @SerialName("@graph") val graph: List? = null +) + +@Serializable +data class AfdProductMetadata( + @SerialName("@id") val id: String, + val wmoCollectiveId: String? = null, + val issuingOffice: String? = null, + val issuanceTime: String? = null, + val productCode: String? = null, + val productName: String? = null +) + +@Serializable +data class AfdProductResponse( + @SerialName("@id") val id: String, + val wmoCollectiveId: String? = null, + val issuingOffice: String? = null, + val issuanceTime: String? = null, + val productCode: String? = null, + val productName: String? = null, + val productText: String +) diff --git a/app/src/main/java/net/trigeek/tailwinds/data/model/Office.kt b/app/src/main/java/net/trigeek/tailwinds/data/model/Office.kt new file mode 100644 index 0000000..ddae2ae --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/model/Office.kt @@ -0,0 +1,14 @@ +package net.trigeek.tailwinds.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Office( + val id: String, + val name: String? = null +) + +@Serializable +data class AfdLocationsResponse( + val locations: Map +) diff --git a/app/src/main/java/net/trigeek/tailwinds/data/model/PointsResponse.kt b/app/src/main/java/net/trigeek/tailwinds/data/model/PointsResponse.kt new file mode 100644 index 0000000..c5749e6 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/model/PointsResponse.kt @@ -0,0 +1,22 @@ +package net.trigeek.tailwinds.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class PointsResponse( + @SerialName("@context") val context: JsonElement? = null, + val id: String? = null, + val type: String? = null, + val properties: PointProperties +) + +@Serializable +data class PointProperties( + val cwa: String, + val forecastOffice: String? = null, + val gridId: String? = null, + val gridX: Int? = null, + val gridY: Int? = null +) diff --git a/app/src/main/java/net/trigeek/tailwinds/data/model/Result.kt b/app/src/main/java/net/trigeek/tailwinds/data/model/Result.kt new file mode 100644 index 0000000..60e1b0e --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/model/Result.kt @@ -0,0 +1,7 @@ +package net.trigeek.tailwinds.data.model + +sealed interface Result { + data object Loading : Result + data class Success(val data: T) : Result + data class Error(val message: String) : Result +} diff --git a/app/src/main/java/net/trigeek/tailwinds/data/preferences/UserPreferences.kt b/app/src/main/java/net/trigeek/tailwinds/data/preferences/UserPreferences.kt new file mode 100644 index 0000000..a5bcf98 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/preferences/UserPreferences.kt @@ -0,0 +1,33 @@ +package net.trigeek.tailwinds.data.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "user_preferences") + +class UserPreferences(private val context: Context) { + + private val SELECTED_OFFICE_ID = stringPreferencesKey("selected_office_id") + + fun getSelectedOfficeId(): Flow { + return context.dataStore.data.map { preferences -> + preferences[SELECTED_OFFICE_ID] + } + } + + suspend fun setSelectedOfficeId(officeId: String?) { + context.dataStore.edit { preferences -> + if (officeId != null) { + preferences[SELECTED_OFFICE_ID] = officeId + } else { + preferences.remove(SELECTED_OFFICE_ID) + } + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/data/remote/NwsApi.kt b/app/src/main/java/net/trigeek/tailwinds/data/remote/NwsApi.kt new file mode 100644 index 0000000..4cb9d53 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/remote/NwsApi.kt @@ -0,0 +1,27 @@ +package net.trigeek.tailwinds.data.remote + +import net.trigeek.tailwinds.data.model.AfdLocationsResponse +import net.trigeek.tailwinds.data.model.AfdProductListResponse +import net.trigeek.tailwinds.data.model.AfdProductResponse +import net.trigeek.tailwinds.data.model.PointsResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Url + +interface NwsApi { + @GET("products/types/AFD/locations") + suspend fun getAfdLocations(): Response + + @GET("products/types/AFD/locations/{locationId}") + suspend fun getAfdListForLocation(@Path("locationId") locationId: String): Response + + @GET + suspend fun getAfdProduct(@Url url: String): Response + + @GET("points/{latitude},{longitude}") + suspend fun getPointInfo( + @Path("latitude") latitude: Double, + @Path("longitude") longitude: Double + ): Response +} diff --git a/app/src/main/java/net/trigeek/tailwinds/data/remote/interceptor/UserAgentInterceptor.kt b/app/src/main/java/net/trigeek/tailwinds/data/remote/interceptor/UserAgentInterceptor.kt new file mode 100644 index 0000000..8f9fe5b --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/remote/interceptor/UserAgentInterceptor.kt @@ -0,0 +1,13 @@ +package net.trigeek.tailwinds.data.remote.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class UserAgentInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("User-Agent", "TailwindsApp/0.1 (Android)") + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/data/repository/WeatherRepository.kt b/app/src/main/java/net/trigeek/tailwinds/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..d9e4aa1 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/data/repository/WeatherRepository.kt @@ -0,0 +1,85 @@ +package net.trigeek.tailwinds.data.repository + +import net.trigeek.tailwinds.data.model.Office +import net.trigeek.tailwinds.data.model.Result +import net.trigeek.tailwinds.data.remote.NwsApi +import java.io.IOException + +class WeatherRepository( + private val nwsApi: NwsApi +) { + suspend fun getAfdLocations(): Result> { + return try { + val response = nwsApi.getAfdLocations() + if (response.isSuccessful && response.body() != null) { + val locations = response.body()!!.locations.map { (id, name) -> + Office(id = id, name = name) + } + Result.Success(locations) + } else { + Result.Error("Failed to fetch locations: ${response.code()}") + } + } catch (e: IOException) { + Result.Error("Network error. Check connection.") + } catch (e: Exception) { + Result.Error("An unexpected error occurred: ${e.message}") + } + } + + suspend fun getAfdForLocation(locationId: String): Result { + return try { + // First, get the list of AFD products for this location + val listResponse = nwsApi.getAfdListForLocation(locationId) + if (!listResponse.isSuccessful || listResponse.body() == null) { + return when (listResponse.code()) { + 404 -> Result.Error("No forecast discussion available for this office") + 503 -> Result.Error("Weather service temporarily unavailable") + else -> Result.Error("Failed to fetch AFD list: ${listResponse.code()}") + } + } + + val products = listResponse.body()!!.graph + if (products.isNullOrEmpty()) { + return Result.Error("No forecast discussion available for this office") + } + + // Get the latest product's URL (the @id field) + val latestProductUrl = products.first().id + + // Fetch the actual product with the text content + val productResponse = nwsApi.getAfdProduct(latestProductUrl) + if (productResponse.isSuccessful && productResponse.body() != null) { + Result.Success(productResponse.body()!!.productText) + } else { + when (productResponse.code()) { + 404 -> Result.Error("Forecast discussion not found") + 503 -> Result.Error("Weather service temporarily unavailable") + else -> Result.Error("Failed to fetch AFD content: ${productResponse.code()}") + } + } + } catch (e: IOException) { + Result.Error("Network error. Check connection.") + } catch (e: Exception) { + Result.Error("An unexpected error occurred: ${e.message}") + } + } + + suspend fun getOfficeFromCoordinates( + latitude: Double, + longitude: Double + ): Result { + return try { + val response = nwsApi.getPointInfo(latitude, longitude) + if (response.isSuccessful && response.body() != null) { + val cwa = response.body()!!.properties.cwa + Result.Success(cwa) + } else { + Result.Error("Failed to get office from location: ${response.code()}") + } + } catch (e: IOException) { + Result.Error("Network error. Check connection.") + } catch (e: Exception) { + Result.Error("An unexpected error occurred: ${e.message}") + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/di/AppModule.kt b/app/src/main/java/net/trigeek/tailwinds/di/AppModule.kt new file mode 100644 index 0000000..662cee0 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/di/AppModule.kt @@ -0,0 +1,84 @@ +package net.trigeek.tailwinds.di + +import android.content.Context +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import net.trigeek.tailwinds.data.preferences.UserPreferences +import net.trigeek.tailwinds.data.remote.NwsApi +import net.trigeek.tailwinds.data.remote.interceptor.UserAgentInterceptor +import net.trigeek.tailwinds.data.repository.WeatherRepository +import net.trigeek.tailwinds.domain.LocationProvider +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(UserAgentInterceptor()) + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .build() + } + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api.weather.gov/") + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @Provides + @Singleton + fun provideNwsApi(retrofit: Retrofit): NwsApi { + return retrofit.create(NwsApi::class.java) + } + + @Provides + @Singleton + fun provideWeatherRepository(nwsApi: NwsApi): WeatherRepository { + return WeatherRepository(nwsApi) + } + + @Provides + @Singleton + fun provideUserPreferences( + @ApplicationContext context: Context + ): UserPreferences { + return UserPreferences(context) + } + + @Provides + @Singleton + fun provideLocationProvider( + @ApplicationContext context: Context + ): LocationProvider { + return LocationProvider(context) + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/domain/LocationProvider.kt b/app/src/main/java/net/trigeek/tailwinds/domain/LocationProvider.kt new file mode 100644 index 0000000..fc267c8 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/domain/LocationProvider.kt @@ -0,0 +1,44 @@ +package net.trigeek.tailwinds.domain + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import kotlinx.coroutines.tasks.await +import net.trigeek.tailwinds.data.model.Result + +class LocationProvider(context: Context) { + + private val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + + @SuppressLint("MissingPermission") + suspend fun getCurrentLocation(): Result { + return try { + // Try to get current location + val cancellationTokenSource = CancellationTokenSource() + var location = fusedLocationClient.getCurrentLocation( + Priority.PRIORITY_BALANCED_POWER_ACCURACY, + cancellationTokenSource.token + ).await() + + // If getCurrentLocation returns null (common in emulators), try last known location + if (location == null) { + location = fusedLocationClient.lastLocation.await() + } + + if (location != null) { + Result.Success(location) + } else { + Result.Error("Unable to get location. Try again or select office manually.") + } + } catch (e: SecurityException) { + Result.Error("Location permission required") + } catch (e: Exception) { + Result.Error("Unable to get location: ${e.message}") + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeScreen.kt b/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeScreen.kt new file mode 100644 index 0000000..e080391 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeScreen.kt @@ -0,0 +1,63 @@ +package net.trigeek.tailwinds.ui.home + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import net.trigeek.tailwinds.ui.home.components.AfdContent +import net.trigeek.tailwinds.ui.home.components.ErrorState +import net.trigeek.tailwinds.ui.home.components.LoadingState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val locationPermissionState = rememberPermissionState( + permission = Manifest.permission.ACCESS_COARSE_LOCATION + ) + + LaunchedEffect(locationPermissionState.status.isGranted) { + if (locationPermissionState.status.isGranted) { + viewModel.loadAfd() + } + } + + when (val state = uiState) { + is HomeUiState.Loading -> { + LoadingState( + message = "Loading forecast discussion...", + modifier = modifier + ) + } + is HomeUiState.Content -> { + AfdContent( + afdText = state.afdText, + officeName = state.officeName, + modifier = modifier + ) + } + is HomeUiState.Error -> { + if (state.isLocationError && !locationPermissionState.status.isGranted) { + ErrorState( + message = "Location permission required to get forecast for your area. Grant permission or select an office from the Locations tab.", + onRetry = { locationPermissionState.launchPermissionRequest() }, + modifier = modifier + ) + } else { + ErrorState( + message = state.message, + onRetry = { viewModel.refresh() }, + modifier = modifier + ) + } + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeViewModel.kt b/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..4e2f602 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/home/HomeViewModel.kt @@ -0,0 +1,113 @@ +package net.trigeek.tailwinds.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import net.trigeek.tailwinds.data.model.Result +import net.trigeek.tailwinds.data.preferences.UserPreferences +import net.trigeek.tailwinds.data.repository.WeatherRepository +import net.trigeek.tailwinds.domain.LocationProvider +import javax.inject.Inject + +sealed interface HomeUiState { + data object Loading : HomeUiState + data class Content( + val afdText: String, + val officeName: String, + val officeId: String, + val isManualSelection: Boolean + ) : HomeUiState + data class Error( + val message: String, + val isLocationError: Boolean = false + ) : HomeUiState +} + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val weatherRepository: WeatherRepository, + private val locationProvider: LocationProvider, + private val userPreferences: UserPreferences +) : ViewModel() { + + private val _uiState = MutableStateFlow(HomeUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAfd() + } + + fun loadAfd() { + viewModelScope.launch { + _uiState.value = HomeUiState.Loading + + val selectedOfficeId = userPreferences.getSelectedOfficeId().first() + + if (selectedOfficeId != null) { + fetchAfdForOffice(selectedOfficeId, isManualSelection = true) + } else { + fetchAfdFromLocation() + } + } + } + + fun onOfficeSelected(officeId: String) { + viewModelScope.launch { + userPreferences.setSelectedOfficeId(officeId) + fetchAfdForOffice(officeId, isManualSelection = true) + } + } + + fun refresh() { + loadAfd() + } + + private suspend fun fetchAfdFromLocation() { + when (val locationResult = locationProvider.getCurrentLocation()) { + is Result.Success -> { + val location = locationResult.data + when (val officeResult = weatherRepository.getOfficeFromCoordinates( + location.latitude, + location.longitude + )) { + is Result.Success -> { + fetchAfdForOffice(officeResult.data, isManualSelection = false) + } + is Result.Error -> { + _uiState.value = HomeUiState.Error(officeResult.message) + } + is Result.Loading -> { /* Should not happen */ } + } + } + is Result.Error -> { + _uiState.value = HomeUiState.Error( + message = locationResult.message, + isLocationError = true + ) + } + is Result.Loading -> { /* Should not happen */ } + } + } + + private suspend fun fetchAfdForOffice(officeId: String, isManualSelection: Boolean) { + when (val afdResult = weatherRepository.getAfdForLocation(officeId)) { + is Result.Success -> { + _uiState.value = HomeUiState.Content( + afdText = afdResult.data, + officeName = officeId, + officeId = officeId, + isManualSelection = isManualSelection + ) + } + is Result.Error -> { + _uiState.value = HomeUiState.Error(afdResult.message) + } + is Result.Loading -> { /* Should not happen */ } + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/home/components/AfdContent.kt b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/AfdContent.kt new file mode 100644 index 0000000..b9e831d --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/AfdContent.kt @@ -0,0 +1,52 @@ +package net.trigeek.tailwinds.ui.home.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +fun AfdContent( + afdText: String, + officeName: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Area Forecast Discussion", + style = MaterialTheme.typography.titleLarge + ) + Text( + text = "Office: $officeName", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + + Text( + text = afdText, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 16.dp) + ) + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/home/components/ErrorState.kt b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/ErrorState.kt new file mode 100644 index 0000000..28109db --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/ErrorState.kt @@ -0,0 +1,41 @@ +package net.trigeek.tailwinds.ui.home.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorState( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = message, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text("Retry") + } + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/home/components/LoadingState.kt b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/LoadingState.kt new file mode 100644 index 0000000..1836fbd --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/home/components/LoadingState.kt @@ -0,0 +1,27 @@ +package net.trigeek.tailwinds.ui.home.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingState(message: String = "Loading...", modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text(text = message) + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsScreen.kt b/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsScreen.kt new file mode 100644 index 0000000..f1de62d --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsScreen.kt @@ -0,0 +1,55 @@ +package net.trigeek.tailwinds.ui.locations + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.trigeek.tailwinds.ui.home.components.ErrorState +import net.trigeek.tailwinds.ui.home.components.LoadingState +import net.trigeek.tailwinds.ui.locations.components.OfficeListItem + +@Composable +fun LocationsScreen( + viewModel: LocationsViewModel, + onOfficeSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is LocationsUiState.Loading -> { + LoadingState( + message = "Loading NWS offices...", + modifier = modifier + ) + } + is LocationsUiState.Content -> { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + items(state.offices) { office -> + OfficeListItem( + office = office, + onClick = { + viewModel.selectOffice(office.id) + onOfficeSelected(office.id) + }, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + } + is LocationsUiState.Error -> { + ErrorState( + message = state.message, + onRetry = { viewModel.loadOffices() }, + modifier = modifier + ) + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsViewModel.kt b/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsViewModel.kt new file mode 100644 index 0000000..c53675d --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/locations/LocationsViewModel.kt @@ -0,0 +1,56 @@ +package net.trigeek.tailwinds.ui.locations + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.trigeek.tailwinds.data.model.Office +import net.trigeek.tailwinds.data.model.Result +import net.trigeek.tailwinds.data.preferences.UserPreferences +import net.trigeek.tailwinds.data.repository.WeatherRepository +import javax.inject.Inject + +sealed interface LocationsUiState { + data object Loading : LocationsUiState + data class Content(val offices: List) : LocationsUiState + data class Error(val message: String) : LocationsUiState +} + +@HiltViewModel +class LocationsViewModel @Inject constructor( + private val weatherRepository: WeatherRepository, + private val userPreferences: UserPreferences +) : ViewModel() { + + private val _uiState = MutableStateFlow(LocationsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadOffices() + } + + fun loadOffices() { + viewModelScope.launch { + _uiState.value = LocationsUiState.Loading + + when (val result = weatherRepository.getAfdLocations()) { + is Result.Success -> { + _uiState.value = LocationsUiState.Content(result.data) + } + is Result.Error -> { + _uiState.value = LocationsUiState.Error(result.message) + } + is Result.Loading -> { /* Should not happen */ } + } + } + } + + fun selectOffice(officeId: String) { + viewModelScope.launch { + userPreferences.setSelectedOfficeId(officeId) + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/locations/components/OfficeListItem.kt b/app/src/main/java/net/trigeek/tailwinds/ui/locations/components/OfficeListItem.kt new file mode 100644 index 0000000..a529068 --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/locations/components/OfficeListItem.kt @@ -0,0 +1,43 @@ +package net.trigeek.tailwinds.ui.locations.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import net.trigeek.tailwinds.data.model.Office + +@Composable +fun OfficeListItem( + office: Office, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = office.id, + style = MaterialTheme.typography.titleMedium + ) + if (office.name != null) { + Text( + text = office.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/navigation/AppDestinations.kt b/app/src/main/java/net/trigeek/tailwinds/ui/navigation/AppDestinations.kt new file mode 100644 index 0000000..d9a8f3a --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/navigation/AppDestinations.kt @@ -0,0 +1,16 @@ +package net.trigeek.tailwinds.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.ui.graphics.vector.ImageVector + +enum class AppDestinations( + val label: String, + val icon: ImageVector, +) { + HOME("Home", Icons.Default.Home), + LOCATIONS("Locations", Icons.Default.LocationOn), + ABOUT("About", Icons.Default.Info), +} diff --git a/app/src/main/java/net/trigeek/tailwinds/ui/profile/ProfileScreen.kt b/app/src/main/java/net/trigeek/tailwinds/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..6a4451b --- /dev/null +++ b/app/src/main/java/net/trigeek/tailwinds/ui/profile/ProfileScreen.kt @@ -0,0 +1,40 @@ +package net.trigeek.tailwinds.ui.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ProfileScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Tailwinds", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "Version 0.1", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = "Weather forecast discussions from NWS", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 16.dp) + ) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..7bd9e3d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.ksp) apply false + alias(libs.plugins.hilt) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3132cd0..c4eb24d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] agp = "8.13.2" kotlin = "2.3.0" +kotlinMetadataJvm = "2.3.0" +ksp = "2.3.4" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -8,6 +10,16 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.2" composeBom = "2025.12.01" +retrofit = "3.0.0" +retrofitKotlinxSerializationConverter = "1.0.0" +okhttp = "5.3.2" +kotlinxSerialization = "1.9.0" +kotlinxCoroutines = "1.10.2" +datastore = "1.2.0" +playServicesLocation = "21.3.0" +accompanistPermissions = "0.36.0" +lifecycleViewmodelCompose = "2.10.0" +hilt = "2.57.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -27,9 +39,42 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } compose-material-icons = { group = "androidx.compose.material", name = "material-icons-core" } +# Networking +kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlinMetadataJvm" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" } + +# DataStore for preferences +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Location services +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } + +# Permissions handling +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } + +# ViewModel for Compose +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }