Initial commit for assignment 09

This commit is contained in:
2023-06-28 10:06:27 +02:00
commit c7b3f50a63
52 changed files with 2000 additions and 0 deletions

49
.gitignore vendored Executable file
View File

@ -0,0 +1,49 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof

1
app/.gitignore vendored Executable file
View File

@ -0,0 +1 @@
/build

70
app/build.gradle Executable file
View File

@ -0,0 +1,70 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'de.luh.hci.mi.experiencesampling'
compileSdk 33
defaultConfig {
applicationId "de.luh.hci.mi.experiencesampling"
minSdk 27
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.8.0'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.5.1'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
implementation "androidx.navigation:navigation-compose:2.7.0-alpha01"
}

21
app/proguard-rules.pro vendored Executable file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,22 @@
package de.luh.hci.mi.experiencesampling
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("de.luh.hci.mi.experiencesampling", appContext.packageName)
}
}

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".ExperienceSampling"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ExperienceSampling">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ExperienceSampling">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".notifications.SamplingActivity"
android:launchMode="singleTask"
android:taskAffinity=""
android:excludeFromRecents="true"
android:theme="@style/Theme.ExperienceSampling" />
<receiver
android:process=":remote"
android:name=".notifications.NotificationTrigger" />
</application>
</manifest>

View File

@ -0,0 +1,71 @@
package de.luh.hci.mi.experiencesampling
import android.app.AlarmManager
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import de.luh.hci.mi.experiencesampling.data.CouchDB
import de.luh.hci.mi.experiencesampling.data.Repository
import de.luh.hci.mi.experiencesampling.data.RepositoryImpl
import de.luh.hci.mi.experiencesampling.notifications.NotificationTrigger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
// Time between notifications in milliseconds.
const val samplingInterval = 10000L // milliseconds
// Unique identifier for experimenter. Used to distinguish between entries in database.
const val experimenter = "2386452183276023461634920"
// The application instance exists as long as the app executes and is therefore used for app-wide
// data that is used in activities.
class ExperienceSampling : Application() {
// officially: use Dagger-Hilt dependency injection, rather than manual dependency injection
// https://developer.android.com/training/dependency-injection
// https://developer.android.com/training/dependency-injection/manual
private lateinit var remoteDb: CouchDB
lateinit var repository: Repository
private set
// Coroutine scope for submitting data to server. Continues even when navigating away from UI.
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate() {
super.onCreate()
remoteDb = CouchDB("https://couchdb.hci.uni-hannover.de/mobint/")
repository = RepositoryImpl(applicationContext, remoteDb)
}
// Whether or not periodic experience sampling is on.
val periodicSampling = mutableStateOf(false)
// Schedules the next notification delay ms from now.
fun scheduleNextNotification(delay: Long) {
val am = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val i = Intent(this, NotificationTrigger::class.java)
val pi = PendingIntent.getBroadcast(this, 0, i, PendingIntent.FLAG_MUTABLE)
am.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + delay,
pi
)
}
// Cancels any registered notification.
fun cancelNotification() {
val am = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val i = Intent(this, NotificationTrigger::class.java)
val pi = PendingIntent.getBroadcast(this, 0, i, PendingIntent.FLAG_MUTABLE)
am.cancel(pi)
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
}

View File

