GigaProjects

← Back to GigaWeather

MainActivity.kt

package com.gigaprojects.gigaweather

import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import com.gigaprojects.gigaweather.data.LocationDatabase
import com.gigaprojects.gigaweather.data.LocationEntity
import com.gigaprojects.gigaweather.ui.theme.GigaWeatherTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.*

class MainActivity : ComponentActivity() {
    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { _ -> }

    private val requestLocationPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
            permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
        ) {
            // Permission granted, handled in UI
        }
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        hideSystemBars()
        checkNotificationPermission()

        val isCheckingInitialDestination = mutableStateOf(true)
        val db = LocationDatabase.getDatabase(this)

        lifecycleScope.launch {
            val targetLocation = withContext(Dispatchers.IO) {
                findLaunchLocation(db.locationDao().getAllLocationsSync())
            }

            if (targetLocation != null) {
                openWeatherDetail(targetLocation)
            }

            isCheckingInitialDestination.value = false
        }
        
        setContent {
            val sharedPreferences = remember { getSharedPreferences("geo_weather_prefs", Context.MODE_PRIVATE) }
            val useSystemTheme = sharedPreferences.collectAsState(key = "use_system_theme", defaultValue = true)
            val darkModeEnabled = sharedPreferences.collectAsState(key = "dark_mode_enabled", defaultValue = false)
            val dynamicColor = sharedPreferences.collectAsState(key = "dynamic_color", defaultValue = true)
            
            val darkTheme = if (useSystemTheme.value) isSystemInDarkTheme() else darkModeEnabled.value
            
            GigaWeatherTheme(darkTheme = darkTheme, dynamicColor = dynamicColor.value) {
                if (isCheckingInitialDestination.value) {
                    LaunchLoadingScreen()
                } else {
                    MainScreen(
                        onRequestLocationPermission = {
                            requestLocationPermissionLauncher.launch(
                                arrayOf(
                                    Manifest.permission.ACCESS_FINE_LOCATION,
                                    Manifest.permission.ACCESS_COARSE_LOCATION
                                )
                            )
                        },
                        onOpenDetail = { name, lat, lon ->
                            openWeatherDetail(name, lat, lon)
                        }
                    )
                }
            }
        }
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus) {
            hideSystemBars()
        }
    }

    private fun hideSystemBars() {
        val windowInsetsController =
            WindowCompat.getInsetsController(window, window.decorView)
        windowInsetsController.systemBarsBehavior =
            WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
    }

    private fun checkNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
                requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
    }

    private fun findLaunchLocation(locations: List<LocationEntity>): LocationEntity? {
        val defaultLocation = locations.find { it.isDefault }
        if (defaultLocation != null) return defaultLocation

        val selectedLocation = locations.find { it.selected }
        if (selectedLocation != null) return selectedLocation

        if (locations.size == 1) return locations.first()

        return null
    }

    private fun openWeatherDetail(location: LocationEntity) {
        openWeatherDetail(location.name, location.latitude, location.longitude)
    }

    private fun openWeatherDetail(name: String, latitude: Double, longitude: Double) {
        val intent = Intent(this, WeatherDetailActivity::class.java).apply {
            putExtra("name", name)
            putExtra("lat", latitude)
            putExtra("lon", longitude)
        }

        startActivity(intent)
    }
}

