EditRule.kt (21761B) - raw
1 package me.rhunk.snapenhance.ui.manager.pages.tracker 2 3 import androidx.compose.foundation.clickable 4 import androidx.compose.foundation.layout.* 5 import androidx.compose.foundation.lazy.LazyColumn 6 import androidx.compose.foundation.lazy.items 7 import androidx.compose.foundation.rememberScrollState 8 import androidx.compose.foundation.verticalScroll 9 import androidx.compose.material.icons.Icons 10 import androidx.compose.material.icons.filled.* 11 import androidx.compose.material3.* 12 import androidx.compose.runtime.* 13 import androidx.compose.ui.Alignment 14 import androidx.compose.ui.Modifier 15 import androidx.compose.ui.draw.clip 16 import androidx.compose.ui.graphics.Color 17 import androidx.compose.ui.text.TextStyle 18 import androidx.compose.ui.text.font.FontWeight 19 import androidx.compose.ui.text.style.TextAlign 20 import androidx.compose.ui.text.style.TextOverflow 21 import androidx.compose.ui.unit.dp 22 import androidx.compose.ui.unit.sp 23 import androidx.navigation.NavBackStackEntry 24 import me.rhunk.snapenhance.common.data.* 25 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState 26 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList 27 import me.rhunk.snapenhance.storage.* 28 import me.rhunk.snapenhance.ui.manager.Routes 29 import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog 30 31 @Composable 32 fun ActionCheckbox( 33 text: String, 34 checked: MutableState<Boolean>, 35 onChanged: (Boolean) -> Unit = {} 36 ) { 37 Row( 38 modifier = Modifier.clickable { 39 checked.value = !checked.value 40 onChanged(checked.value) 41 }, 42 horizontalArrangement = Arrangement.spacedBy(2.dp), 43 verticalAlignment = Alignment.CenterVertically 44 ) { 45 Checkbox( 46 modifier = Modifier.size(30.dp), 47 checked = checked.value, 48 onCheckedChange = { 49 checked.value = it 50 onChanged(it) 51 } 52 ) 53 Text(text, fontSize = 12.sp) 54 } 55 } 56 57 58 @Composable 59 fun ConditionCheckboxes( 60 params: TrackerRuleActionParams 61 ) { 62 ActionCheckbox(text = "Only when I'm inside conversation", checked = remember { mutableStateOf(params.onlyInsideConversation) }, onChanged = { params.onlyInsideConversation = it }) 63 ActionCheckbox(text = "Only when I'm outside conversation", checked = remember { mutableStateOf(params.onlyOutsideConversation) }, onChanged = { params.onlyOutsideConversation = it }) 64 ActionCheckbox(text = "Only when Snapchat is active", checked = remember { mutableStateOf(params.onlyWhenAppActive) }, onChanged = { params.onlyWhenAppActive = it }) 65 ActionCheckbox(text = "Only when Snapchat is inactive", checked = remember { mutableStateOf(params.onlyWhenAppInactive) }, onChanged = { params.onlyWhenAppInactive = it }) 66 ActionCheckbox(text = "No notification when Snapchat is active", checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) }, onChanged = { params.noPushNotificationWhenAppActive = it }) 67 } 68 69 class EditRule : Routes.Route() { 70 private val fab = mutableStateOf<@Composable (() -> Unit)?>(null) 71 72 // persistent add event state 73 private var currentEventType by mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key) 74 private var addEventActions by mutableStateOf(emptySet<TrackerRuleAction>()) 75 private val addEventActionParams by mutableStateOf(TrackerRuleActionParams()) 76 77 override val floatingActionButton: @Composable () -> Unit = { 78 fab.value?.invoke() 79 } 80 81 @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) 82 override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry -> 83 val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull() 84 85 val events = rememberAsyncMutableStateList(defaultValue = emptyList()) { 86 currentRuleId?.let { ruleId -> 87 context.database.getTrackerEvents(ruleId) 88 } ?: emptyList() 89 } 90 var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) } 91 val scopes = rememberAsyncMutableStateList(defaultValue = emptyList()) { 92 currentRuleId?.let { ruleId -> 93 context.database.getRuleTrackerScopes(ruleId).also { 94 currentScopeType = if (it.isEmpty()) { 95 TrackerScopeType.WHITELIST 96 } else { 97 it.values.first() 98 } 99 }.map { it.key } 100 } ?: emptyList() 101 } 102 val ruleName = rememberAsyncMutableState(defaultValue = "", keys = arrayOf(currentRuleId)) { 103 currentRuleId?.let { ruleId -> 104 context.database.getTrackerRule(ruleId)?.name ?: "Custom Rule" 105 } ?: "Custom Rule" 106 } 107 108 LaunchedEffect(Unit) { 109 fab.value = { 110 var deleteConfirmation by remember { mutableStateOf(false) } 111 112 if (deleteConfirmation) { 113 AlertDialog( 114 onDismissRequest = { deleteConfirmation = false }, 115 title = { Text("Delete Rule") }, 116 text = { Text("Are you sure you want to delete this rule?") }, 117 confirmButton = { 118 Button( 119 onClick = { 120 if (currentRuleId != null) { 121 context.database.deleteTrackerRule(currentRuleId) 122 } 123 routes.navController.popBackStack() 124 } 125 ) { 126 Text("Delete") 127 } 128 }, 129 dismissButton = { 130 Button( 131 onClick = { deleteConfirmation = false } 132 ) { 133 Text("Cancel") 134 } 135 } 136 ) 137 } 138 139 Column( 140 modifier = Modifier.fillMaxWidth(), 141 verticalArrangement = Arrangement.spacedBy(6.dp), 142 horizontalAlignment = Alignment.End 143 ) { 144 ExtendedFloatingActionButton( 145 onClick = { 146 val ruleId = currentRuleId ?: context.database.newTrackerRule() 147 events.forEach { event -> 148 context.database.addOrUpdateTrackerRuleEvent( 149 event.id.takeIf { it > -1 }, 150 ruleId, 151 event.eventType, 152 event.params, 153 event.actions 154 ) 155 } 156 context.database.setTrackerRuleName(ruleId, ruleName.value.trim()) 157 context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes) 158 routes.navController.popBackStack() 159 }, 160 text = { Text("Save Rule") }, 161 icon = { Icon(Icons.Default.Save, contentDescription = "Save Rule") } 162 ) 163 164 if (currentRuleId != null) { 165 ExtendedFloatingActionButton( 166 containerColor = MaterialTheme.colorScheme.error, 167 onClick = { deleteConfirmation = true }, 168 text = { Text("Delete Rule") }, 169 icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete Rule") } 170 ) 171 } 172 } 173 } 174 } 175 176 DisposableEffect(Unit) { 177 onDispose { fab.value = null } 178 } 179 180 LazyColumn( 181 verticalArrangement = Arrangement.spacedBy(2.dp), 182 ) { 183 item { 184 TextField( 185 value = ruleName.value, 186 onValueChange = { 187 ruleName.value = it 188 }, 189 singleLine = true, 190 placeholder = { 191 Text( 192 "Rule Name", 193 fontSize = 18.sp, 194 modifier = Modifier.fillMaxWidth(), 195 textAlign = TextAlign.Center 196 ) 197 }, 198 modifier = Modifier.fillMaxWidth(), 199 colors = TextFieldDefaults.colors( 200 focusedContainerColor = Color.Transparent, 201 unfocusedContainerColor = Color.Transparent 202 ), 203 textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) 204 ) 205 } 206 207 208 item { 209 Column( 210 modifier = Modifier.fillMaxWidth(), 211 ){ 212 Text("Scope", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) 213 214 var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) } 215 216 val friendDialogActions = remember { 217 AddFriendDialog.Actions( 218 onFriendState = { friend, state -> 219 if (state) { 220 scopes.add(friend.userId) 221 } else { 222 scopes.remove(friend.userId) 223 } 224 }, 225 onGroupState = { group, state -> 226 if (state) { 227 scopes.add(group.conversationId) 228 } else { 229 scopes.remove(group.conversationId) 230 } 231 }, 232 getFriendState = { friend -> 233 friend.userId in scopes 234 }, 235 getGroupState = { group -> 236 group.conversationId in scopes 237 } 238 ) 239 } 240 241 Box(modifier = Modifier.clickable { scopes.clear() }) { 242 Row( 243 modifier = Modifier 244 .fillMaxWidth() 245 .padding(10.dp), 246 horizontalArrangement = Arrangement.spacedBy(4.dp), 247 verticalAlignment = Alignment.CenterVertically, 248 ) { 249 RadioButton(selected = scopes.isEmpty(), onClick = null) 250 Text("All Friends/Groups") 251 } 252 } 253 254 Box(modifier = Modifier.clickable { 255 currentScopeType = TrackerScopeType.WHITELIST 256 addFriendDialog = AddFriendDialog( 257 context, 258 friendDialogActions, 259 pinnedIds = scopes, 260 ) 261 }) { 262 Row( 263 modifier = Modifier 264 .fillMaxWidth() 265 .padding(10.dp), 266 horizontalArrangement = Arrangement.spacedBy(4.dp), 267 verticalAlignment = Alignment.CenterVertically, 268 ) { 269 RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.WHITELIST, onClick = null) 270 Text("No one except " + if (currentScopeType == TrackerScopeType.WHITELIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...") 271 } 272 } 273 274 Box(modifier = Modifier.clickable { 275 currentScopeType = TrackerScopeType.BLACKLIST 276 addFriendDialog = AddFriendDialog( 277 context, 278 friendDialogActions, 279 pinnedIds = scopes, 280 ) 281 }) { 282 Row( 283 modifier = Modifier 284 .fillMaxWidth() 285 .padding(10.dp), 286 horizontalArrangement = Arrangement.spacedBy(4.dp), 287 verticalAlignment = Alignment.CenterVertically, 288 ) { 289 RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.BLACKLIST, onClick = null) 290 Text("Everyone except " + if (currentScopeType == TrackerScopeType.BLACKLIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...") 291 } 292 } 293 294 addFriendDialog?.Content { 295 addFriendDialog = null 296 } 297 } 298 299 var addEventDialog by remember { mutableStateOf(false) } 300 val showDropdown = remember { mutableStateOf(false) } 301 302 Row( 303 modifier = Modifier.fillMaxWidth(), 304 horizontalArrangement = Arrangement.SpaceBetween, 305 verticalAlignment = Alignment.CenterVertically 306 ) { 307 Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) 308 IconButton(onClick = { addEventDialog = true }, modifier = Modifier.padding(8.dp)) { 309 Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(32.dp)) 310 } 311 } 312 313 if (addEventDialog) { 314 AlertDialog( 315 onDismissRequest = { addEventDialog = false }, 316 title = { Text("Add Event", fontSize = 20.sp, fontWeight = FontWeight.Bold) }, 317 text = { 318 Column( 319 modifier = Modifier 320 .verticalScroll(rememberScrollState()) 321 .padding(16.dp), 322 verticalArrangement = Arrangement.spacedBy(4.dp) 323 ) { 324 Row( 325 modifier = Modifier 326 .fillMaxWidth() 327 .padding(2.dp), 328 horizontalArrangement = Arrangement.SpaceBetween, 329 verticalAlignment = Alignment.CenterVertically 330 ) { 331 Text("Type", fontSize = 14.sp, fontWeight = FontWeight.Bold) 332 ExposedDropdownMenuBox(expanded = showDropdown.value, onExpandedChange = { showDropdown.value = it }) { 333 ElevatedButton( 334 onClick = { showDropdown.value = true }, 335 modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) 336 ) { 337 Text(currentEventType, overflow = TextOverflow.Ellipsis, maxLines = 1) 338 } 339 DropdownMenu(expanded = showDropdown.value, onDismissRequest = { showDropdown.value = false }) { 340 TrackerEventType.entries.forEach { eventType -> 341 DropdownMenuItem(onClick = { 342 currentEventType = eventType.key 343 showDropdown.value = false 344 }, text = { 345 Text(eventType.key) 346 }) 347 } 348 } 349 } 350 } 351 352 Text("Triggers", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) 353 354 FlowRow( 355 modifier = Modifier 356 .fillMaxWidth() 357 .padding(2.dp), 358 ) { 359 TrackerRuleAction.entries.forEach { action -> 360 ActionCheckbox(action.name, checked = remember { mutableStateOf(addEventActions.contains(action)) }) { 361 if (it) { 362 addEventActions += action 363 } else { 364 addEventActions -= action 365 } 366 } 367 } 368 } 369 370 Text("Conditions", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) 371 ConditionCheckboxes(addEventActionParams) 372 } 373 }, 374 confirmButton = { 375 Button( 376 onClick = { 377 events.add(0, TrackerRuleEvent(-1, true, currentEventType, addEventActionParams.copy(), addEventActions.toList())) 378 addEventDialog = false 379 } 380 ) { 381 Text("Add") 382 } 383 } 384 ) 385 } 386 } 387 388 item { 389 if (events.isEmpty()) { 390 Text("No events", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier 391 .padding(10.dp) 392 .fillMaxWidth(), textAlign = TextAlign.Center) 393 } 394 } 395 396 items(events) { event -> 397 var expanded by remember { mutableStateOf(false) } 398 399 ElevatedCard( 400 modifier = Modifier 401 .fillMaxWidth() 402 .clip(MaterialTheme.shapes.medium) 403 .padding(4.dp), 404 onClick = { expanded = !expanded } 405 ) { 406 Column { 407 Row( 408 modifier = Modifier 409 .fillMaxWidth() 410 .padding(10.dp), 411 horizontalArrangement = Arrangement.SpaceBetween, 412 verticalAlignment = Alignment.CenterVertically 413 ) { 414 Row( 415 modifier = Modifier.weight(1f, fill = false), 416 horizontalArrangement = Arrangement.spacedBy(6.dp), 417 verticalAlignment = Alignment.CenterVertically, 418 ) { 419 Icon( 420 if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, 421 contentDescription = null, 422 modifier = Modifier.size(24.dp) 423 ) 424 Column { 425 Text(event.eventType, lineHeight = 20.sp, fontSize = 18.sp, fontWeight = FontWeight.Bold) 426 Text(text = event.actions.joinToString(", ") { it.name }, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 14.sp) 427 } 428 } 429 OutlinedIconButton( 430 onClick = { 431 if (event.id > -1) { 432 context.database.deleteTrackerRuleEvent(event.id) 433 } 434 events.remove(event) 435 } 436 ) { 437 Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") 438 } 439 } 440 if (expanded) { 441 Column( 442 modifier = Modifier.padding(10.dp) 443 ) { 444 ConditionCheckboxes(event.params) 445 } 446 } 447 } 448 } 449 } 450 451 item { 452 Spacer(modifier = Modifier.height(140.dp)) 453 } 454 } 455 } 456 } 457