@ -0,0 +1,75 @@
package de.luh.hci.mi.experiencesampling
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.luh.hci.mi.experiencesampling.ui.sampling.SamplingScreen
import de.luh.hci.mi.experiencesampling.ui.sampling.SamplingViewModel
import de.luh.hci.mi.experiencesampling.ui.start.StartScreen
import de.luh.hci.mi.experiencesampling.ui.start.StartViewModel
import de.luh.hci.mi.experiencesampling.ui.statistics.StatisticsScreen
import de.luh.hci.mi.experiencesampling.ui.statistics.StatisticsViewModel
import de.luh.hci.mi.experiencesampling.ui.theme.ExperienceSamplingTheme
// The main activity sets the content root, which is a navigation graph. Each "composable" in the
// navigation graph is a separate page/screen of the app.
// https://developer.android.com/jetpack/compose/navigation
// https://developer.android.com/guide/navigation/principles
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
log("onCreate")
setContent {
ExperienceSamplingTheme {
// The NavController manages navigation in the navigation graph (e.g, back and up).
val navController = rememberNavController()
// The NavHost composable is a container for navigation destinations.
NavHost(
navController = navController,
startDestination = Routes.START
) {
// The NavGraph has a composable for each navigation destination.
// Each destination is associated with a route (a string).
// ViewModels can be scoped to navigation graphs.
// navigation destination
composable(route = Routes.START) {
StartScreen(
onNavigate = navController::navigate, // function used to navigate to another destination
viewModel(factory = StartViewModel.Factory) // create view model (or get from cache)
)
}
// navigation destination
composable(route = Routes.STATISTICS) {
StatisticsScreen(
navigateBack = navController::popBackStack, // executed when save button is pressed
viewModel(factory = StatisticsViewModel.Factory) // create view model (or get from cache)
)
}
// navigation destination
composable(route = Routes.SAMPLING) {
SamplingScreen(
navigateBack = navController::popBackStack, // executed when save button is pressed
viewModel(factory = SamplingViewModel.Factory) // create view model (or get from cache)
)
}
}
}
}
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
}

View File

@ -0,0 +1,8 @@
package de.luh.hci.mi.experiencesampling
// Each navigation destination (screen) has a "route" associated to it.
object Routes {
const val START = "start"
const val STATISTICS = "statistics"
const val SAMPLING = "sampling"
}

View File

@ -0,0 +1,68 @@
package de.luh.hci.mi.experiencesampling.data
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* Stores and retrieves JSON documents on/from the DB server.
* http://couchdb.apache.org
* https://www.json.org/json-en.html
* http://developer.android.com/reference/org/json/JSONObject.html
*/
class CouchDB(private val dbUrl: String) {
// Gets a JSON object from the server given a resource path.
// Example path: _design/esm/_view/ctxt?key=%22Programmieren+1%22
// Throws an exception on failure.
suspend fun get(resourcePath: String): JSONObject =
withContext(Dispatchers.IO) {
Log.d(javaClass.simpleName, "get: " + Thread.currentThread().name)
// simulate delay
// delay(3000L)
val url = URL(dbUrl + resourcePath)
val con = url.openConnection() as HttpURLConnection
try {
val reader = BufferedReader(InputStreamReader(con.inputStream))
val obj = JSONObject(reader.readText())
reader.close()
return@withContext obj
} finally {
con.disconnect()
}
}
// Stores the given JSON object at the indicated location on the server.
// Returns the server's response. Throws an exception on failure.
suspend fun put(resourcePath: String, doc: JSONObject): JSONObject =
withContext(Dispatchers.IO) {
Log.d(javaClass.simpleName, "put: " + Thread.currentThread().name)
// simulate delay
// delay(7000L)
val url = URL(dbUrl + resourcePath)
val con = url.openConnection() as HttpURLConnection
try {
con.doOutput = true
con.requestMethod = "PUT"
con.setChunkedStreamingMode(0) // write in chunks
con.setRequestProperty("Content-type", "application/json")
con.setRequestProperty("Accept", "application/json")
val writer = BufferedWriter(OutputStreamWriter(con.outputStream))
writer.write(doc.toString())
writer.close()
val reader = BufferedReader(InputStreamReader(con.inputStream))
val obj = JSONObject(reader.readText())
reader.close()
return@withContext obj
} finally {
con.disconnect()
}
}
}

View File

@ -0,0 +1,15 @@
package de.luh.hci.mi.experiencesampling.data
// A questionnaire item.
data class Item(
val title: String,
val question: String,
val negativeEnd: String,
val positiveEnd: String
)
// A rating associated with a questionnaire item.
data class Rating(
val item: Item,
val value: Float
)

View File

@ -0,0 +1,20 @@
package de.luh.hci.mi.experiencesampling.data
// A repository is an interface to (a category of) the app's data.
// The repository interface isolates the data layer from the rest of the app.
// https://developer.android.com/topic/architecture/data-layer
// https://developer.android.com/codelabs/basic-android-kotlin-training-repository-pattern
interface Repository {
// Contexts a user may be in.
fun getContexts(): List<String>
// Questionnaire items.
fun getItems(): List<Item>
// Submits the list of ratings. May throw an IOException.
suspend fun submitRatings(context: String, ratings: List<Rating>)
// Retrieves the ratings for the given context. May throw an IOException.
suspend fun getRatings(context: String): Map<Item, Float>
}