@Composable
fun LaunchLoadingScreen() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator()
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    onRequestLocationPermission: () -> Unit,
    onOpenDetail: (String, Double, Double) -> Unit
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val db = remember { LocationDatabase.getDatabase(context) }
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()

    val locations: List<LocationEntity> by db.locationDao()
        .getAllLocations()
        .observeAsState(initial = emptyList())

    var showAddDialog by remember { mutableStateOf(false) }
    var locationToDelete by remember { mutableStateOf<LocationEntity?>(null) }
    var isLocating by remember { mutableStateOf(false) }

    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            LargeTopAppBar(
                title = { Text(stringResource(R.string.app_name)) },
                actions = {
                    IconButton(
                        onClick = {
                            if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                                onRequestLocationPermission()
                            } else {
                                isLocating = true
                                val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
                                val providers = locationManager.getProviders(true)
                                var bestLocation: Location? = null
                                for (provider in providers) {
                                    val l = try {
                                        locationManager.getLastKnownLocation(provider)
                                    } catch (e: SecurityException) {
                                        null
                                    }
                                    if (l != null && (bestLocation == null || l.accuracy < bestLocation.accuracy)) {
                                        bestLocation = l
                                    }
                                }

                                if (bestLocation != null) {
                                    isLocating = false
                                    onOpenDetail(context.getString(R.string.current_location), bestLocation.latitude, bestLocation.longitude)
                                } else {
                                    // Try request single update
                                    val provider = if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
                                        LocationManager.NETWORK_PROVIDER
                                    } else if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
                                        LocationManager.GPS_PROVIDER
                                    } else {
                                        null
                                    }

                                    if (provider != null) {
                                        try {
                                            locationManager.requestSingleUpdate(provider, object : LocationListener {
                                                override fun onLocationChanged(location: Location) {
                                                    isLocating = false
                                                    onOpenDetail(context.getString(R.string.current_location), location.latitude, location.longitude)
                                                }
                                                override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
                                                override fun onProviderEnabled(provider: String) {}
                                                override fun onProviderDisabled(provider: String) {}
                                            }, null)
                                        } catch (e: SecurityException) {
                                            isLocating = false
                                        }
                                    } else {
                                        isLocating = false
                                    }
                                }
                            }
                        },
                        enabled = !isLocating
                    ) {
                        if (isLocating) {
                            CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
                        } else {
                            Icon(Icons.Default.MyLocation, contentDescription = stringResource(R.string.current_location))
                        }
                    }
                    IconButton(onClick = {
                        context.startActivity(Intent(context, SettingsActivity::class.java))
                    }) {
                        Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings_nav_desc))
                    }
                },
                scrollBehavior = scrollBehavior
            )
        },
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = { showAddDialog = true },
                icon = { Icon(Icons.Default.Add, contentDescription = null) },
                text = { Text(stringResource(R.string.SearchBTNTXT)) }
            )
        }
    ) { innerPadding ->
        if (locations.isEmpty()) {
            Box(modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
                Text(
                    text = stringResource(R.string.no_locations_msg),
                    textAlign = TextAlign.Center,
                    style = MaterialTheme.typography.bodyLarge,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        } else {
            LazyColumn(
                modifier = Modifier.fillMaxSize(),
                contentPadding = innerPadding
            ) {
                items(locations) { loc ->
                    ListItem(
                        headlineContent = { Text(loc.name) },
                        supportingContent = { Text(stringResource(R.string.coordinates_label, loc.latitude, loc.longitude)) },
                        trailingContent = {
                            Row {
                                IconButton(onClick = {
                                    scope.launch(Dispatchers.IO) {
                                        if (loc.isDefault) {
                                            db.locationDao().updateLocation(loc.copy(isDefault = false))
                                        } else {
                                            db.locationDao().clearDefaultLocation()
                                            db.locationDao().updateLocation(loc.copy(isDefault = true))
                                        }
                                    }
                                }) {
                                    Icon(
                                        if (loc.isDefault) Icons.Default.Star else Icons.Default.StarBorder,
                                        contentDescription = stringResource(if (loc.isDefault) R.string.remove_default else R.string.set_as_default),
                                        tint = if (loc.isDefault) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
                                    )
                                }
                                IconButton(onClick = { locationToDelete = loc }) {
                                    Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.DelLoc), tint = MaterialTheme.colorScheme.error)
                                }
                            }
                        },
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable {
                                scope.launch(Dispatchers.IO) {
                                    db.locationDao().deselectAllLocations()
                                    db.locationDao().updateLocation(loc.copy(selected = true))
                                }
                                onOpenDetail(loc.name, loc.latitude, loc.longitude)
                            }
                    )
                    HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant)
                }
            }
        }
    }

    if (showAddDialog) {
        AddLocationDialog(
            onDismiss = { showAddDialog = false },
            onAdd = { name, lat, lon ->
                scope.launch(Dispatchers.IO) {
                    val shouldMakeDefault = locations.isEmpty()
                    db.locationDao().deselectAllLocations()
                    db.locationDao().insertLocation(
                        LocationEntity(
                            name = name,
                            latitude = lat,
                            longitude = lon,
                            selected = true,
                            isDefault = shouldMakeDefault
                        )
                    )
                    withContext(Dispatchers.Main) { showAddDialog = false }
                }
            }
        )
    }

    locationToDelete?.let { location ->
        AlertDialog(
            onDismissRequest = { locationToDelete = null },
            title = { Text(stringResource(R.string.DelLoc)) },
            text = { Text(String.format(stringResource(R.string.DelLocConAsk), location.name)) },
            confirmButton = {
                TextButton(onClick = {
                    scope.launch(Dispatchers.IO) {
                        db.locationDao().deleteLocation(location)
                        withContext(Dispatchers.Main) { locationToDelete = null }
                    }
                }) {
                    Text(stringResource(R.string.DelTXT), color = MaterialTheme.colorScheme.error)
                }
            },
            dismissButton = {
                TextButton(onClick = { locationToDelete = null }) {
                    Text(stringResource(R.string.CancelTXT))
                }
            }
        )
    }
}

