Merge branch 'frontend/welcome-screens' into 'main'

feat: welcome screens

Closes #13

See merge request EmmaVandewalle/writand!35
This commit is contained in:
Emma Vandewalle 2024-08-16 21:10:10 +00:00
commit b7cb3eb27c
36 changed files with 1176 additions and 65 deletions

View file

@ -90,8 +90,10 @@ dependencies {
testImplementation "org.mockito:mockito-android:4.1.0"
// Hilt
implementation "com.google.dagger:hilt-android:2.48"
kapt "com.google.dagger:hilt-compiler:2.48"
implementation "com.google.dagger:hilt-android:2.49"
kapt "com.google.dagger:hilt-compiler:2.49"
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
// Room
implementation 'androidx.room:room-runtime:2.6.1'
@ -99,8 +101,11 @@ dependencies {
implementation 'androidx.room:room-ktx:2.6.1'
// Proto DataStore
implementation 'androidx.datastore:datastore:1.1.1'
implementation 'com.google.protobuf:protobuf-javalite:3.21.7'
implementation 'androidx.datastore:datastore:1.1.1'
implementation("androidx.datastore:datastore-core:1.1.1")
// implementation("androidx.datastore:datastore-preferences:1.1.1")
// implementation("androidx.datastore:datastore-preferences-core:1.1.1")
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"

View file

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".WApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View file

@ -3,18 +3,18 @@ package be.re.writand
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import be.re.writand.navigation.WNavGraph
import be.re.writand.ui.theme.WritandTheme
import dagger.hilt.android.AndroidEntryPoint
class MainActivity : ComponentActivity() {
@AndroidEntryPoint
class MainActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@ -26,7 +26,7 @@ class MainActivity : ComponentActivity() {
) {
WNavGraph(
navHostController = rememberNavController(),
modifier = Modifier.padding(5.dp)
modifier = Modifier.background(color = MaterialTheme.colorScheme.primary)
)
}
}

View file

@ -0,0 +1,7 @@
package be.re.writand
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class WApplication : Application() {}

View file

@ -1,7 +1,10 @@
package be.re.writand.data.local
import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import be.re.writand.ProtoSettings
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
@ -29,3 +32,11 @@ class SettingsSerializer @Inject constructor() : Serializer<ProtoSettings> {
) = t.writeTo(output)
}
/**
* Added field to Context to enable getting an instance of ProtoSettings where needed.
*/
val Context.protoSettingsDataStore: DataStore<ProtoSettings> by dataStore(
fileName = "settings.proto",
serializer = SettingsSerializer()
)

View file

@ -5,7 +5,38 @@ package be.re.writand.data.local.models
*/
enum class UserLanguage {
ENGLISH,
DUTCH
DUTCH;
/**
* Overwritten toString function for displaying the different languages as text on the screen.
*/
override fun toString(): String {
return when (this) {
ENGLISH -> "English"
DUTCH -> "Dutch"
}
}
}
/**
* Checks whether the given string is a language supported by the enum class UserLanguage.
*/
fun String.isUserLanguage(): Boolean {
return UserLanguage.entries.map { entry -> entry.toString() }.contains(this)
}
/**
* Converts a given string to a valid UserLanguage or English when the string isn't valid.
* The choice to give English back when invalid is chosen as the checks for input strings should be
* checked in a use-case/viewmodel with the function isUserLanguage() first.
*/
fun String.toUserLanguage(): UserLanguage {
// when i18n: first translate to English then get back UserLanguage
return when (this) {
"English" -> UserLanguage.ENGLISH
"Dutch" -> UserLanguage.DUTCH
else -> UserLanguage.ENGLISH
}
}
/**
@ -13,7 +44,38 @@ enum class UserLanguage {
*/
enum class UserTheme {
DARK,
LIGHT
LIGHT;
/**
* Overwritten toString function for displaying the different themes as text on the screen.
*/
override fun toString(): String {
return when (this) {
DARK -> "Dark"
LIGHT -> "Light"
}
}
}
/**
* Checks whether the given string is a theme supported by the enum class UserTheme.
*/
fun String.isUserTheme(): Boolean {
return UserTheme.entries.map { entry -> entry.toString() }.contains(this)
}
/**
* Converts a given string to a valid UserTheme or the Dark when the string isn't valid.
* The choice to give Dark back when invalid is chosen as the checks for input strings should be
* checked in a use-case/viewmodel with the function isUserTheme() first.
*/
fun String.toUserTheme(): UserTheme {
// when i18n: first translate to English then get back UserLanguage
return when (this) {
"Dark" -> UserTheme.DARK
"Light" -> UserTheme.LIGHT
else -> UserTheme.DARK // should not occur: checks should happen in view models
}
}
/**
@ -31,5 +93,5 @@ data class UserSettings(
var userTheme: UserTheme = UserTheme.DARK,
var maxSavedProjects: Int = 3,
var maxSavedFiles: Int = 5,
var fontSize: Int = 10
var fontSize: Float = 14.0f
)

View file

@ -1,15 +1,18 @@
package be.re.writand.data.repos.settings
import be.re.writand.ProtoSettings
import be.re.writand.data.local.models.UserLanguage
import be.re.writand.data.local.models.UserSettings
import be.re.writand.data.local.models.UserTheme
/**
* Repository containing getters and setters to interact with all of the user settings
* Repository containing initializer, and setters to interact with all of the user settings.
*/
interface IUserSettingsRepository {
suspend fun getUserSettings(): UserSettings
suspend fun initializeSettingsIfNull()
suspend fun toUserSettings(protoSettings: ProtoSettings): UserSettings
suspend fun setLanguage(userLanguage: UserLanguage)
@ -19,5 +22,5 @@ interface IUserSettingsRepository {
suspend fun setMaxSavedFiles(maxSaved: Int)
suspend fun setFontSize(size: Int)
suspend fun setFontSize(size: Float)
}

View file

@ -1,37 +1,70 @@
package be.re.writand.data.repos.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.IOException
import be.re.writand.ProtoLanguage
import be.re.writand.ProtoSettings
import be.re.writand.ProtoTheme
import be.re.writand.data.local.models.UserLanguage
import be.re.writand.data.local.models.UserSettings
import be.re.writand.data.local.models.UserTheme
import be.re.writand.data.local.protoSettingsDataStore
import be.re.writand.utils.toProtoLanguage
import be.re.writand.utils.toProtoTheme
import be.re.writand.utils.toUserLanguage
import be.re.writand.utils.toUserTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserSettingsRepository @Inject constructor(
private val store: DataStore<ProtoSettings>
context: Context
) : IUserSettingsRepository {
private val userSettings = store.data
private val dataStore: DataStore<ProtoSettings> = context.protoSettingsDataStore
val userSettings: Flow<UserSettings> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(ProtoSettings.getDefaultInstance())
} else {
throw exception
}
}
.map { protoSettings ->
toUserSettings(protoSettings)
}
override suspend fun getUserSettings(): UserSettings {
override suspend fun initializeSettingsIfNull() {
val currentSettings = dataStore.data.first();
if (currentSettings == ProtoSettings.getDefaultInstance()) {
val defaultSettings = ProtoSettings.newBuilder()
.setLanguage(ProtoLanguage.PROTO_LANGUAGE_ENGLISH)
.setTheme(ProtoTheme.PROTO_THEME_DARK)
.setMaxSavedProjects(3)
.setMaxSavedFiles(5)
.setFontSize(14.0f)
.build()
dataStore.updateData { defaultSettings }
}
}
override suspend fun toUserSettings(protoSettings: ProtoSettings): UserSettings {
return UserSettings(
userLanguage = userSettings.first().language.toUserLanguage(),
userTheme = userSettings.first().theme.toUserTheme(),
maxSavedProjects = userSettings.first().maxSavedProjects,
maxSavedFiles = userSettings.first().maxSavedFiles,
fontSize = userSettings.first().fontSize
userLanguage = protoSettings.language.toUserLanguage(),
userTheme = protoSettings.theme.toUserTheme(),
maxSavedProjects = protoSettings.maxSavedProjects,
maxSavedFiles = protoSettings.maxSavedFiles,
fontSize = protoSettings.fontSize
)
}
override suspend fun setLanguage(userLanguage: UserLanguage) {
store.updateData {
dataStore.updateData {
val builder = it.toBuilder()
builder.setLanguage(userLanguage.toProtoLanguage())
builder.build()
@ -39,7 +72,7 @@ class UserSettingsRepository @Inject constructor(
}
override suspend fun setTheme(userTheme: UserTheme) {
store.updateData {
dataStore.updateData {
val builder = it.toBuilder()
builder.setTheme(userTheme.toProtoTheme())
builder.build()
@ -47,7 +80,7 @@ class UserSettingsRepository @Inject constructor(
}
override suspend fun setMaxSavedProjects(maxSaved: Int) {
store.updateData {
dataStore.updateData {
val builder = it.toBuilder()
builder.setMaxSavedProjects(maxSaved)
builder.build()
@ -55,15 +88,15 @@ class UserSettingsRepository @Inject constructor(
}
override suspend fun setMaxSavedFiles(maxSaved: Int) {
store.updateData {
dataStore.updateData {
val builder = it.toBuilder()
builder.setMaxSavedFiles(maxSaved)
builder.build()
}
}
override suspend fun setFontSize(size: Int) {
store.updateData {
override suspend fun setFontSize(size: Float) {
dataStore.updateData {
val builder = it.toBuilder()
builder.setFontSize(size)
builder.build()

View file

@ -1,12 +1,9 @@
package be.re.writand.data.repos.tos
import android.content.Context
import android.util.Log
import be.re.writand.data.local.models.TermsOfService
import be.re.writand.utils.Version
import java.io.File
import java.io.InputStream
import java.util.concurrent.Flow
import javax.inject.Inject
class TOSRepository @Inject constructor(

View file

@ -1,9 +1,10 @@
package be.re.writand.di
import android.content.Context
import android.content.res.AssetManager
import be.re.writand.data.local.db.ProjectsDao
import be.re.writand.data.local.db.ProjectsDatabase
import be.re.writand.data.repos.settings.IUserSettingsRepository
import be.re.writand.data.repos.settings.UserSettingsRepository
import be.re.writand.data.repos.tos.ITOSRepository
import be.re.writand.data.repos.tos.TOSRepository
import dagger.Module
@ -38,7 +39,12 @@ class DatabaseModule {
}
@Provides
fun provideTOSRepository(repo: TOSRepository): ITOSRepository {
return repo
fun provideUserSettingsRepository(@ApplicationContext context: Context): IUserSettingsRepository {
return UserSettingsRepository(context)
}
@Provides
fun provideTOSRepository(@ApplicationContext context: Context): ITOSRepository {
return TOSRepository(context)
}
}

View file

@ -0,0 +1,27 @@
package be.re.writand.domain.settings
import be.re.writand.data.repos.settings.UserSettingsRepository
import javax.inject.Inject
/**
* Use-case that takes in a string which will alter the current UserSettings if the string is a
* valid Float and above 0, otherwise nothing will be altered.
* @param[userSettingsRepository] repository to change the UserSettings when valid input is given.
*/
class SetFontSizeSettingsUseCase @Inject constructor(
private val userSettingsRepository: UserSettingsRepository
) {
suspend operator fun invoke(fontSize: String): NumberFormatException? {
try {
val newFontSize: Float = fontSize.toFloat()
if (newFontSize > 0) {
userSettingsRepository.setFontSize(newFontSize)
}
return null
} catch (e: NumberFormatException) {
return e
}
}
}

View file

@ -0,0 +1,23 @@
package be.re.writand.domain.settings
import be.re.writand.data.local.models.isUserLanguage
import be.re.writand.data.local.models.toUserLanguage
import be.re.writand.data.repos.settings.UserSettingsRepository
import javax.inject.Inject
/**
* Use-case that takes in a string which will alter the current UserSettings if the string is part
* of the defined UserLanguage fields, otherwise nothing will be altered.
* @param[userSettingsRepository] repository to change the UserSettings when valid input is given.
*/
class SetLanguageSettingsUseCase @Inject constructor(
private val userSettingsRepository: UserSettingsRepository
) {
suspend operator fun invoke(language: String) {
if (language.isUserLanguage()) {
userSettingsRepository.setLanguage(language.toUserLanguage())
}
}
}

View file

@ -0,0 +1,23 @@
package be.re.writand.domain.settings
import be.re.writand.data.local.models.isUserTheme
import be.re.writand.data.local.models.toUserTheme
import be.re.writand.data.repos.settings.UserSettingsRepository
import javax.inject.Inject
/**
* Use-case that takes in a string which will alter the current UserSettings if the string is part
* of the defined UserTheme fields, otherwise nothing will be altered.
* @param[userSettingsRepository] repository to change the UserSettings when valid input is given.
*/
class SetThemeSettingsUseCase @Inject constructor(
private val userSettingsRepository: UserSettingsRepository
) {
suspend operator fun invoke(theme: String) {
if (theme.isUserTheme()) {
userSettingsRepository.setTheme(theme.toUserTheme())
}
}
}

View file

@ -0,0 +1,17 @@
package be.re.writand.domain.tos
import be.re.writand.data.repos.tos.ITOSRepository
import javax.inject.Inject
/**
* Use-case for getting back the TOS file in a list.
*/
class GetTOSUseCase @Inject constructor(
private val tosRepository: ITOSRepository
) {
operator fun invoke(): List<String> {
return tosRepository.getTOS().text
}
}

View file

@ -0,0 +1,31 @@
package be.re.writand.screens
import be.re.writand.data.local.models.UserSettings
import be.re.writand.data.repos.settings.UserSettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* View model to be used by components needing the UserSettings for font size, theme etc.
* This view model should be a general model offering the UserSettings, and nothing more.
* @param[userSettingsRepository] providing the UserSettings.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userSettingsRepository: UserSettingsRepository
): WViewModel() {
private val _settings = MutableStateFlow<UserSettings?>(null)
val settings: StateFlow<UserSettings?> = _settings
init {
launchCatching {
userSettingsRepository.userSettings.collect { settings ->
_settings.value = settings
}
}
}
}

View file

@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -14,21 +13,24 @@ import be.re.writand.ui.theme.MainGreen
/**
* A standard button with predefined color and shape.
* @param[text] the text that should be shown in the button.
* @param[modifier] compose.ui modifier to change the button, not the text.
* @param[onClick] the callback function to execute when the button is clicked.
* @param[enabled] boolean to express if the button is enabled to be clicked or not.
*/
@Composable
fun WButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
enabled: Boolean = true
) {
Button(
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.buttonColors(containerColor = MainGreen),
shape = RoundedCornerShape(10.dp),
enabled = enabled
) {
Text(text = text, color = Color.Black, modifier = Modifier.padding(5.dp))
WText(text = text, color = Color.Black, modifier = Modifier.padding(5.dp))
}
}

View file

@ -0,0 +1,89 @@
package be.re.writand.screens.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Text field composable to use the themed colors and font size from settings.
* @param[title] label in text field itself instead of in front of text field.
* @param[value] initial value of the content of the text field.
* @param[onTextChange] lambda that is executed when text field is being edited.
* @param[keyboardOptions] options to have different keyboard layouts (numeric only etc).
* @param[leadingIcon] icon in the text field, in front of the 'value' parameter (e.g. search icon).
*/
@Composable
fun WTextField(
title: String,
value: String,
onTextChange: (String) -> Unit,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
leadingIcon: @Composable (() -> Unit)? = null
) {
TextField(
singleLine = true,
label = { WText(text = title, color = Color.Black) },
value = value,
onValueChange = { onTextChange(it) },
colors = TextFieldDefaults.colors(
focusedIndicatorColor = MaterialTheme.colorScheme.tertiary
),
keyboardOptions = keyboardOptions,
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxWidth(),
leadingIcon = leadingIcon
)
}
/**
* Text field composable with label in front to use the themed colors and font size from settings.
* This is the same as [WTextField], but the label in the text field is gone, and the label is
* placed in front of the text field.
* @param[title] label in text field itself instead of in front of text field.
* @param[labelSize] the width of the label [title].
* @param[value] initial value of the content of the text field.
* @param[fullSize] the full width of the label plus text field.
* @param[onTextChange] lambda that is executed when text field is being edited.
* @param[keyboardOptions] options to have different keyboard layouts (numeric only etc).
* @param[leadingIcon] icon in the text field, in front of the 'value' parameter (e.g. search icon).
*/
@Composable
fun WLabelAndTextField(
title: String,
labelSize: Dp = 75.dp,
value: String,
fullSize: Dp = 500.dp,
onTextChange: (String) -> Unit,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
leadingIcon: @Composable (() -> Unit)? = null
) {
Row(
modifier = Modifier.width(fullSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
WText(text = title, modifier = Modifier.width(labelSize))
Spacer(modifier = Modifier.width(20.dp))
WTextField(
title = "",
value = value,
onTextChange = onTextChange,
keyboardOptions = keyboardOptions,
leadingIcon = leadingIcon
)
}
}

View file

@ -0,0 +1,30 @@
package be.re.writand.screens.components
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* Checkbox composable using the theme present in the users settings.
* @param[text] text present next to the checkbox.
* @param[modifier] compose.ui modifier to change the checkbox with its text.
* @param[checked] boolean that tells whether the checkbox is checked or not.
* @param[updateChecked] lambda that is called when a checkbox is clicked.
*/
@Composable
fun WCheckbox(
text: String,
modifier: Modifier = Modifier,
checked: Boolean,
updateChecked: (String) -> Unit
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = checked, onCheckedChange = { updateChecked(text) })
WText(text = text)
}
}

View file

@ -0,0 +1,30 @@
package be.re.writand.screens.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
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
/**
* Loading indicator composable having a progress indicator and a quote.
*/
@Composable
fun WLoadingIndicator() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 30.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 10.dp))
WText(text = "while( !( succeed = try() ) );", textAlign = TextAlign.Center)
}
}

View file

@ -0,0 +1,28 @@
package be.re.writand.screens.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import be.re.writand.R
/**
* Logo composable having the background in the logo.
* @param[modifier] compose.ui modifier to change the Image itself, the .fillMaxSize() is always
* appended to the end of the modifier.
* @param[alignment] alignment parameter used to place the painterResource in the given bounds
* defined by the width and height.
*/
@Composable
fun WLogoImage(modifier: Modifier, alignment: Alignment) {
Image(
painterResource(id = R.drawable.writand_with_background),
contentDescription = "logo",
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxSize(),
alignment = alignment
)
}

View file

@ -0,0 +1,124 @@
package be.re.writand.screens.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Given a list of options, it produces a composable of elements having a radio button with text
* on the left, elements are placed all together on a ROW.
* @param[enable] a boolean to disable all the buttons when value is set to false.
* @param[textTitle] text to explain the category of options.
* @param[labelSize] the width [textTitle] is allowed to use.
* @param[options] list of string options the user should be choosing of.
* @param[selectedOption] the current option that is selected by the radiobuttons.
* @param[onOptionSelected] lambda to change which button is selected.
* @param[vMChangeField] view model lambda to call when selected option is changed.
*/
@Composable
fun WRadioButtonsSelectorRowWise(
enable: Boolean,
textTitle: String,
labelSize: Dp = 75.dp,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
vMChangeField: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
WText(text = textTitle, modifier = Modifier.width(labelSize))
options.forEach { text ->
Row(
modifier = Modifier
.padding(10.dp)
.selectable(
selected = (text == selectedOption),
onClick = {
onOptionSelected(text)
vMChangeField()
}
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == selectedOption),
enabled = enable,
colors = RadioButtonDefaults.colors(
selectedColor = MaterialTheme.colorScheme.tertiary,
),
onClick = {
onOptionSelected(text)
vMChangeField()
}
)
WText(text = text)
}
}
}
}
/**
* Given a list of options, it produces a composable of elements having a radio button with text
* on the left, elements are placed all together on a COLUMN.
* @param[enable] a boolean to disable all the buttons when value is set to false.
* @param[textTitle] text to explain the category of options.
* @param[labelSize] the width [textTitle] is allowed to use.
* @param[options] list of string options the user should be choosing of.
* @param[selectedOption] the current option that is selected by the radiobuttons.
* @param[onOptionSelected] lambda to change which button is selected.
* @param[vMChangeField] view model lambda to call when selected option is changed.
*/
@Composable
fun WRadioButtonsSelectorColumnWise(
enable: Boolean,
textTitle: String,
labelSize: Dp = 75.dp,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
vMChangeField: () -> Unit = {}
) {
Row {
WText(text = textTitle, modifier = Modifier.width(labelSize))
Column {
options.forEach { text ->
Row(
modifier = Modifier
.selectable(
selected = (text == selectedOption),
onClick = {
onOptionSelected(text)
vMChangeField()
}
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == selectedOption),
enabled = enable,
colors = RadioButtonDefaults.colors(
selectedColor = MaterialTheme.colorScheme.tertiary
),
onClick = {
onOptionSelected(text)
vMChangeField()
}
)
WText(text = text)
}
}
}
}
}

View file

@ -0,0 +1,80 @@
package be.re.writand.screens.components
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import be.re.writand.screens.SettingsViewModel
/**
* Composable to use UserSettings in the standard compose.material3.Text,
* all parameters are explained in there, but the [settingsViewModel].
* @param[settingsViewModel] view model that provides the UserSettings.
* @see[Text].
*/
@Composable
fun WText(
text: String,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onPrimary,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current,
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
val settings by settingsViewModel.settings.collectAsState()
val size = if (fontSize == TextUnit.Unspecified && settings != null) {
settings!!.fontSize.sp
} else if (fontSize != TextUnit.Unspecified) {
fontSize
} else {
14.sp
}
Text(
text = text,
modifier = modifier,
color = color,
fontSize = size,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style
)
}

View file

@ -5,13 +5,20 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import be.re.writand.navigation.WAppDestinations
import be.re.writand.screens.components.WButton
/**
* Screen displayed on the startup of the app showing the logo while UserSettings are being loaded.
* @param[navHostController] controller to use the WNavGraph to change screen to right destination.
* @param[vM] view model corresponding to this screen.
*/
@Composable
fun SplashScreen(
navHostController: NavHostController
navHostController: NavHostController,
vM: SplashViewModel = hiltViewModel()
) {
Box(modifier = Modifier.size(20.dp, 20.dp)) {

View file

@ -1,4 +1,26 @@
package be.re.writand.screens.splash
class SplashViewModel {
import android.util.Log
import be.re.writand.data.repos.settings.UserSettingsRepository
import be.re.writand.screens.WViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* View model to be used by SplashScreen that handles the UserSettings and navigation.
* @param[userSettingsRepository] repository to setup the settings if not initialized yet.
*/
@HiltViewModel
class SplashViewModel @Inject constructor(
userSettingsRepository: UserSettingsRepository
): WViewModel() {
init {
// this is needed for every start of the app to direct the user:
// welcome screen, project picker, editor (--> version 2 is for the editor as well)
launchCatching {
userSettingsRepository.initializeSettingsIfNull()
}
}
}

View file

@ -1,24 +1,160 @@
package be.re.writand.screens.welcome
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import be.re.writand.data.local.models.UserLanguage
import be.re.writand.data.local.models.UserTheme
import be.re.writand.navigation.WAppDestinations
import be.re.writand.screens.components.WButton
import be.re.writand.screens.components.WLabelAndTextField
import be.re.writand.screens.components.WLogoImage
import be.re.writand.screens.components.WRadioButtonsSelectorRowWise
import be.re.writand.screens.components.WText
/**
* Screen displayed for first time users to set their basic settings such as language etc.
* @param[navHostController] controller to use the WNavGraph to change screen to right destination.
* @param[vM] view model that handles everything for this screen for changes in settings and ui.
*/
@Composable
fun WelcomeSettingsScreen(
navHostController: NavHostController
navHostController: NavHostController,
vM: WelcomeSettingsViewModel = hiltViewModel()
) {
Box(modifier = Modifier.size(20.dp, 20.dp)) {
WButton(
text = "Finish",
onClick = { navHostController.navigate(WAppDestinations.PROJECT_PICKER) }
)
val languageOptions = UserLanguage.entries.map { entry -> entry.toString() }
val (languageSelected, onLanguageSelect) = remember {
mutableStateOf(languageOptions[0])
}
val themeOptions = UserTheme.entries.map { entry -> entry.toString() }
val (themeSelected, themeSelect) = remember {
mutableStateOf(themeOptions[0])
}
Box(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.secondary),
contentAlignment = Alignment.Center
) {
Scaffold(modifier = Modifier
.size(600.dp)
.border(
1.dp,
color = MaterialTheme.colorScheme.tertiary,
shape = RoundedCornerShape(10.dp)
)
.clip(shape = RoundedCornerShape(10.dp)),
topBar = {
Box(modifier = Modifier.height(75.dp)) {
WLogoImage(
modifier = Modifier.padding(25.dp),
alignment = Alignment.CenterStart
)
}
},
bottomBar = {
Row(
modifier = Modifier
.fillMaxWidth(1f)
.padding(15.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.End
) {
WButton(
text = "Apply",
onClick = {
vM.onApply(languageSelected, themeSelected, vM.textFieldInput.value)
},
modifier = Modifier.padding(end = 10.dp)
)
WButton(
text = "Finish",
onClick = {
navHostController.navigate(WAppDestinations.PROJECT_PICKER)
}
)
}
}
) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(50.dp, 15.dp)
) {
WText(
text = "Welcome",
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
WText(
text = " to settings",
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
}
Column(
modifier = Modifier.padding(start = 100.dp)
) {
WRadioButtonsSelectorRowWise(
enable = true,
textTitle = "Language",
options = languageOptions,
selectedOption = languageSelected,
onOptionSelected = onLanguageSelect
)
WRadioButtonsSelectorRowWise(
enable = true,
textTitle = "Theme",
options = themeOptions,
selectedOption = themeSelected,
onOptionSelected = themeSelect
)
WLabelAndTextField(
title = "Fontsize",
value = vM.textFieldInput.value,
fullSize = 200.dp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
onTextChange = vM::onTextFieldChange
)
}
}
}
}
}

View file

@ -0,0 +1,64 @@
package be.re.writand.screens.welcome
import androidx.compose.runtime.mutableStateOf
import be.re.writand.data.local.models.UserSettings
import be.re.writand.data.repos.settings.UserSettingsRepository
import be.re.writand.domain.settings.SetFontSizeSettingsUseCase
import be.re.writand.domain.settings.SetLanguageSettingsUseCase
import be.re.writand.domain.settings.SetThemeSettingsUseCase
import be.re.writand.screens.WViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* View model to be used by WelcomeSettingsScreen that handles the UserSettings and buttons.
* @param[userSettingsRepository] repository to setup the settings if not initialized yet.
* @param[setLanguage] use-case that alters the UserLanguage of the UserSettings if a valid String
* is given as argument.
* @param[setTheme] use-case that alters the UserTheme of the UserSettings if a valid String
* is given as argument.
* @param[setFontSize] use-case that alters the fontSize of the UserSettings if the field contains
* a String that can be converted to a Float above 0.
*/
@HiltViewModel
class WelcomeSettingsViewModel @Inject constructor(
private val userSettingsRepository: UserSettingsRepository,
private val setLanguage: SetLanguageSettingsUseCase,
private val setTheme: SetThemeSettingsUseCase,
private val setFontSize: SetFontSizeSettingsUseCase
) : WViewModel() {
private val _settings = MutableStateFlow<UserSettings?>(null)
val settings: StateFlow<UserSettings?> = _settings
val textFieldInput = mutableStateOf(_settings.value?.fontSize.toString())
init {
launchCatching {
userSettingsRepository.userSettings.collect { settings ->
_settings.value = settings
textFieldInput.value = settings.fontSize.toString()
}
}
}
fun onTextFieldChange(newValue: String) {
textFieldInput.value = newValue
}
fun onApply(language: String, theme: String, fontSize: String) {
launchCatching {
setLanguage(language)
setTheme(theme)
val errorFontSize: NumberFormatException? = setFontSize(fontSize)
errorFontSize.let {
// reset the text field to its old value
textFieldInput.value = _settings.value?.fontSize.toString()
}
}
}
}

View file

@ -1,24 +1,103 @@
package be.re.writand.screens.welcome
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import be.re.writand.navigation.WAppDestinations
import be.re.writand.screens.components.WButton
import be.re.writand.screens.components.WLogoImage
import be.re.writand.screens.components.WText
/**
* Screen displayed for first time users to welcome them to the app.
* @param[navHostController] controller to use the WNavGraph to change screen to right destination.
*/
@Composable
fun WelcomeStartScreen(
navHostController: NavHostController
) {
Box(modifier = Modifier.size(20.dp, 20.dp)) {
WButton(
text = "Next",
onClick = { navHostController.navigate(WAppDestinations.WELCOME_TOS) }
)
Box(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.secondary),
contentAlignment = Alignment.Center
) {
Scaffold(modifier = Modifier
.size(600.dp)
.border(
1.dp,
color = MaterialTheme.colorScheme.tertiary,
shape = RoundedCornerShape(10.dp)
)
.clip(shape = RoundedCornerShape(10.dp)),
topBar = {
Box(modifier = Modifier.height(75.dp)) {
WLogoImage(
modifier = Modifier.padding(25.dp),
alignment = Alignment.CenterStart
)
}
},
bottomBar = {
Box(
modifier = Modifier
.fillMaxWidth(1f)
.padding(15.dp),
contentAlignment = Alignment.BottomEnd
) {
WButton(
text = "Next",
onClick = { navHostController.navigate(WAppDestinations.WELCOME_TOS) }
)
}
}
) { it ->
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(50.dp, 15.dp)
) {
WText(
text = "Welcome",
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
WText(
text = " to Writand",
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
}
WText(text = "Any fool can write code that a computer can understand.\nGood programmers can write code that humans can understand.")
WText(text = "~ Martin Fowler")
}
}
}
}

View file

@ -1,24 +1,152 @@
package be.re.writand.screens.welcome
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import be.re.writand.navigation.WAppDestinations
import be.re.writand.screens.components.WButton
import be.re.writand.screens.components.WCheckbox
import be.re.writand.screens.components.WLoadingIndicator
import be.re.writand.screens.components.WLogoImage
import be.re.writand.screens.components.WText
/**
* Screen displayed for first time users to set their basic settings such as language etc.
* @param[navHostController] controller to use the WNavGraph to change screen to right destination.
* @param[vM] view model that gives access to the TOS file content.
*/
@Composable
fun WelcomeTOSScreen(
navHostController: NavHostController
navHostController: NavHostController,
vM: WelcomeTOSViewModel = hiltViewModel()
) {
var checked by remember { mutableStateOf(false) }
val uiState by vM.uiState.collectAsState()
Box(modifier = Modifier.size(20.dp, 20.dp)) {
WButton(
text = "Next",
onClick = { navHostController.navigate(WAppDestinations.WELCOME_SETTINGS) }
)
Box(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.secondary),
contentAlignment = Alignment.Center
) {
Scaffold(modifier = Modifier
.size(600.dp)
.border(
1.dp,
color = MaterialTheme.colorScheme.tertiary,
shape = RoundedCornerShape(10.dp)
)
.clip(shape = RoundedCornerShape(10.dp)),
topBar = {
Box(modifier = Modifier.height(75.dp)) {
WLogoImage(
modifier = Modifier.padding(25.dp),
alignment = Alignment.CenterStart
)
}
},
bottomBar = {
Box(
modifier = Modifier
.fillMaxWidth(1f)
.padding(15.dp),
contentAlignment = Alignment.BottomEnd
) {
WButton(
text = "Next",
enabled = checked,
onClick = { navHostController.navigate(WAppDestinations.WELCOME_SETTINGS) }
)
}
}
) { it ->
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(50.dp, 15.dp)
) {
WText(
text = "Welcome",
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
WText(
text = " to TOS",
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp
)
}
Box(
modifier = Modifier
.height(350.dp)
.padding(horizontal = 50.dp, vertical = 25.dp)
.border(1.dp, color = MaterialTheme.colorScheme.secondary)
) {
when (val value = uiState) {
WelcomeTOSUiState.Loading -> {
WLoadingIndicator()
}
is WelcomeTOSUiState.Success -> {
LazyColumn {
items(value.tosStrings.size) { item ->
Text(text = value.tosStrings[item])
}
}
}
is WelcomeTOSUiState.Failed -> {
Text(text = value.message)
}
}
}
WCheckbox(
text = "Accept",
modifier = Modifier
.padding(horizontal = 50.dp)
.fillMaxWidth(),
checked = checked,
updateChecked = { checked = !checked }
)
}
}
}
}

View file

@ -0,0 +1,13 @@
package be.re.writand.screens.welcome
/**
* Interface to present the 3 differences stages of getting the TOS file content:
* - [Loading]: the content is pending.
* - [Success]: the TOS file is ready, and given in a list of Strings.
* - [Failed]: unable to get the TOS content from the file, give back an error message.
*/
sealed interface WelcomeTOSUiState {
object Loading: WelcomeTOSUiState
data class Success(val tosStrings: List<String>): WelcomeTOSUiState
data class Failed(val message: String): WelcomeTOSUiState
}

View file

@ -0,0 +1,33 @@
package be.re.writand.screens.welcome
import be.re.writand.domain.tos.GetTOSUseCase
import be.re.writand.screens.WViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* View model to be used by WelcomeTOSScreen that handles the TOS content.
* This view model uses the WelcomeTOSUiState to enable handling failures of getting the file.
* @param[getTOSUseCase] use case to get the TOS content in a list of Strings.
*/
@HiltViewModel
class WelcomeTOSViewModel @Inject constructor(
private val getTOSUseCase: GetTOSUseCase
): WViewModel() {
private val _uiState: MutableStateFlow<WelcomeTOSUiState> = MutableStateFlow(WelcomeTOSUiState.Loading)
val uiState: StateFlow<WelcomeTOSUiState> = _uiState
init {
launchCatching {
val tosList: List<String> = getTOSUseCase()
if (tosList.isNotEmpty()) {
_uiState.value = WelcomeTOSUiState.Success(tosList)
} else {
_uiState.value = WelcomeTOSUiState.Failed("Terms of Services could not be loaded.")
}
}
}
}

View file

@ -11,14 +11,14 @@ private val DarkColorPalette = darkColorScheme(
primary = MainGrey,
secondary = VariantDarkGrey,
tertiary = MainGreen,
onBackground = Color.White
onPrimary = Color.White
)
private val LightColorPalette = lightColorScheme(
primary = Color.White,
secondary = VariantLightGrey,
tertiary = MainGreen,
onBackground = Color.Black
onPrimary = Color.Black
/* Other default colors to override
background = Color.White,

View file

@ -28,5 +28,5 @@ message ProtoSettings {
ProtoTheme theme = 2;
uint32 max_saved_projects = 3;
uint32 max_saved_files = 4;
uint32 font_size = 5;
float font_size = 5;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -8,6 +8,6 @@ plugins {
id 'com.android.application' version '8.5.1' apply false
id 'com.android.library' version '8.5.1' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'com.google.dagger.hilt.android' version '2.44' apply false
id 'com.google.dagger.hilt.android' version '2.49' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'
}