View File

@ -0,0 +1,124 @@
package de.luh.hci.mi.experiencesampling.data
import android.content.Context
import android.util.Log
import de.luh.hci.mi.experiencesampling.R
import de.luh.hci.mi.experiencesampling.experimenter
import org.json.JSONObject
import java.net.URLEncoder
// Implementation of the repository interface. It relies on a database as a data source,
// which is provided via the constructor (dependency injection).
// https://developer.android.com/topic/architecture/data-layer
// https://developer.android.com/codelabs/basic-android-kotlin-training-repository-pattern
class RepositoryImpl(
context: Context, // application context (not the user's context)
private val db: CouchDB
) : Repository {
// Fixed list of contexts a user may be in.
private val contexts = listOf(
context.getString(R.string.context_1),
context.getString(R.string.context_2),
context.getString(R.string.context_3)
)
// Fixed list of questionnaire items.
private val items = listOf(
Item(
context.getString(R.string.understanding),
context.getString(R.string.understanding_question),
context.getString(R.string.complicated),
context.getString(R.string.easy)
),
Item(
context.getString(R.string.interest),
context.getString(R.string.interest_question),
context.getString(R.string.not_interesting),
context.getString(R.string.interesting)
),
Item(
context.getString(R.string.lecturer),
context.getString(R.string.lecturer_question),
context.getString(R.string.boring),
context.getString(R.string.exciting)
),
Item(
context.getString(R.string.slides),
context.getString(R.string.slides_question),
context.getString(R.string.obstructive),
context.getString(R.string.supportive)
),
)
override fun getContexts(): List<String> {
return contexts
}
override fun getItems(): List<Item> {
return items
}
override suspend fun submitRatings(context: String, ratings: List<Rating>) {
Log.d(javaClass.simpleName, "submitRatings: " + Thread.currentThread().name)
// store in online database
// create JSON document and send to server
val obj = JSONObject()
// timestamp
val time = System.currentTimeMillis()
obj.put("time", time)
obj.put("experimenter", experimenter)
obj.put("context", context)
for (rating in ratings) {
obj.put(rating.item.title, rating.value)
}
Log.d(javaClass.simpleName, obj.toString())
val docId = "esm$time"
val response = db.put(docId, obj)
Log.d(javaClass.simpleName, response.toString())
}
/*
Example query:
https://couchdb.hci.uni-hannover.de/mobint/_design/esm/_view/ctxtexp?key=["Mobile Interaktion","MyExperimenterId"]
Example get response:
{"total_rows":8,"offset":0,"rows":[
{"id":"esm1686831673152","key":"Mensa","value":{"_id":"esm1686831673152","_rev":"1-dacc2a8f08698a838d291870f737f282","time":1686831673152,"experimenter":"MyExperimenterId","context":"Mensa","Mood":5,"Understanding":4,"Difficulty":3}},
{"id":"esm1686834330948","key":"Mensa","value":{"_id":"esm1686834330948","_rev":"1-bceece5c4ae08cdf34dd5f2f2ff0ee5a","time":1686834330948,"experimenter":"MyExperimenterId","context":"Mensa","Mood":1,"Understanding":1,"Difficulty":1}}
]}
*/
override suspend fun getRatings(context: String): Map<Item, Float> {
// query context for all experimenters
// val key = URLEncoder.encode("\"$context\"", "UTF-8")
// val query = "_design/esm/_view/ctxt?key=$key"
// query context for one experimenter only
val key = URLEncoder.encode("[\"$context\",\"$experimenter\"]", "UTF-8")
val query = "_design/esm/_view/ctxtexp?key=$key"
Log.d(javaClass.simpleName, query)
val obj = db.get(query)
// compute the average ratings per item from the set of retrieved values
val averageRatings: MutableMap<Item, Float> = mutableMapOf()
val rows = obj.getJSONArray("rows")
val n = rows.length()
if (n > 0) {
for (i in 0 until n) {
val row = rows.getJSONObject(i).getJSONObject("value")
Log.d(javaClass.simpleName, "row: $row")
for (item in getItems()) {
val rating = row.getDouble(item.title).toFloat()
val sum = averageRatings[item] ?: 0f
averageRatings[item] = sum + rating
}
}
for (item in getItems()) {
val sum = averageRatings[item] ?: 0f
averageRatings[item] = sum / n
}
}
return averageRatings
}
}