private fun httpGet(urlString: String): String {
    val url = URL(urlString)
    val c = url.openConnection() as HttpURLConnection
    c.setRequestProperty("User-Agent", "GigaWeatherApp")
    c.connectTimeout = 12000
    c.readTimeout = 12000
    BufferedReader(InputStreamReader(c.inputStream, StandardCharsets.UTF_8)).use { reader ->
        val sb = StringBuilder()
        var line: String?
        while (reader.readLine().also { line = it } != null) sb.append(line)
        return sb.toString()
    }
}

@Composable
fun AddLocationDialog(
    onDismiss: () -> Unit,
    onAdd: (String, Double, Double) -> Unit
) {
    val scope = rememberCoroutineScope()
    var query by remember { mutableStateOf("") }
    var results by remember { mutableStateOf(emptyList<Triple<String, Double, Double>>()) }
    var loading by remember { mutableStateOf(false) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(stringResource(R.string.SearchForCity)) },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                OutlinedTextField(
                    value = query,
                    onValueChange = { query = it },
                    label = { Text(stringResource(R.string.CityName)) },
                    placeholder = { Text(stringResource(R.string.search_placeholder)) },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )
                Button(
                    onClick = {
                        loading = true
                        results = emptyList()
                        scope.launch(Dispatchers.IO) {
                            try {
                                val coordinatePattern = Regex("""^(-?\\d+\\.?\\d*)\\s*,\\s*(-?\\d+\\.?\\d*)$""")
                                val matchResult = coordinatePattern.matchEntire(query.trim())
                                val url = if (matchResult != null) {
                                    val latitude = matchResult.groupValues[1].toDouble()
                                    val longitude = matchResult.groupValues[2].toDouble()
                                    "https://geocoding-api.open-meteo.com/v1/reverse?latitude=$latitude&longitude=$longitude&language=" + Locale.getDefault().language + "&format=json"
                                } else {
                                    "https://geocoding-api.open-meteo.com/v1/search?name=" + URLEncoder.encode(query, "UTF-8") + "&count=20&language=" + Locale.getDefault().language + "&format=json"
                                }
                                val json = httpGet(url)
                                val obj = JSONObject(json)
                                val list = mutableListOf<Triple<String, Double, Double>>()
                                val arr = if (obj.has("results")) obj.optJSONArray("results") ?: JSONArray()
                                          else if (obj.has("name")) JSONArray().apply { put(obj) }
                                          else JSONArray()
                                for (i in 0 until arr.length()) {
                                    val item = arr.getJSONObject(i)
                                    val name = item.optString("name", "Unknown")
                                    val lat = item.optDouble("latitude", 0.0)
                                    val lon = item.optDouble("longitude", 0.0)
                                    var displayName = name
                                    if (item.has("admin1")) displayName += ", " + item.getString("admin1")
                                    if (item.has("country")) displayName += ", " + item.getString("country")
                                    list.add(Triple(displayName, lat, lon))
                                }
                                withContext(Dispatchers.Main) { results = list; loading = false }
                            } catch (e: Exception) {
                                withContext(Dispatchers.Main) { loading = false }
                            }
                        }
                    },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(stringResource(R.string.SearchBTNTXT))
                }
                if (loading) {
                    Box(Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
                } else if (results.isNotEmpty()) {
                    LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) {
                        items(results) { (name, lat, lon) ->
                            ListItem(
                                headlineContent = { Text(name) },
                                supportingContent = { Text(stringResource(R.string.coordinates_label, lat, lon)) },
                                modifier = Modifier.clickable { onAdd(name, lat, lon) }
                            )
                        }
                    }
                }
            }
        },
        confirmButton = { },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text(stringResource(R.string.CancelTXT)) }
        }
    )
}