diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbcf0e169..9d2db540d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -163,6 +164,7 @@ dependencies { implementation(platform(libs.compose.bom)) implementation(libs.compose.material3) implementation(libs.compose.materialIconsExtended) + implementation(libs.compose.navigation) implementation(libs.compose.runtime.livedata) debugImplementation(libs.compose.ui.tooling) implementation(libs.compose.ui.toolingPreview) @@ -189,6 +191,7 @@ dependencies { @Suppress("RedundantSuppression") implementation(libs.dnsjava) implementation(libs.guava) + implementation(libs.kotlinx.serialization) implementation(libs.mikepenz.aboutLibraries) implementation(libs.nsk90.kstatemachine) implementation(libs.okhttp.base) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 173b80fc0..487416721 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,9 +60,8 @@ - @@ -70,15 +69,18 @@ - + + + @@ -106,7 +108,7 @@ @@ -134,7 +136,7 @@ + android:parentActivityName=".ui.MainActivity" /> () - - @Inject - lateinit var licenseInfoProvider: Optional - - - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - AppTheme { - val uriHandler = LocalUriHandler.current - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = { onSupportNavigateUp() }) { - Icon( - Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.navigate_up) - ) - } - }, - title = { - Text(stringResource(R.string.navigation_drawer_about)) - }, - actions = { - IconButton(onClick = { - uriHandler.openUri(Constants.HOMEPAGE_URL - .buildUpon() - .withStatParams("AboutActivity") - .build().toString()) - }) { - Icon( - Icons.Default.Home, - contentDescription = stringResource(R.string.navigation_drawer_website) - ) - } - } - ) - } - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - val scope = rememberCoroutineScope() - val state = rememberPagerState(pageCount = { 3 }) - - TabRow(state.currentPage) { - Tab(state.currentPage == 0, onClick = { - scope.launch { state.scrollToPage(0) } - }) { - Text( - stringResource(R.string.app_name), - modifier = Modifier.padding(8.dp) - ) - } - Tab(state.currentPage == 1, onClick = { - scope.launch { state.scrollToPage(1) } - }) { - Text( - stringResource(R.string.about_translations), - modifier = Modifier.padding(8.dp) - ) - } - Tab(state.currentPage == 2, onClick = { - scope.launch { state.scrollToPage(2) } - }) { - Text( - stringResource(R.string.about_libraries), - modifier = Modifier.padding(8.dp) - ) - } - } - - HorizontalPager( - state, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalAlignment = Alignment.Top - ) { index -> - when (index) { - 0 -> AboutApp(licenseInfoProvider = licenseInfoProvider.getOrNull()) - 1 -> { - val translations = model.translations.observeAsState(emptyList()) - TranslatorsGallery(translations.value) - } - - 2 -> LibrariesContainer(Modifier.fillMaxSize(), - itemContentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - itemSpacing = 8.dp, - librariesBlock = { ctx -> - Libs.Builder() - .withJson(ctx, R.raw.aboutlibraries) - .build() - }) - } - } - } - } - } - } - } - - - @HiltViewModel - class Model @Inject constructor( - @ApplicationContext val context: Context, - private val logger: Logger - ): ViewModel() { - - data class Translation( - val language: String, - val translators: Set - ) - - val translations = MutableLiveData>() - - init { - viewModelScope.launch(Dispatchers.IO) { - loadTranslations() - } - } - - private fun loadTranslations() { - try { - context.resources.assets.open("translators.json").use { stream -> - val jsonTranslations = JSONObject(stream.readBytes().decodeToString()) - val result = LinkedList() - for (langCode in jsonTranslations.keys()) { - val jsonTranslators = jsonTranslations.getJSONArray(langCode) - val translators = Array(jsonTranslators.length()) { idx -> - jsonTranslators.getString(idx) - } - - val langTag = langCode.replace('_', '-') - val language = Locale.forLanguageTag(langTag).displayName - result += Translation(language, translators.toSet()) - } - - // sort translations by localized language name - val collator = Collator.getInstance() - result.sortWith { o1, o2 -> - collator.compare(o1.language, o2.language) - } - - translations.postValue(result) - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't load translators", e) - } - } - - } - - - interface AppLicenseInfoProvider { - @Composable - fun LicenseInfo() - } - - @Module - @InstallIn(ActivityComponent::class) - interface AppLicenseInfoProviderModule { - @BindsOptionalOf - fun appLicenseInfoProvider(): AppLicenseInfoProvider - } - -} - - -@Composable -fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null) { - Column( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState())) { - Image( - UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier - .size(128.dp) - .align(Alignment.CenterHorizontally) - ) - Text( - stringResource(R.string.app_name), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - - Text( - stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - - Text( - stringResource(R.string.about_copyright), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - - Text( - stringResource(R.string.about_license_info_no_warranty), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - - PixelBoxes( - arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black), - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - - licenseInfoProvider?.LicenseInfo() - } -} - -@Composable -@Preview -fun AboutApp_Preview() { - AboutApp(licenseInfoProvider = object : AboutActivity.AppLicenseInfoProvider { - @Composable - override fun LicenseInfo() { - Text("Some flavored License Info") - } - }) -} - - -@Composable -fun TranslatorsGallery( - translations: List -) { - val collator = Collator.getInstance() - LazyColumn(Modifier.padding(8.dp)) { - items(translations) { translation -> - Text( - translation.language, - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(vertical = 4.dp) - ) - Text( - translation.translators - .sortedWith { a, b -> collator.compare(a, b) } - .joinToString(" · "), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(bottom = 16.dp) - ) - } - } -} - -@Composable -@Preview -fun TranslatorsGallery_Sample() { - TranslatorsGallery(listOf( - AboutActivity.Model.Translation("Some Language", setOf("User 1", "User 2")), - AboutActivity.Model.Translation("Another Language", setOf("User 3", "User 4")) - )) -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt index ad6752eb2..1f2973961 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -6,53 +6,17 @@ package at.bitfire.davdroid.ui import android.content.Intent import android.os.Bundle -import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import at.bitfire.davdroid.ui.account.AccountActivity -import at.bitfire.davdroid.ui.intro.IntroActivity -import at.bitfire.davdroid.ui.setup.LoginActivity -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint +@Deprecated("Automatically redirects to MainActivity. Should be removed in the future.") class AccountsActivity: AppCompatActivity() { - - @Inject - lateinit var accountsDrawerHandler: AccountsDrawerHandler - - private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled -> - if (cancelled) - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // handle "Sync all" intent from launcher shortcut - val syncAccounts = intent.action == Intent.ACTION_SYNC - - setContent { - AccountsScreen( - initialSyncAccounts = syncAccounts, - onShowAppIntro = { - introActivityLauncher.launch(null) - }, - accountsDrawerHandler = accountsDrawerHandler, - onAddAccount = { - startActivity(Intent(this, LoginActivity::class.java)) - }, - onShowAccount = { account -> - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - }, - onManagePermissions = { - startActivity(Intent(this, PermissionsActivity::class.java)) - } - ) - } + startActivity( + Intent(this, MainActivity::class.java).apply { + action = intent.action + } + ) } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt index 4a081dcfb..5c529b0a5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composition.LocalNavController +import at.bitfire.davdroid.ui.navigation.Routes import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity import kotlinx.coroutines.launch import java.net.URI @@ -104,6 +106,7 @@ abstract class AccountsDrawerHandler { open fun ImportantEntries( snackbarHostState: SnackbarHostState ) { + val navController = LocalNavController.current val context = LocalContext.current val isBeta = LocalInspectionMode.current || @@ -116,7 +119,7 @@ abstract class AccountsDrawerHandler { icon = Icons.Default.Info, title = stringResource(R.string.navigation_drawer_about), onClick = { - context.startActivity(Intent(context, AboutActivity::class.java)) + navController.navigate(Routes.About) } ) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt index e0ec50d83..d65b4d971 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid.ui import android.Manifest import android.accounts.Account +import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build @@ -71,11 +72,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.toRoute import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.account.AccountActivity import at.bitfire.davdroid.ui.account.AccountProgress import at.bitfire.davdroid.ui.composable.ActionCard import at.bitfire.davdroid.ui.composable.ProgressBar +import at.bitfire.davdroid.ui.composition.LocalNavController +import at.bitfire.davdroid.ui.intro.INTRO_CANCELLED +import at.bitfire.davdroid.ui.navigation.Routes +import at.bitfire.davdroid.ui.setup.LoginActivity import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -84,9 +92,49 @@ import kotlinx.coroutines.launch @Composable fun AccountsScreen( + backStackEntry: NavBackStackEntry, + accountsDrawerHandler: AccountsDrawerHandler +) { + val route = backStackEntry.toRoute() + val navController = LocalNavController.current + val context = LocalContext.current + val activity = context as? Activity + + LaunchedEffect(Unit) { + navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow(INTRO_CANCELLED, false) + ?.collect { cancelled -> + if (cancelled) activity?.finish() + } + } + + AccountsScreen( + accountsDrawerHandler = accountsDrawerHandler, + initialSyncAccounts = route.syncAccounts, + onShowAppIntro = { navController.navigate(Routes.Intro) }, + onAddAccount = { + // eventually this will become a navigation + context.startActivity(Intent(context, LoginActivity::class.java)) + }, + onShowAccount = { account -> + // eventually this will become a navigation + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + context.startActivity(intent) + }, + onManagePermissions = { + // eventually this will become a navigation + context.startActivity(Intent(context, PermissionsActivity::class.java)) + } + ) +} + +@Composable +fun AccountsScreen( + accountsDrawerHandler: AccountsDrawerHandler, initialSyncAccounts: Boolean, onShowAppIntro: () -> Unit, - accountsDrawerHandler: AccountsDrawerHandler, onAddAccount: () -> Unit, onShowAccount: (Account) -> Unit, onManagePermissions: () -> Unit, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt new file mode 100644 index 000000000..e7ac32eaa --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt @@ -0,0 +1,57 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import at.bitfire.davdroid.ui.about.AboutScreen +import at.bitfire.davdroid.ui.intro.IntroScreen +import at.bitfire.davdroid.ui.navigation.LocalNavController +import at.bitfire.davdroid.ui.navigation.Routes +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class MainActivity: AppCompatActivity() { + + @Inject + lateinit var accountsDrawerHandler: AccountsDrawerHandler + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val navController = rememberNavController() + + CompositionLocalProvider(LocalNavController provides navController) { + NavHost( + navController = navController, + startDestination = accountsFromIntent() + ) { + composable { AccountsScreen(it, accountsDrawerHandler) } + composable { IntroScreen() } + composable { AboutScreen() } + } + } + } + } + + /** + * Initializes the accounts route from the current intent data. + * Checks whether the action is [Intent.ACTION_SYNC]. + */ + private fun accountsFromIntent() = Routes.Accounts( + // handle "Sync all" intent from launcher shortcut + syncAccounts = intent.action == Intent.ACTION_SYNC + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt index 6ac2d82eb..c2b341486 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt @@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.composable.CardWithImage @@ -80,7 +80,7 @@ fun PermissionsScreen( @Composable fun PermissionsScreen( modifier: Modifier = Modifier, - model: PermissionsModel = viewModel() + model: PermissionsModel = hiltViewModel() ) { // check permissions when the lifecycle owner (for instance Activity) is resumed val lifecycle = LocalLifecycleOwner.current.lifecycle diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt index da537402f..356493b1a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt @@ -40,8 +40,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString @@ -77,7 +77,7 @@ fun TasksScreen(onNavUp: () -> Unit) { @Composable fun TasksCard( - model: TasksModel = viewModel() + model: TasksModel = hiltViewModel() ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt index 4b1038a79..72d97128a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt @@ -86,7 +86,7 @@ object UiUtils { ShortcutInfo.Builder(context, SHORTCUT_SYNC_ALL) .setIcon(Icon.createWithResource(context, R.drawable.ic_sync_shortcut)) .setShortLabel(context.getString(R.string.accounts_sync_all)) - .setIntent(Intent(Intent.ACTION_SYNC, null, context, AccountsActivity::class.java)) + .setIntent(Intent(Intent.ACTION_SYNC, null, context, MainActivity::class.java)) .build() ) } catch(e: Exception) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutModel.kt new file mode 100644 index 000000000..96050f42c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutModel.kt @@ -0,0 +1,68 @@ +package at.bitfire.davdroid.ui.about + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.text.Collator +import java.util.LinkedList +import java.util.Locale +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +@HiltViewModel +class AboutModel @Inject constructor( + @ApplicationContext val context: Context, + private val logger: Logger, + val licenseInfoProvider: AppLicenseInfoProvider? +): ViewModel() { + + data class Translation( + val language: String, + val translators: Set + ) + + val translations = MutableLiveData>() + + init { + viewModelScope.launch(Dispatchers.IO) { + loadTranslations() + } + } + + private fun loadTranslations() { + try { + context.resources.assets.open("translators.json").use { stream -> + val jsonTranslations = JSONObject(stream.readBytes().decodeToString()) + val result = LinkedList() + for (langCode in jsonTranslations.keys()) { + val jsonTranslators = jsonTranslations.getJSONArray(langCode) + val translators = Array(jsonTranslators.length()) { idx -> + jsonTranslators.getString(idx) + } + + val langTag = langCode.replace('_', '-') + val language = Locale.forLanguageTag(langTag).displayName + result += Translation(language, translators.toSet()) + } + + // sort translations by localized language name + val collator = Collator.getInstance() + result.sortWith { o1, o2 -> + collator.compare(o1.language, o2.language) + } + + translations.postValue(result) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't load translators", e) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutScreen.kt new file mode 100644 index 000000000..72f1649c9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AboutScreen.kt @@ -0,0 +1,250 @@ +package at.bitfire.davdroid.ui.about + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.Constants.withStatParams +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.UiUtils +import at.bitfire.davdroid.ui.composable.PixelBoxes +import at.bitfire.davdroid.ui.composition.LocalNavController +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.util.withJson +import kotlinx.coroutines.launch +import java.text.Collator + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun AboutScreen(model: AboutModel = hiltViewModel()) { + val uriHandler = LocalUriHandler.current + val navController = LocalNavController.current + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.navigate_up) + ) + } + }, + title = { + Text(stringResource(R.string.navigation_drawer_about)) + }, + actions = { + IconButton(onClick = { + uriHandler.openUri( + Constants.HOMEPAGE_URL + .buildUpon() + .withStatParams("AboutActivity") + .build().toString()) + }) { + Icon( + Icons.Default.Home, + contentDescription = stringResource(R.string.navigation_drawer_website) + ) + } + } + ) + } + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + val scope = rememberCoroutineScope() + val state = rememberPagerState(pageCount = { 3 }) + + TabRow(state.currentPage) { + Tab(state.currentPage == 0, onClick = { + scope.launch { state.scrollToPage(0) } + }) { + Text( + stringResource(R.string.app_name), + modifier = Modifier.padding(8.dp) + ) + } + Tab(state.currentPage == 1, onClick = { + scope.launch { state.scrollToPage(1) } + }) { + Text( + stringResource(R.string.about_translations), + modifier = Modifier.padding(8.dp) + ) + } + Tab(state.currentPage == 2, onClick = { + scope.launch { state.scrollToPage(2) } + }) { + Text( + stringResource(R.string.about_libraries), + modifier = Modifier.padding(8.dp) + ) + } + } + + HorizontalPager( + state, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.Top + ) { index -> + when (index) { + 0 -> AboutApp(licenseInfoProvider = model.licenseInfoProvider) + 1 -> { + val translations = model.translations.observeAsState(emptyList()) + TranslatorsGallery(translations.value) + } + + 2 -> LibrariesContainer( + Modifier.fillMaxSize(), + itemContentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + itemSpacing = 8.dp, + librariesBlock = { ctx -> + Libs.Builder() + .withJson(ctx, R.raw.aboutlibraries) + .build() + }) + } + } + } + } +} + +@Composable +fun AboutApp(licenseInfoProvider: AppLicenseInfoProvider? = null) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState())) { + Image( + UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier + .size(128.dp) + .align(Alignment.CenterHorizontally) + ) + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + + Text( + stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(R.string.about_copyright), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.about_license_info_no_warranty), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + PixelBoxes( + arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) + + licenseInfoProvider?.LicenseInfo() + } +} + +@Composable +@Preview +fun AboutApp_Preview() { + AboutApp(licenseInfoProvider = object : AppLicenseInfoProvider { + @Composable + override fun LicenseInfo() { + Text("Some flavored License Info") + } + }) +} + + +@Composable +fun TranslatorsGallery( + translations: List +) { + val collator = Collator.getInstance() + LazyColumn(Modifier.padding(8.dp)) { + items(translations) { translation -> + Text( + translation.language, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 4.dp) + ) + Text( + translation.translators + .sortedWith { a, b -> collator.compare(a, b) } + .joinToString(" · "), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } +} + +@Composable +@Preview +fun TranslatorsGallery_Sample() { + TranslatorsGallery(listOf( + AboutModel.Translation("Some Language", setOf("User 1", "User 2")), + AboutModel.Translation("Another Language", setOf("User 3", "User 4")) + )) +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProvider.kt new file mode 100644 index 000000000..9380c5a62 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProvider.kt @@ -0,0 +1,8 @@ +package at.bitfire.davdroid.ui.about + +import androidx.compose.runtime.Composable + +interface AppLicenseInfoProvider { + @Composable + fun LicenseInfo() +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProviderModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProviderModule.kt new file mode 100644 index 000000000..4bb35c8b0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/about/AppLicenseInfoProviderModule.kt @@ -0,0 +1,13 @@ +package at.bitfire.davdroid.ui.about + +import dagger.BindsOptionalOf +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +interface AppLicenseInfoProviderModule { + @BindsOptionalOf + fun appLicenseInfoProvider(): AppLicenseInfoProvider +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt index 39f4c5efa..90db413c4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.Constants import at.bitfire.davdroid.Constants.withStatParams @@ -41,7 +41,7 @@ import java.util.Locale @Composable fun BatteryOptimizationsPageContent( - model: BatteryOptimizationsPageModel = viewModel() + model: BatteryOptimizationsPageModel = hiltViewModel() ) { val ignoreBatteryOptimizationsResultLauncher = rememberLauncherForActivityResult( BatteryOptimizationsPage.IgnoreBatteryOptimizationsContract diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt deleted file mode 100644 index 97d919f73..000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.intro - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.BackHandler -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.runtime.rememberCoroutineScope -import at.bitfire.davdroid.ui.AppTheme -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class IntroActivity : AppCompatActivity() { - - val model by viewModels() - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val pages = model.pages - - setContent { - AppTheme { - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState { pages.size } - - BackHandler { - if (pagerState.settledPage == 0) { - setResult(Activity.RESULT_CANCELED) - finish() - } else scope.launch { - pagerState.animateScrollToPage(pagerState.settledPage - 1) - } - } - - IntroScreen( - pages = pages, - pagerState = pagerState, - onDonePressed = { - setResult(Activity.RESULT_OK) - finish() - } - ) - } - } - } - - - /** - * For launching the [IntroActivity]. Result is `true` when the user cancelled the intro. - */ - object Contract: ActivityResultContract() { - override fun createIntent(context: Context, input: Unit?): Intent = - Intent(context, IntroActivity::class.java) - - override fun parseResult(resultCode: Int, intent: Intent?): Boolean { - return resultCode == Activity.RESULT_CANCELED - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt index ec159b2e7..190c78180 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui.intro +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas @@ -53,11 +54,42 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.AppTheme import at.bitfire.davdroid.ui.M3ColorScheme +import at.bitfire.davdroid.ui.composition.LocalNavController import kotlinx.coroutines.launch +const val INTRO_CANCELLED = "cancelled" + +@Composable +fun IntroScreen( + model: IntroModel = hiltViewModel() +) { + val navController = LocalNavController.current + + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState { model.pages.size } + + BackHandler { + if (pagerState.settledPage == 0) { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(INTRO_CANCELLED, true) + navController.popBackStack() + } else scope.launch { + pagerState.animateScrollToPage(pagerState.settledPage - 1) + } + } + + IntroScreen( + pages = model.pages, + pagerState = pagerState, + onDonePressed = { navController.popBackStack() } + ) +} + @Composable fun IntroScreen( pages: List, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt index 642830595..5d76b520d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt @@ -26,9 +26,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.Constants import at.bitfire.davdroid.Constants.withStatParams import at.bitfire.davdroid.R @@ -54,7 +54,7 @@ class OpenSourcePage @Inject constructor( } @Composable - private fun Page(model: Model = viewModel()) { + private fun Page(model: Model = hiltViewModel()) { val dontShow by model.dontShow.collectAsStateWithLifecycle(false) OpenSourcePage( dontShow = dontShow, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/NavController.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/NavController.kt new file mode 100644 index 000000000..71991c542 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/NavController.kt @@ -0,0 +1,6 @@ +package at.bitfire.davdroid.ui.navigation + +import androidx.compose.runtime.compositionLocalOf +import androidx.navigation.NavController + +val LocalNavController = compositionLocalOf { error("No NavController attached.") } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Routes.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Routes.kt new file mode 100644 index 000000000..b217cd463 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Routes.kt @@ -0,0 +1,14 @@ +package at.bitfire.davdroid.ui.navigation + +import kotlinx.serialization.Serializable + +object Routes { + @Serializable + data class Accounts(val syncAccounts: Boolean) + + @Serializable + data object Intro + + @Serializable + data object About +} diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt b/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt index 3efcf73f0..28e1c6a19 100644 --- a/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt +++ b/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt @@ -4,10 +4,10 @@ package at.bitfire.davdroid -import at.bitfire.davdroid.ui.AboutActivity import at.bitfire.davdroid.ui.AccountsDrawerHandler import at.bitfire.davdroid.ui.OpenSourceLicenseInfoProvider import at.bitfire.davdroid.ui.OseAccountsDrawerHandler +import at.bitfire.davdroid.ui.about.AppLicenseInfoProvider import at.bitfire.davdroid.ui.intro.IntroPageFactory import at.bitfire.davdroid.ui.setup.LoginTypesProvider import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider @@ -34,7 +34,7 @@ interface OseFlavorModules { @InstallIn(ViewModelComponent::class) interface ForViewModels { @Binds - fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider + fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AppLicenseInfoProvider @Binds fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt index a076d63c7..065fc12a2 100644 --- a/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt @@ -14,17 +14,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.about.AppLicenseInfoProvider import com.google.common.io.CharStreams import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject -class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLicenseInfoProvider { +class OpenSourceLicenseInfoProvider @Inject constructor(): AppLicenseInfoProvider { @Composable override fun LicenseInfo() { @@ -33,7 +34,7 @@ class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLice @Composable fun LicenseInfoGpl( - model: Model = viewModel() + model: Model = hiltViewModel() ) { model.gpl?.let { OpenSourceLicenseInfo(it.toAnnotatedString()) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2f9722c5..fa4f092b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ bitfire-ical4android = "12df9bfddb" bitfire-vcard4android = "ae5d609f92" compose-accompanist = "0.37.0" compose-bom = "2024.12.01" +compose-navigation = "2.8.5" dnsjava = "3.6.0" glance = "1.1.1" guava = "33.4.0-android" @@ -31,6 +32,7 @@ hilt = "2.55" # keep in sync with ksp version kotlin = "2.1.0" kotlinx-coroutines = "1.10.1" +kotlinx-serialization = "1.7.3" # see https://github.com/google/ksp/releases for version numbers ksp = "2.1.0-1.0.29" mikepenz-aboutLibraries = "11.4.0" @@ -72,6 +74,7 @@ compose-accompanist-permissions = { module = "com.google.accompanist:accompanist compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-materialIconsExtended = { module = "androidx.compose.material:material-icons-extended" } +compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -85,6 +88,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers junit = { module = "junit:junit", version = "4.13.2" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } mikepenz-aboutLibraries = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -106,5 +110,6 @@ android-application = { id = "com.android.application", version.ref = "android-a compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } mikepenz-aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "mikepenz-aboutLibraries" }