View File

@ -0,0 +1,87 @@
package de.luh.hci.mi.experiencesampling.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import de.luh.hci.mi.experiencesampling.R
// This component receives alarms and triggers a notification for each alarm.
// The alarm is registered in ExperienceSampling::scheduleNextNotification.
// https://developer.android.com/develop/ui/views/notifications
class NotificationTrigger : BroadcastReceiver() {
// Theis broadcast receiver is registered to receive alarms.
override fun onReceive(context: Context, intent: Intent) {
showNotification(context)
}
// Notifications are posted on notification channels, so create one if needed.
private fun createNotificationChannel(context: Context) {
val channelId = context.getString(R.string.channel_id)
val notificationManager = NotificationManagerCompat.from(context)
var channel = notificationManager.getNotificationChannel(channelId)
log("channel: $channel")
if (channel == null) {
val name = context.getString(R.string.channel_name)
val description = context.getString(R.string.channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
channel = NotificationChannel(channelId, name, importance)
channel.description = description
channel.enableLights(true)
channel.lightColor = Color.RED
channel.enableVibration(true)
channel.vibrationPattern = longArrayOf(0, 200, 500, 400, 500, 400, 500, 200, 500)
notificationManager.createNotificationChannel(channel)
}
}
// Shows a notification. Clicking on the notification starts the sampling activity.
private fun showNotification(context: Context) {
createNotificationChannel(context)
val intent = Intent(context, SamplingActivity::class.java)
intent.action = Intent.ACTION_VIEW
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val channelId = context.getString(R.string.channel_id)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(context.getString(R.string.what_experience))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent) // fire this intent when the user taps the notification
.setAutoCancel(true) // remove the notification when the user taps it
// https://developer.android.com/training/permissions/requesting
val notificationManager = NotificationManagerCompat.from(context)
val permissionGranted = ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
log("permissionGranted: $permissionGranted")
notificationManager.notify(0, builder.build())
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
}

View File

@ -0,0 +1,34 @@
package de.luh.hci.mi.experiencesampling.notifications
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import de.luh.hci.mi.experiencesampling.ui.sampling.SamplingScreen
import de.luh.hci.mi.experiencesampling.ui.sampling.SamplingViewModel
import de.luh.hci.mi.experiencesampling.ui.theme.ExperienceSamplingTheme
// Shows the survey UI and submits the entered data to database server.
// Started as a response to a notification.
class SamplingActivity : ComponentActivity() {
// Associates the UI with the activity.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
log("onCreate (response to notification)")
setContent {
ExperienceSamplingTheme {
SamplingScreen(
navigateBack = this::finish, // end activity when input is done
viewModel(factory = SamplingViewModel.Factory) // create view model (or get from cache)
)
}
}
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
}

View File

@ -0,0 +1,109 @@
package de.luh.hci.mi.experiencesampling.ui.sampling
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.luh.hci.mi.experiencesampling.R
import de.luh.hci.mi.experiencesampling.ui.statistics.RadioGroup
import kotlinx.coroutines.launch
// UI for showing the questionnaire and taking an experience sample.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SamplingScreen(
navigateBack: () -> Unit, // navigate back to previous screen
viewModel: SamplingViewModel // the view model of this screen
) {
// needed for showing a popup message
val snackbarHostState = remember {
SnackbarHostState()
}
// needed for showing a popup message
val scope = rememberCoroutineScope()
val thankYouMessage = stringResource(R.string.thank_you)
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
floatingActionButton = {
FloatingActionButton(onClick = {
println("FAB: " + Thread.currentThread().name)
viewModel.submit()
scope.launch {
println("sl: " + Thread.currentThread().name)
snackbarHostState.showSnackbar(thankYouMessage)
navigateBack()
}
}) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.submit)
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.padding(8.dp))
Text(stringResource(R.string.context), fontSize = 24.sp)
Text(stringResource(R.string.context_question))
RadioGroup(viewModel.contexts, viewModel.selectedContext, viewModel::onContextSelected)
LazyColumn(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(viewModel.items) { item ->
Spacer(Modifier.padding(8.dp))
Text(item.title, fontSize = 24.sp)
if (item.question.isNotBlank()) {
Text(item.question)
}
Row {
Text(item.negativeEnd)
Spacer(modifier = Modifier.weight(1f))
Text(item.positiveEnd)
}
val sliderState = viewModel.sliderState(item)
Slider(
value = sliderState.value,
onValueChange = { sliderState.value = it; println(it) },
steps = 4,
valueRange = 0f..5f
)
}
}
Spacer(Modifier.padding(48.dp))
}
}
}

View File

@ -0,0 +1,102 @@
package de.luh.hci.mi.experiencesampling.ui.sampling
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.luh.hci.mi.experiencesampling.ExperienceSampling
import de.luh.hci.mi.experiencesampling.data.Item
import de.luh.hci.mi.experiencesampling.data.Rating
import de.luh.hci.mi.experiencesampling.data.Repository
import de.luh.hci.mi.experiencesampling.samplingInterval
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.IOException
import kotlin.math.roundToInt
// ViewModel for the sampling screen.
class SamplingViewModel constructor(
private val app: ExperienceSampling,
private val repository: Repository, // the underlying repository (data model)
private val applicationScope: CoroutineScope,
) : ViewModel() {
// contexts from data model, const
val contexts = repository.getContexts()
// questionnaire items from data model, const
val items = repository.getItems()
// the currently selected context
var selectedContext by mutableStateOf(contexts[0])
private set
fun onContextSelected(context: String) {
selectedContext = context
}
// the current slider positions
private val sliderStates = mutableMapOf<Item, MutableState<Float>>()
fun sliderState(item: Item): MutableState<Float> {
var slider = sliderStates[item]
if (slider == null) {
slider = mutableFloatStateOf(0f)
sliderStates[item] = slider
}
return slider
}
// Submits the sample to the repository.
fun submit() {
// submit sample as a list of ratings
val sample = items.map {
Rating(it, sliderState(it).value.roundToInt().toFloat())
}
// https://medium.com/androiddevelopers/coroutines-patterns-for-work-that-shouldnt-be-cancelled-e26c40f142ad
// The submission should not be cancelled when the user navigates away from the screen.
// Thus an app-level coroutine scope is used here.
applicationScope.launch {
try {
println("applicationScope.launch: " + Thread.currentThread().name)
repository.submitRatings(selectedContext, sample)
} catch (ex: IOException) {
log("exception trying to submit: $ex")
}
}
}
// Called when this ViewModel is no longer used and will be destroyed. Can be used for cleanup.
override fun onCleared() {
log("onCleared")
// Navigating away from the sampling screen: Register next notification for sampling.
if (app.periodicSampling.value) {
app.scheduleNextNotification(samplingInterval)
}
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
companion object {
// Companion object for creating the view model in the right lifecycle scope.
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[APPLICATION_KEY] as ExperienceSampling
val repository = app.repository
val scope = app.applicationScope
SamplingViewModel(app, repository, scope)
}
}
}
}

View File

@ -0,0 +1,52 @@
package de.luh.hci.mi.experiencesampling.ui.start
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.luh.hci.mi.experiencesampling.R
import de.luh.hci.mi.experiencesampling.Routes
import de.luh.hci.mi.experiencesampling.ui.theme.Purple40
import de.luh.hci.mi.experiencesampling.ui.theme.Purple80
@Composable
fun StartScreen(
onNavigate: (route: String) -> Unit, // used to navigate to another screen
viewModel: StartViewModel,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.padding(24.dp))
Button(onClick = { onNavigate(Routes.SAMPLING) }) {
Text(stringResource(R.string.enter_experience_sample))
}
Spacer(Modifier.padding(24.dp))
Button(onClick = { onNavigate(Routes.STATISTICS) }) {
Text(stringResource(R.string.show_statistics))
}
Spacer(Modifier.padding(24.dp))
Button(
onClick = { viewModel.togglePeriodicSampling() },
colors = ButtonDefaults.buttonColors(if (viewModel.periodicSampling) Purple80 else Purple40)
) {
if (viewModel.periodicSampling) {
Text(stringResource(R.string.stop_periodic_sampling))
} else {
Text(stringResource(R.string.start_periodic_sampling))
}
}
Spacer(Modifier.padding(24.dp))
}
}

View File

@ -0,0 +1,56 @@
package de.luh.hci.mi.experiencesampling.ui.start
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.luh.hci.mi.experiencesampling.ExperienceSampling
import de.luh.hci.mi.experiencesampling.data.Repository
import de.luh.hci.mi.experiencesampling.samplingInterval
// ViewModel for the start screen.
class StartViewModel constructor(
private val app: ExperienceSampling,
private val repository: Repository, // the underlying repository (data model)
) : ViewModel() {
var periodicSampling by app.periodicSampling
fun togglePeriodicSampling() {
if (app.periodicSampling.value) {
log("stop sampling")
app.periodicSampling.value = false
app.cancelNotification()
} else {
log("start sampling")
app.scheduleNextNotification(samplingInterval)
app.periodicSampling.value = true
}
}
// Called when this ViewModel is no longer used and will be destroyed. Can be used for cleanup.
override fun onCleared() {
log("onCleared")
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
companion object {
// Companion object for creating the view model in the right lifecycle scope.
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[APPLICATION_KEY] as ExperienceSampling
val repository = app.repository
StartViewModel(app, repository)
}
}
}
}

View File

@ -0,0 +1,114 @@
package de.luh.hci.mi.experiencesampling.ui.statistics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.luh.hci.mi.experiencesampling.ui.theme.Pink40
import kotlin.math.roundToInt
// The statistics screen shows average ratings for the selected context.
@Composable
fun StatisticsScreen(
navigateBack: () -> Unit,
viewModel: StatisticsViewModel
) {
// todo: implement
LazyColumn {
items(viewModel.items) { item ->
Text(item.title)
val ratingState = viewModel.ratingState(item)
RatingBar(
rating = ratingState.value,
startLabel = item.negativeEnd,
endLabel = item.positiveEnd
)
}
}
}
@Composable
fun RadioGroup(options: List<String>, selectedOption: String, onOptionSelected: (String) -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
options.forEach { option ->
Row(
Modifier
.fillMaxWidth()
.selectable(
selected = (option == selectedOption),
onClick = { onOptionSelected(option) }
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (option == selectedOption),
onClick = { onOptionSelected(option) }
)
Text(text = option)
}
}
}
}
@Composable
fun RatingBar(
modifier: Modifier = Modifier,
rating: Float = 0f,
stars: Int = 5,
color: Color = Pink40,
startLabel: String = "",
endLabel: String = ""
) {
assert(stars in 0..10)
assert(rating in 0f..10f)
assert(rating <= stars)
val ratingStars = rating.roundToInt()
val restStars = stars - ratingStars
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (startLabel.isNotBlank() || endLabel.isNotBlank()) {
Text("(%.1f)".format(rating), modifier = modifier.padding(start = 8.dp))
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(startLabel, modifier = modifier.padding(end = 8.dp))
repeat(ratingStars) {
Icon(imageVector = Icons.Filled.Star, contentDescription = null, tint = color)
}
repeat(restStars) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.LightGray
)
}
Text(endLabel, modifier = modifier.padding(start = 8.dp))
}
}
}
@Preview
@Composable
fun RatingPreview() {
RatingBar(rating = 2.5f, startLabel = "very bad", endLabel = "very good")
}

View File

@ -0,0 +1,102 @@
package de.luh.hci.mi.experiencesampling.ui.statistics
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.luh.hci.mi.experiencesampling.ExperienceSampling
import de.luh.hci.mi.experiencesampling.data.Item
import de.luh.hci.mi.experiencesampling.data.Repository
import kotlinx.coroutines.launch
import java.io.IOException
// ViewModel for the statistics screen.
class StatisticsViewModel constructor(
private val repository: Repository, // the underlying repository (data model)
) : ViewModel() {
// contexts from data model, const
val contexts = repository.getContexts()
// questionnaire items from data model, const
val items = repository.getItems()
// the currently selected context
var selectedContext by mutableStateOf(contexts[0])
private set
fun onContextSelected(context: String) {
if (context != selectedContext) {
selectedContext = context
viewModelScope.launch {
try {
ratings = repository.getRatings(context)
for (item in items) {
val rs = ratingState(item)
rs.value = ratings[item] ?: 0f
}
} catch (ex: IOException) {
log("exception trying to get ratings (onContextSelected): $ex")
}
}
}
}
private var ratings: Map<Item, Float> = mapOf()
private val ratingStates = mutableMapOf<Item, MutableState<Float>>()
init {
viewModelScope.launch {
try {
ratings = repository.getRatings(selectedContext)
for (item in items) {
val rs = ratingState(item)
rs.value = ratings[item] ?: 0f
}
} catch (ex: IOException) {
log("exception trying to get ratings (init): $ex")
}
}
}
fun ratingState(item: Item): MutableState<Float> {
var rating = ratingStates[item]
if (rating == null) {
rating = mutableFloatStateOf(0f)
ratingStates[item] = rating
}
return rating
}
// Called when this ViewModel is no longer used and will be destroyed. Can be used for cleanup.
override fun onCleared() {
log("onCleared")
ratings = mapOf()
ratingStates.clear()
}
// Logs a debug message.
private fun log(msg: String) {
Log.d(this.javaClass.simpleName, msg)
}
companion object {
// Companion object for creating the view model in the right lifecycle scope.
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[APPLICATION_KEY] as ExperienceSampling
val repository = app.repository
StatisticsViewModel(repository)
}
}
}
}

View File

@ -0,0 +1,11 @@
package de.luh.hci.mi.experiencesampling.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -0,0 +1,70 @@
package de.luh.hci.mi.experiencesampling.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ExperienceSamplingTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,34 @@
package de.luh.hci.mi.experiencesampling.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,45 @@
<resources>
<string name="app_name">ExperienceSampling</string>
<string name="submit">Submit</string>
<string name="context">Context</string>
<string name="channel_id">Experience Sampling</string>
<string name="channel_name">Experience Sampling</string>
<string name="channel_description">This channel sends notifications that request inputting experience samples.</string>
<string name="what_experience">What is your current experience?</string>
<string name="context_1">Mobile Interaktion</string>
<string name="context_2">Programmieren 1</string>
<string name="context_3">Mensa</string>
<string name="thank_you">Thank you!</string>
<string name="context_question">What is your current context?</string>
<string name="enter_experience_sample">Enter Experience Sample</string>
<string name="show_statistics">Show Statistics</string>
<string name="stop_periodic_sampling">Stop Periodic Sampling</string>
<string name="start_periodic_sampling">Start Periodic Sampling</string>
<!-- questions -->
<string name="understanding">Understanding</string>
<string name="understanding_question">How easy to comprehend is the lecture material presented?</string>
<string name="interest">Interest</string>
<string name="interest_question">How interesting is today\'s topic for you?</string>
<string name="lecturer">Lecturer</string>
<string name="lecturer_question">How would you rate the lecturers preseting style?</string>
<string name="slides">Slides</string>
<string name="slides_question">Rate how the slides complement the presented material.</string>
<string name="grade">School Grade</string>
<string name="grade_question">Rate today\'s lecture using school grades</string>
<string name="overall">Overall</string>
<string name="overall_question">Did you enjoy today\'s lecture overall?</string>
<!-- scales -->
<string name="complicated">complicated</string>
<string name="easy">easy</string>
<string name="not_interesting">not interesting</string>
<string name="interesting">interesting</string>
<string name="boring">boring</string>
<string name="exciting">exciting</string>
<string name="obstructive">obstructive</string>
<string name="supportive">supportive</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ExperienceSampling" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,16 @@
package de.luh.hci.mi.experiencesampling
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
build.gradle Executable file
View File

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
}

23
gradle.properties Executable file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Executable file

Binary file not shown.

6
gradle/wrapper/gradle-wrapper.properties vendored Executable file
View File

@ -0,0 +1,6 @@
#Wed Jun 14 13:42:06 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Executable file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle Executable file
View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ExperienceSampling"
include ':app'