mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-13 13:47:47 +02:00
refactor(app): navigation
This commit is contained in:
@ -15,15 +15,9 @@ import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.rhunk.snapenhance.common.ui.AppMaterialTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var sections: Map<EnumSection, Section>
|
||||
private lateinit var navController: NavHostController
|
||||
private lateinit var managerContext: RemoteSideContext
|
||||
|
||||
override fun onPostResume() {
|
||||
super.onPostResume()
|
||||
sections.values.forEach { it.onResumed() }
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (::navController.isInitialized.not()) return
|
||||
@ -40,37 +34,30 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val startDestination = intent.getStringExtra("route")?.let { EnumSection.fromRoute(it) } ?: EnumSection.HOME
|
||||
managerContext = SharedContextHolder.remote(this).apply {
|
||||
activity = this@MainActivity
|
||||
checkForRequirements()
|
||||
}
|
||||
|
||||
sections = EnumSection.entries.associateWith {
|
||||
it.section.java.constructors.first().newInstance() as Section
|
||||
}.onEach { (section, instance) ->
|
||||
with(instance) {
|
||||
enumSection = section
|
||||
context = managerContext
|
||||
init()
|
||||
}
|
||||
}
|
||||
val routes = Routes(managerContext)
|
||||
routes.getRoutes().forEach { it.init() }
|
||||
|
||||
setContent {
|
||||
navController = rememberNavController()
|
||||
val navigation = remember { Navigation(managerContext, sections, navController) }
|
||||
val navigation = remember {
|
||||
Navigation(managerContext, navController, routes.also {
|
||||
it.navController = navController
|
||||
})
|
||||
}
|
||||
val startDestination = remember { intent.getStringExtra("route") ?: routes.home.routeInfo.id }
|
||||
|
||||
AppMaterialTheme {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = { navigation.TopBar() },
|
||||
bottomBar = { navigation.NavBar() },
|
||||
floatingActionButton = { navigation.Fab() }
|
||||
) { innerPadding ->
|
||||
navigation.NavigationHost(
|
||||
innerPadding = innerPadding,
|
||||
startDestination = startDestination
|
||||
)
|
||||
}
|
||||
bottomBar = { navigation.BottomBar() },
|
||||
floatingActionButton = { navigation.FloatingActionButton() }
|
||||
) { innerPadding -> navigation.Content(innerPadding, startDestination) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,64 +10,40 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navigation
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class Navigation(
|
||||
private val context: RemoteSideContext,
|
||||
private val sections: Map<EnumSection, Section>,
|
||||
private val navHostController: NavHostController
|
||||
private val navController: NavHostController,
|
||||
val routes: Routes = Routes(context).also {
|
||||
it.navController = navController
|
||||
}
|
||||
){
|
||||
@Composable
|
||||
fun NavigationHost(
|
||||
startDestination: EnumSection,
|
||||
innerPadding: PaddingValues
|
||||
) {
|
||||
NavHost(
|
||||
navHostController,
|
||||
startDestination = startDestination.route,
|
||||
Modifier.padding(innerPadding),
|
||||
enterTransition = { fadeIn(tween(100)) },
|
||||
exitTransition = { fadeOut(tween(100)) }
|
||||
) {
|
||||
sections.forEach { (_, instance) ->
|
||||
instance.navController = navHostController
|
||||
instance.build(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentSection(navDestination: NavDestination) = sections.firstNotNullOf { (section, instance) ->
|
||||
if (navDestination.hierarchy.any { it.route == section.route }) {
|
||||
instance
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination ?: return
|
||||
val currentSection = getCurrentSection(currentDestination)
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val canGoBack = remember (navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry)?.let {
|
||||
!it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination)
|
||||
} == true }
|
||||
|
||||
TopAppBar(title = {
|
||||
currentSection.Title()
|
||||
routes.getCurrentRoute(navBackStackEntry)?.title?.invoke() ?: Text(text = routes.getCurrentRoute(navBackStackEntry)?.routeInfo?.translatedKey ?: "Unknown Page")
|
||||
}, navigationIcon = {
|
||||
val backButtonAnimation by animateFloatAsState(if (currentSection.canGoBack()) 1f else 0f,
|
||||
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f,
|
||||
label = "backButtonAnimation"
|
||||
)
|
||||
|
||||
@ -78,65 +54,88 @@ class Navigation(
|
||||
.height(48.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { navHostController.popBackStack() }
|
||||
onClick = {
|
||||
if (canGoBack) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}, actions = {
|
||||
currentSection.TopBarActions(this)
|
||||
routes.getCurrentRoute(navBackStackEntry)?.topBarActions?.invoke(this)
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Fab() {
|
||||
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination ?: return
|
||||
val currentSection = getCurrentSection(currentDestination)
|
||||
fun BottomBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.primary } }
|
||||
|
||||
currentSection.FloatingActionButton()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavBar() {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
sections.keys.forEach { section ->
|
||||
fun selected() = currentDestination?.hierarchy?.any { it.route == section.route } == true
|
||||
|
||||
val currentRoute = routes.getCurrentRoute(navBackStackEntry)
|
||||
primaryRoutes.forEach { route ->
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = false,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = section.icon,
|
||||
contentDescription = null
|
||||
)
|
||||
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
|
||||
},
|
||||
|
||||
label = {
|
||||
Text(
|
||||
textAlign = TextAlign.Center,
|
||||
softWrap = false,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.wrapContentWidth(unbounded = true),
|
||||
text = if (selected()) context.translation["manager.routes.${section.route}"] else "",
|
||||
text = if (currentRoute == route) context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] else "",
|
||||
)
|
||||
},
|
||||
selected = selected(),
|
||||
selected = currentRoute == route,
|
||||
onClick = {
|
||||
navHostController.navigate(section.route) {
|
||||
popUpTo(navHostController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
route.navigateReset()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingActionButton() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
routes.getCurrentRoute(navBackStackEntry)?.floatingActionButton?.invoke()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content(paddingValues: PaddingValues, startDestination: String) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
Modifier.padding(paddingValues),
|
||||
enterTransition = { fadeIn(tween(100)) },
|
||||
exitTransition = { fadeOut(tween(100)) }
|
||||
) {
|
||||
routes.getRoutes().filter { it.parentRoute == null }.forEach { route ->
|
||||
val children = routes.getRoutes().filter { it.parentRoute == route }
|
||||
if (children.isEmpty()) {
|
||||
composable(route.routeInfo.id) {
|
||||
route.content.invoke(it)
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
} else {
|
||||
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
|
||||
composable("main_" + route.routeInfo.id) {
|
||||
route.content.invoke(it)
|
||||
}
|
||||
children.forEach { child ->
|
||||
composable(child.routeInfo.id) {
|
||||
child.content.invoke(it)
|
||||
}
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
134
app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
Normal file
134
app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
Normal file
@ -0,0 +1,134 @@
|
||||
package me.rhunk.snapenhance.ui.manager
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DataObject
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Stars
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings
|
||||
import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot
|
||||
|
||||
|
||||
data class RouteInfo(
|
||||
val id: String,
|
||||
val key: String = id,
|
||||
val icon: ImageVector = Icons.Default.Home,
|
||||
val primary: Boolean = false,
|
||||
) {
|
||||
var translatedKey: String? = null
|
||||
val childIds = mutableListOf<String>()
|
||||
}
|
||||
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class Routes(
|
||||
private val context: RemoteSideContext,
|
||||
) {
|
||||
lateinit var navController: NavController
|
||||
private val routes = mutableListOf<Route>()
|
||||
|
||||
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRoot())
|
||||
|
||||
val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRoot())
|
||||
|
||||
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot())
|
||||
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
|
||||
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
|
||||
|
||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
|
||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
||||
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)
|
||||
val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social)
|
||||
|
||||
val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRoot())
|
||||
|
||||
open class Route {
|
||||
open val init: () -> Unit = { }
|
||||
open val title: @Composable (() -> Unit)? = null
|
||||
open val topBarActions: @Composable RowScope.() -> Unit = {}
|
||||
open val floatingActionButton: @Composable () -> Unit = {}
|
||||
open val content: @Composable (NavBackStackEntry) -> Unit = {}
|
||||
open val customComposables: NavGraphBuilder.() -> Unit = {}
|
||||
|
||||
var parentRoute: Route? = null
|
||||
private set
|
||||
|
||||
lateinit var context: RemoteSideContext
|
||||
lateinit var routeInfo: RouteInfo
|
||||
lateinit var routes: Routes
|
||||
|
||||
private fun replaceArguments(id: String, args: Map<String, String>) = args.takeIf { it.isNotEmpty() }?.let {
|
||||
args.entries.fold(id) { acc, (key, value) ->
|
||||
acc.replace("{$key}", value)
|
||||
}
|
||||
} ?: id
|
||||
|
||||
fun navigate(args: MutableMap<String, String>.() -> Unit = {}) {
|
||||
routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() }))
|
||||
}
|
||||
|
||||
fun navigateReset(args: MutableMap<String, String>.() -> Unit = {}) {
|
||||
routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) {
|
||||
popUpTo(routes.navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
|
||||
fun parent(route: Route): Route {
|
||||
assert(route.routeInfo.primary) { "Parent route must be a primary route" }
|
||||
parentRoute = route
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
val currentRoute: Route?
|
||||
get() = routes.firstOrNull { route ->
|
||||
navController.currentBackStackEntry?.destination?.hierarchy?.any { it.route == route.routeInfo.id } ?: false
|
||||
}
|
||||
|
||||
val currentDestination: String?
|
||||
get() = navController.currentBackStackEntry?.destination?.route
|
||||
|
||||
fun getCurrentRoute(navBackStackEntry: NavBackStackEntry?): Route? {
|
||||
if (navBackStackEntry == null) return null
|
||||
|
||||
return navBackStackEntry.destination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
routes.firstOrNull { route ->
|
||||
route.routeInfo.id == destination.route || route.routeInfo.childIds.contains(destination.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRoutes(): List<Route> = routes
|
||||
|
||||
private fun route(routeInfo: RouteInfo, route: Route): Route {
|
||||
route.apply {
|
||||
this.routeInfo = routeInfo
|
||||
routes = this@Routes
|
||||
context = this@Routes.context
|
||||
this.routeInfo.translatedKey = context.translation.getOrNull("manager.routes.${route.routeInfo.key.substringBefore("/")}")
|
||||
}
|
||||
routes.add(route)
|
||||
return route
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
package me.rhunk.snapenhance.ui.manager
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented
|
||||
import me.rhunk.snapenhance.ui.manager.sections.TasksSection
|
||||
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
|
||||
import me.rhunk.snapenhance.ui.manager.sections.home.HomeSection
|
||||
import me.rhunk.snapenhance.ui.manager.sections.scripting.ScriptsSection
|
||||
import me.rhunk.snapenhance.ui.manager.sections.social.SocialSection
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
enum class EnumSection(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
val section: KClass<out Section> = NotImplemented::class
|
||||
) {
|
||||
TASKS(
|
||||
route = "tasks",
|
||||
icon = Icons.Filled.TaskAlt,
|
||||
section = TasksSection::class
|
||||
),
|
||||
FEATURES(
|
||||
route = "features",
|
||||
icon = Icons.Filled.Stars,
|
||||
section = FeaturesSection::class
|
||||
),
|
||||
HOME(
|
||||
route = "home",
|
||||
icon = Icons.Filled.Home,
|
||||
section = HomeSection::class
|
||||
),
|
||||
SOCIAL(
|
||||
route = "social",
|
||||
icon = Icons.Filled.Group,
|
||||
section = SocialSection::class
|
||||
),
|
||||
SCRIPTS(
|
||||
route = "scripts",
|
||||
icon = Icons.Filled.DataObject,
|
||||
section = ScriptsSection::class
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromRoute(route: String): EnumSection {
|
||||
return entries.first { it.route == route }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
open class Section {
|
||||
lateinit var enumSection: EnumSection
|
||||
lateinit var context: RemoteSideContext
|
||||
lateinit var navController: NavController
|
||||
|
||||
val currentRoute get() = navController.currentBackStackEntry?.destination?.route
|
||||
|
||||
open fun init() {}
|
||||
open fun onResumed() {}
|
||||
|
||||
open fun sectionTopBarName(): String = context.translation["manager.routes.${enumSection.route}"]
|
||||
open fun canGoBack(): Boolean = false
|
||||
|
||||
@Composable
|
||||
open fun Title() { Text(text = sectionTopBarName()) }
|
||||
|
||||
@Composable
|
||||
open fun Content() { NotImplemented() }
|
||||
|
||||
@Composable
|
||||
open fun TopBarActions(rowScope: RowScope) {}
|
||||
|
||||
@Composable
|
||||
open fun FloatingActionButton() {}
|
||||
|
||||
open fun build(navGraphBuilder: NavGraphBuilder) {
|
||||
navGraphBuilder.composable(enumSection.route) {
|
||||
Content()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections
|
||||
package me.rhunk.snapenhance.ui.manager.pages
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.border
|
||||
@ -15,11 +15,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -35,13 +35,13 @@ import me.rhunk.snapenhance.task.PendingTaskListener
|
||||
import me.rhunk.snapenhance.task.Task
|
||||
import me.rhunk.snapenhance.task.TaskStatus
|
||||
import me.rhunk.snapenhance.task.TaskType
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class TasksSection : Section() {
|
||||
class TasksRoot : Routes.Route() {
|
||||
private var activeTasks by mutableStateOf(listOf<PendingTask>())
|
||||
private lateinit var recentTasks: MutableList<Task>
|
||||
private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
|
||||
@ -132,9 +132,7 @@ class TasksSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
override fun TopBarActions(rowScope: RowScope) {
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) {
|
||||
@ -386,9 +384,7 @@ class TasksSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
override fun Content() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val scrollState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
recentTasks = remember { mutableStateListOf() }
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.features
|
||||
package me.rhunk.snapenhance.ui.manager.pages.features
|
||||
|
||||
typealias ClickCallback = (Boolean) -> Unit
|
||||
typealias RegisterClickCallback = (ClickCallback) -> ClickCallback
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.features
|
||||
package me.rhunk.snapenhance.ui.manager.pages.features
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@ -30,33 +30,29 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.config.*
|
||||
import me.rhunk.snapenhance.ui.manager.MainActivity
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class FeaturesSection : Section() {
|
||||
class FeaturesRoot : Routes.Route() {
|
||||
private val alertDialogs by lazy { AlertDialogs(context.translation) }
|
||||
|
||||
companion object {
|
||||
const val MAIN_ROUTE = "feature_root"
|
||||
const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}"
|
||||
const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}"
|
||||
}
|
||||
|
||||
|
||||
private var activityLauncherHelper: ActivityLauncherHelper? = null
|
||||
private val featuresRouteName by lazy { context.translation["manager.routes.features"] }
|
||||
|
||||
private lateinit var rememberScaffoldState: BottomSheetScaffoldState
|
||||
|
||||
private val allContainers by lazy {
|
||||
@ -85,24 +81,14 @@ class FeaturesSection : Section() {
|
||||
}
|
||||
|
||||
private fun navigateToMainRoot() {
|
||||
navController.navigate(MAIN_ROUTE, NavOptions.Builder()
|
||||
.setPopUpTo(navController.graph.findStartDestination().id, false)
|
||||
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
|
||||
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
|
||||
.setLaunchSingleTop(true)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun canGoBack() = sectionTopBarName() != featuresRouteName
|
||||
|
||||
override fun sectionTopBarName(): String {
|
||||
navController.currentBackStackEntry?.arguments?.getString("name")?.let { routeName ->
|
||||
val currentContainerPair = allContainers[routeName]
|
||||
return context.translation["${currentContainerPair?.key?.propertyTranslationPath()}.name"]
|
||||
}
|
||||
return featuresRouteName
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
@ -111,17 +97,18 @@ class FeaturesSection : Section() {
|
||||
//open manager if activity launcher is null
|
||||
val intent = Intent(context.androidContext, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.putExtra("route", enumSection.route)
|
||||
intent.putExtra("route", routeInfo.id)
|
||||
context.androidContext.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun build(navGraphBuilder: NavGraphBuilder) {
|
||||
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) {
|
||||
composable(MAIN_ROUTE) {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
Container(context.config.root)
|
||||
}
|
||||
|
||||
override val customComposables: NavGraphBuilder.() -> Unit = {
|
||||
routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE))
|
||||
|
||||
composable(FEATURE_CONTAINER_ROUTE, enterTransition = {
|
||||
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100))
|
||||
}, exitTransition = {
|
||||
@ -146,7 +133,6 @@ class FeaturesSection : Section() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
|
||||
@ -262,7 +248,7 @@ class FeaturesSection : Section() {
|
||||
val container = propertyValue.get() as ConfigContainer
|
||||
|
||||
registerClickCallback {
|
||||
navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
|
||||
routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
|
||||
}
|
||||
|
||||
if (!container.hasGlobalState) return
|
||||
@ -398,10 +384,10 @@ class FeaturesSection : Section() {
|
||||
}
|
||||
currentSearchJob?.cancel()
|
||||
scope.launch {
|
||||
delay(300)
|
||||
navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
|
||||
delay(150)
|
||||
routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
|
||||
.setLaunchSingleTop(true)
|
||||
.setPopUpTo(MAIN_ROUTE, false)
|
||||
.setPopUpTo(routeInfo.id, false)
|
||||
.build()
|
||||
)
|
||||
}.also { currentSearchJob = it }
|
||||
@ -428,13 +414,12 @@ class FeaturesSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TopBarActions(rowScope: RowScope) {
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
|
||||
var showSearchBar by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
if (showSearchBar) {
|
||||
FeatureSearchBar(rowScope, focusRequester)
|
||||
FeatureSearchBar(this, focusRequester)
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
@ -442,7 +427,7 @@ class FeaturesSection : Section() {
|
||||
|
||||
IconButton(onClick = {
|
||||
showSearchBar = showSearchBar.not()
|
||||
if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) {
|
||||
if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) {
|
||||
navigateToMainRoot()
|
||||
}
|
||||
}) {
|
||||
@ -453,7 +438,7 @@ class FeaturesSection : Section() {
|
||||
)
|
||||
}
|
||||
|
||||
if (showSearchBar) return
|
||||
if (showSearchBar) return@topBarActions
|
||||
|
||||
var showExportDropdownMenu by remember { mutableStateOf(false) }
|
||||
var showResetConfirmationDialog by remember { mutableStateOf(false) }
|
||||
@ -504,12 +489,14 @@ class FeaturesSection : Section() {
|
||||
)
|
||||
}
|
||||
|
||||
if (context.activity != null) {
|
||||
IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportDropdownMenu) {
|
||||
DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) {
|
||||
@ -553,8 +540,7 @@ class FeaturesSection : Section() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FloatingActionButton() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val scope = rememberCoroutineScope()
|
||||
FloatingActionButton(
|
||||
onClick = {
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.home
|
||||
package me.rhunk.snapenhance.ui.manager.pages.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ScrollState
|
||||
@ -27,27 +27,74 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.LogReader
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.logger.LogChannel
|
||||
import me.rhunk.snapenhance.common.logger.LogLevel
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
|
||||
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
|
||||
import me.rhunk.snapenhance.ui.util.saveFile
|
||||
|
||||
class HomeSubSection(
|
||||
private val context: RemoteSideContext
|
||||
) {
|
||||
class HomeLogs : Routes.Route() {
|
||||
private val logListState by lazy { LazyListState(0) }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
@Composable
|
||||
fun LogsSection() {
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = {
|
||||
showDropDown = true
|
||||
}) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
context.log.clearLogs()
|
||||
navigate()
|
||||
showDropDown = false
|
||||
}, text = {
|
||||
Text(
|
||||
text = context.translation["manager.sections.home.logs.clear_logs_button"]
|
||||
)
|
||||
})
|
||||
|
||||
DropdownMenuItem(onClick = {
|
||||
activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
|
||||
runCatching {
|
||||
context.log.exportLogsToZip(it)
|
||||
context.longToast("Saved logs to $uri")
|
||||
}.onFailure {
|
||||
context.longToast("Failed to save logs to $uri!")
|
||||
context.log.error("Failed to save logs to $uri!", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
showDropDown = false
|
||||
}, text = {
|
||||
Text(
|
||||
text = context.translation["manager.sections.home.logs.export_logs_button"]
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var lineCount by remember { mutableIntStateOf(0) }
|
||||
@ -172,56 +219,7 @@ class HomeSubSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogsTopBarButtons(activityLauncherHelper: ActivityLauncherHelper, navController: NavController, rowScope: RowScope) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = {
|
||||
showDropDown = true
|
||||
}) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null)
|
||||
}
|
||||
|
||||
rowScope.apply {
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
context.log.clearLogs()
|
||||
navController.navigate(HomeSection.LOGS_SECTION_ROUTE)
|
||||
showDropDown = false
|
||||
}, text = {
|
||||
Text(
|
||||
text = context.translation["manager.sections.home.logs.clear_logs_button"]
|
||||
)
|
||||
})
|
||||
|
||||
DropdownMenuItem(onClick = {
|
||||
activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
|
||||
runCatching {
|
||||
context.log.exportLogsToZip(it)
|
||||
context.longToast("Saved logs to $uri")
|
||||
}.onFailure {
|
||||
context.longToast("Failed to save logs to $uri!")
|
||||
context.log.error("Failed to save logs to $uri!", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
showDropDown = false
|
||||
}, text = {
|
||||
Text(
|
||||
text = context.translation["manager.sections.home.logs.export_logs_button"]
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogsActionButtons() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.home
|
||||
package me.rhunk.snapenhance.ui.manager.pages.home
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@ -11,8 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
@ -25,36 +24,29 @@ import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.R
|
||||
import me.rhunk.snapenhance.common.BuildConfig
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
|
||||
import me.rhunk.snapenhance.ui.manager.data.Updater
|
||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||
import java.util.Locale
|
||||
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
|
||||
|
||||
class HomeSection : Section() {
|
||||
class HomeRoot : Routes.Route() {
|
||||
companion object {
|
||||
val cardMargin = 10.dp
|
||||
const val HOME_ROOT = "home_root"
|
||||
const val LOGS_SECTION_ROUTE = "home_logs"
|
||||
const val SETTINGS_SECTION_ROUTE = "home_settings"
|
||||
}
|
||||
|
||||
private var installationSummary: InstallationSummary? = null
|
||||
private var userLocale: String? = null
|
||||
private val homeSubSection by lazy { HomeSubSection(context) }
|
||||
private var latestUpdate: Updater.LatestRelease? = null
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override fun init() {
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
@ -109,91 +101,28 @@ class HomeSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResumed() {
|
||||
if (!context.mappings.isMappingsLoaded) {
|
||||
context.mappings.init(context.androidContext)
|
||||
}
|
||||
context.coroutineScope.launch {
|
||||
userLocale = context.translation.loadedLocale.getDisplayName(Locale.getDefault())
|
||||
runCatching {
|
||||
installationSummary = context.installationSummary
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
|
||||
}
|
||||
runCatching {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
latestUpdate = Updater.checkForLatestRelease()
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to check for updates: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sectionTopBarName(): String {
|
||||
if (currentRoute == HOME_ROOT) {
|
||||
return ""
|
||||
}
|
||||
return context.translation["manager.routes.$currentRoute"]
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FloatingActionButton() {
|
||||
if (currentRoute == LOGS_SECTION_ROUTE) {
|
||||
homeSubSection.LogsActionButtons()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TopBarActions(rowScope: RowScope) {
|
||||
rowScope.apply {
|
||||
when (currentRoute) {
|
||||
HOME_ROOT -> {
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||
IconButton(onClick = {
|
||||
navController.navigate(LOGS_SECTION_ROUTE)
|
||||
routes.homeLogs.navigate()
|
||||
}) {
|
||||
Icon(Icons.Filled.BugReport, contentDescription = null)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
navController.navigate(SETTINGS_SECTION_ROUTE)
|
||||
routes.settings.navigate()
|
||||
}) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = null)
|
||||
}
|
||||
}
|
||||
LOGS_SECTION_ROUTE -> {
|
||||
homeSubSection.LogsTopBarButtons(activityLauncherHelper, navController, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun build(navGraphBuilder: NavGraphBuilder) {
|
||||
navGraphBuilder.navigation(
|
||||
route = enumSection.route,
|
||||
startDestination = HOME_ROOT
|
||||
) {
|
||||
composable(HOME_ROOT) {
|
||||
Content()
|
||||
}
|
||||
composable(LOGS_SECTION_ROUTE) {
|
||||
homeSubSection.LogsSection()
|
||||
}
|
||||
composable(SETTINGS_SECTION_ROUTE) {
|
||||
SettingsSection(activityLauncherHelper).also { it.context = context }.Content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
override fun Content() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val avenirNextFontFamily = remember {
|
||||
FontFamily(
|
||||
Font(R.font.avenir_next_medium, FontWeight.Medium)
|
||||
)
|
||||
}
|
||||
|
||||
var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(ScrollState(0))
|
||||
@ -312,7 +241,37 @@ class HomeSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
SummaryCards(installationSummary = installationSummary ?: return)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var installationSummary by remember { mutableStateOf(null as InstallationSummary?) }
|
||||
|
||||
fun updateInstallationSummary(scope: CoroutineScope) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
installationSummary = context.installationSummary
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
|
||||
}
|
||||
runCatching {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
latestUpdate = Updater.checkForLatestRelease()
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to check for updates: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
updateInstallationSummary(coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateInstallationSummary(coroutineScope)
|
||||
}
|
||||
|
||||
installationSummary?.let { SummaryCards(installationSummary = it) }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.home
|
||||
package me.rhunk.snapenhance.ui.manager.pages.home
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -15,22 +15,26 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.rhunk.snapenhance.common.action.EnumAction
|
||||
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.setup.Requirements
|
||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.util.saveFile
|
||||
|
||||
class SettingsSection(
|
||||
private val activityLauncherHelper: ActivityLauncherHelper
|
||||
) : Section() {
|
||||
class HomeSettings : Routes.Route() {
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val dialogs by lazy { AlertDialogs(context.translation) }
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowTitle(title: String) {
|
||||
Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
@ -102,10 +106,8 @@ class SettingsSection(
|
||||
) { content(this) }
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.scripting
|
||||
package me.rhunk.snapenhance.ui.manager.pages.scripting
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@ -25,17 +26,17 @@ import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
||||
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
|
||||
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
||||
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||
import me.rhunk.snapenhance.ui.util.chooseFolder
|
||||
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
|
||||
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
|
||||
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
|
||||
|
||||
class ScriptsSection : Section() {
|
||||
class ScriptingRoot : Routes.Route() {
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override fun init() {
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
@ -107,8 +108,7 @@ class ScriptsSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FloatingActionButton() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
@ -160,8 +160,7 @@ class ScriptsSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) }
|
||||
var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@ -289,9 +288,7 @@ class ScriptsSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TopBarActions(rowScope: RowScope) {
|
||||
rowScope.apply {
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {
|
||||
IconButton(onClick = {
|
||||
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||
data = "https://github.com/SnapEnhance/docs".toUri()
|
||||
@ -302,4 +299,3 @@ class ScriptsSection : Section() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.social
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@ -32,7 +32,7 @@ import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
|
||||
class AddFriendDialog(
|
||||
private val context: RemoteSideContext,
|
||||
private val section: SocialSection,
|
||||
private val socialRoot: SocialRoot,
|
||||
) {
|
||||
|
||||
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
|
||||
@ -224,9 +224,7 @@ class AddFriendDialog(
|
||||
} else {
|
||||
context.modDatabase.deleteGroup(group.conversationId)
|
||||
}
|
||||
context.modDatabase.executeAsync {
|
||||
section.onResumed()
|
||||
}
|
||||
socialRoot.updateScopeLists()
|
||||
}
|
||||
}
|
||||
|
||||
@ -251,9 +249,7 @@ class AddFriendDialog(
|
||||
} else {
|
||||
context.modDatabase.deleteFriend(friend.userId)
|
||||
}
|
||||
context.modDatabase.executeAsync {
|
||||
section.onResumed()
|
||||
}
|
||||
socialRoot.updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,277 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.disk.DiskCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.common.data.FileType
|
||||
import me.rhunk.snapenhance.common.data.StoryData
|
||||
import me.rhunk.snapenhance.common.data.download.*
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.core.util.media.PreviewUtils
|
||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class LoggedStories : Routes.Route() {
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
|
||||
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
||||
|
||||
val stories = remember {
|
||||
mutableStateListOf<StoryData>()
|
||||
}
|
||||
val friendInfo = remember {
|
||||
context.modDatabase.getFriendInfo(userId)
|
||||
}
|
||||
val httpClient = remember { OkHttpClient() }
|
||||
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
|
||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||
var coilCacheFile by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
selectedStory?.let { story ->
|
||||
Dialog(onDismissRequest = {
|
||||
selectedStory = null
|
||||
}) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Posted on ${story.postedAt.let {
|
||||
DateFormat.getDateTimeInstance().format(Date(it))
|
||||
}}")
|
||||
Text(text = "Created at ${story.createdAt.let {
|
||||
DateFormat.getDateTimeInstance().format(Date(it))
|
||||
}}")
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Button(onClick = {
|
||||
context.androidContext.externalCacheDir?.let { cacheDir ->
|
||||
val cacheFile = coilCacheFile ?: run {
|
||||
context.shortToast("Failed to get file")
|
||||
return@Button
|
||||
}
|
||||
val targetFile = File(cacheDir, cacheFile.name)
|
||||
cacheFile.copyTo(targetFile, overwrite = true)
|
||||
context.androidContext.startActivity(Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
setDataAndType(
|
||||
FileProvider.getUriForFile(
|
||||
context.androidContext,
|
||||
"me.rhunk.snapenhance.fileprovider",
|
||||
targetFile
|
||||
),
|
||||
FileType.fromFile(targetFile).mimeType
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
}
|
||||
}) {
|
||||
Text(text = "Open")
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
val mediaAuthor = friendInfo?.mutableUsername ?: userId
|
||||
val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16)
|
||||
|
||||
DownloadProcessor(
|
||||
remoteSideContext = context,
|
||||
callback = object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String?) {
|
||||
context.shortToast("Downloaded to $outputPath")
|
||||
}
|
||||
|
||||
override fun onFailure(message: String?, throwable: String?) {
|
||||
context.shortToast("Failed to download $message")
|
||||
}
|
||||
}
|
||||
).enqueue(DownloadRequest(
|
||||
inputMedias = arrayOf(
|
||||
InputMedia(
|
||||
content = story.url,
|
||||
type = DownloadMediaType.REMOTE_MEDIA,
|
||||
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
|
||||
)
|
||||
)
|
||||
), DownloadMetadata(
|
||||
mediaIdentifier = uniqueHash,
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
uniqueHash,
|
||||
MediaDownloadSource.STORY_LOGGER,
|
||||
mediaAuthor,
|
||||
story.createdAt
|
||||
),
|
||||
iconUrl = null,
|
||||
mediaAuthor = friendInfo?.mutableUsername ?: userId,
|
||||
downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation),
|
||||
))
|
||||
}) {
|
||||
Text(text = "Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stories.isEmpty()) {
|
||||
Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(100.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
items(stories) { story ->
|
||||
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
|
||||
|
||||
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
|
||||
runCatching {
|
||||
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
|
||||
|
||||
snapshot.data.toFile().inputStream().use { inputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
|
||||
mediaList[type] = splitInputStream.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
|
||||
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
|
||||
|
||||
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
|
||||
|
||||
overlay?.also {
|
||||
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
|
||||
}
|
||||
|
||||
imageBitmap = bitmap?.asImageBitmap()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(10000L) {
|
||||
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let {
|
||||
openDiskCacheSnapshot(it)
|
||||
it.close()
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val response = httpClient.newCall(Request(
|
||||
url = story.url.toHttpUrl()
|
||||
)).execute()
|
||||
response.body.byteStream().use {
|
||||
val decrypted = story.key?.let { _ ->
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
|
||||
CipherInputStream(it, cipher)
|
||||
} ?: it
|
||||
|
||||
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
|
||||
data.toFile().outputStream().use { fos ->
|
||||
decrypted.copyTo(fos)
|
||||
}
|
||||
commitAndOpenSnapshot()?.use { snapshot ->
|
||||
openDiskCacheSnapshot(snapshot)
|
||||
snapshot.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to load story", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clickable {
|
||||
selectedStory = story
|
||||
coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use {
|
||||
it?.data?.toFile()
|
||||
}
|
||||
}
|
||||
.heightIn(min = 128.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
imageBitmap?.let {
|
||||
Card {
|
||||
Image(
|
||||
bitmap = it,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
LaunchedEffect(Unit) {
|
||||
context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result ->
|
||||
stories.addAll(result.values)
|
||||
result.keys.minOrNull()?.let {
|
||||
lastStoryTimestamp = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,68 +1,90 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.social
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DeleteForever
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.data.MessagingRuleType
|
||||
import me.rhunk.snapenhance.common.data.SocialScope
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.util.BitmojiImage
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class ScopeContent(
|
||||
private val context: RemoteSideContext,
|
||||
private val section: SocialSection,
|
||||
private val navController: NavController,
|
||||
val scope: SocialScope,
|
||||
private val id: String
|
||||
) {
|
||||
class ManageScope: Routes.Route() {
|
||||
private val dialogs by lazy { AlertDialogs(context.translation) }
|
||||
private val translation by lazy { context.translation.getCategory("manager.sections.social") }
|
||||
|
||||
fun deleteScope(coroutineScope: CoroutineScope) {
|
||||
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
|
||||
SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
|
||||
}
|
||||
context.modDatabase.executeAsync {
|
||||
coroutineScope.launch {
|
||||
section.onResumed()
|
||||
navController.popBackStack()
|
||||
routes.navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content() {
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
var deleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (deleteConfirmDialog) {
|
||||
val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) } ?: return@topBarActions
|
||||
val id = navBackStackEntry?.arguments?.getString("id")!!
|
||||
|
||||
Dialog(onDismissRequest = {
|
||||
deleteConfirmDialog = false
|
||||
}) {
|
||||
remember { AlertDialogs(context.translation) }.ConfirmDialog(
|
||||
title = "Are you sure you want to delete this ${scope.key.lowercase()}?",
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
deleteScope(scope, id, coroutineScope); deleteConfirmDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { deleteConfirmDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteForever,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
|
||||
val scope = SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!)
|
||||
val id = navBackStackEntry.arguments?.getString("id")!!
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> Friend()
|
||||
SocialScope.GROUP -> Group()
|
||||
SocialScope.FRIEND -> Friend(id)
|
||||
SocialScope.GROUP -> Group(id)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@ -163,7 +185,7 @@ class ScopeContent(
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
private fun Friend() {
|
||||
private fun Friend(id: String) {
|
||||
//fetch the friend from the database
|
||||
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
|
||||
Text(text = translation["not_found"])
|
||||
@ -208,7 +230,9 @@ class ScopeContent(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
Button(onClick = {
|
||||
navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id))
|
||||
routes.loggedStories.navigate {
|
||||
put("id", id)
|
||||
}
|
||||
}) {
|
||||
Text("Show Logged Stories")
|
||||
}
|
||||
@ -331,7 +355,7 @@ class ScopeContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Group() {
|
||||
private fun Group(id: String) {
|
||||
//fetch the group from the database
|
||||
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
|
||||
Text(text = translation["not_found"])
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.social
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
@ -23,8 +23,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.*
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
|
||||
import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener
|
||||
import me.rhunk.snapenhance.bridge.snapclient.types.Message
|
||||
@ -38,23 +38,21 @@ import me.rhunk.snapenhance.messaging.MessagingConstraints
|
||||
import me.rhunk.snapenhance.messaging.MessagingTask
|
||||
import me.rhunk.snapenhance.messaging.MessagingTaskConstraint
|
||||
import me.rhunk.snapenhance.messaging.MessagingTaskType
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import java.util.SortedMap
|
||||
|
||||
class MessagingPreview(
|
||||
private val context: RemoteSideContext,
|
||||
private val scope: SocialScope,
|
||||
private val scopeId: String
|
||||
) {
|
||||
class MessagingPreview: Routes.Route() {
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
private lateinit var messagingBridge: MessagingBridge
|
||||
private lateinit var previewScrollState: LazyListState
|
||||
|
||||
private val myUserId by lazy { messagingBridge.myUserId }
|
||||
private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") }
|
||||
|
||||
private var conversationId: String? = null
|
||||
private val messages = sortedMapOf<Long, Message>() // server message id => message
|
||||
private var messages = sortedMapOf<Long, Message>()
|
||||
private var messageSize by mutableIntStateOf(0)
|
||||
private var lastMessageId = Long.MAX_VALUE
|
||||
private var conversationId by mutableStateOf<String?>(null)
|
||||
private val selectedMessages = mutableStateListOf<Long>() // client message id
|
||||
|
||||
private fun toggleSelectedMessage(messageId: Long) {
|
||||
@ -172,8 +170,7 @@ class MessagingPreview(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopBarAction() {
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||
var taskSelectionDropdown by remember { mutableStateOf(false) }
|
||||
var selectConstraintsDialog by remember { mutableStateOf(false) }
|
||||
var activeTask by remember { mutableStateOf(null as MessagingTask?) }
|
||||
@ -325,7 +322,11 @@ class MessagingPreview(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationPreview() {
|
||||
private fun ConversationPreview(
|
||||
messages: SortedMap<Long, Message>,
|
||||
messageSize: Int,
|
||||
fetchNewMessages: () -> Unit
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
selectedMessages.clear()
|
||||
@ -393,12 +394,42 @@ class MessagingPreview(
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchNewMessages() {
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding()
|
||||
.size(30.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
|
||||
val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) }
|
||||
val id = remember { navBackStackEntry.arguments?.getString("id")!! }
|
||||
|
||||
previewScrollState = rememberLazyListState()
|
||||
coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
var isBridgeConnected by remember { mutableStateOf(false) }
|
||||
var hasBridgeError by remember { mutableStateOf(false) }
|
||||
|
||||
fun fetchNewMessages() {
|
||||
coroutineScope.launch(Dispatchers.IO) cs@{
|
||||
runCatching {
|
||||
val queriedMessages = messagingBridge.fetchConversationWithMessagesPaginated(
|
||||
conversationId!!,
|
||||
100,
|
||||
20,
|
||||
lastMessageId
|
||||
)
|
||||
|
||||
@ -407,22 +438,26 @@ class MessagingPreview(
|
||||
return@cs
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
messages.putAll(queriedMessages.map { it.serverMessageId to it })
|
||||
messageSize = messages.size
|
||||
if (queriedMessages.isNotEmpty()) {
|
||||
lastMessageId = queriedMessages.first().clientMessageId
|
||||
delay(20)
|
||||
previewScrollState.scrollToItem(queriedMessages.size - 1)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch messages", it)
|
||||
context.shortToast("Failed to fetch messages: ${it.message}")
|
||||
}
|
||||
context.log.verbose("fetched ${messages.size} messages")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMessagingBridgeReady() {
|
||||
fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) {
|
||||
context.log.verbose("onMessagingBridgeReady: $scope $scopeId")
|
||||
|
||||
runCatching {
|
||||
messagingBridge = context.bridgeService!!.messagingBridge!!
|
||||
conversationId = if (scope == SocialScope.FRIEND) messagingBridge.getOneToOneConversationId(scopeId) else scopeId
|
||||
@ -430,7 +465,7 @@ class MessagingPreview(
|
||||
context.longToast("Failed to fetch conversation id")
|
||||
return
|
||||
}
|
||||
if (!messagingBridge.isSessionStarted) {
|
||||
if (runCatching { !messagingBridge.isSessionStarted }.getOrDefault(true)) {
|
||||
context.androidContext.packageManager.getLaunchIntentForPackage(
|
||||
Constants.SNAPCHAT_PACKAGE_NAME
|
||||
)?.let {
|
||||
@ -453,39 +488,14 @@ class MessagingPreview(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding()
|
||||
.size(30.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content() {
|
||||
previewScrollState = rememberLazyListState()
|
||||
coroutineScope = rememberCoroutineScope()
|
||||
var isBridgeConnected by remember { mutableStateOf(false) }
|
||||
var hasBridgeError by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
messages.clear()
|
||||
messageSize = 0
|
||||
conversationId = null
|
||||
|
||||
isBridgeConnected = context.hasMessagingBridge()
|
||||
if (isBridgeConnected) {
|
||||
onMessagingBridgeReady()
|
||||
onMessagingBridgeReady(scope, id)
|
||||
} else {
|
||||
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
@ -496,7 +506,7 @@ class MessagingPreview(
|
||||
delay(100)
|
||||
}
|
||||
isBridgeConnected = true
|
||||
onMessagingBridgeReady()
|
||||
onMessagingBridgeReady(scope, id)
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
if (it != null) {
|
||||
@ -506,6 +516,10 @@ class MessagingPreview(
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (hasBridgeError) {
|
||||
Text("Failed to connect to Snapchat through bridge service")
|
||||
}
|
||||
@ -515,7 +529,7 @@ class MessagingPreview(
|
||||
}
|
||||
|
||||
if (isBridgeConnected && !hasBridgeError) {
|
||||
ConversationPreview()
|
||||
ConversationPreview(messages, messageSize, ::fetchNewMessages)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.social
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -10,7 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.DeleteForever
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -22,10 +21,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -34,119 +30,25 @@ import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
import me.rhunk.snapenhance.common.data.SocialScope
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.BitmojiImage
|
||||
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
|
||||
|
||||
class SocialSection : Section() {
|
||||
private lateinit var friendList: List<MessagingFriendInfo>
|
||||
private lateinit var groupList: List<MessagingGroupInfo>
|
||||
class SocialRoot : Routes.Route() {
|
||||
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
companion object {
|
||||
const val MAIN_ROUTE = "social_route"
|
||||
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}"
|
||||
const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}"
|
||||
fun updateScopeLists() {
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
friendList = context.modDatabase.getFriends(descOrder = true)
|
||||
groupList = context.modDatabase.getGroups()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentScopeContent: ScopeContent? = null
|
||||
private var currentMessagingPreview by mutableStateOf(null as MessagingPreview?)
|
||||
|
||||
private val addFriendDialog by lazy {
|
||||
AddFriendDialog(context, this)
|
||||
}
|
||||
|
||||
//FIXME: don't reload the entire list when a friend is added/deleted
|
||||
override fun onResumed() {
|
||||
friendList = context.modDatabase.getFriends(descOrder = true)
|
||||
groupList = context.modDatabase.getGroups()
|
||||
}
|
||||
|
||||
override fun canGoBack() = currentRoute != MAIN_ROUTE
|
||||
|
||||
override fun build(navGraphBuilder: NavGraphBuilder) {
|
||||
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) {
|
||||
composable(MAIN_ROUTE) {
|
||||
Content()
|
||||
}
|
||||
|
||||
SocialScope.entries.forEach { scope ->
|
||||
composable(scope.tabRoute) {
|
||||
val id = it.arguments?.getString("id") ?: return@composable
|
||||
remember {
|
||||
ScopeContent(
|
||||
context,
|
||||
this@SocialSection,
|
||||
navController,
|
||||
scope,
|
||||
id
|
||||
).also { tab ->
|
||||
currentScopeContent = tab
|
||||
}
|
||||
}.Content()
|
||||
}
|
||||
}
|
||||
|
||||
composable(LOGGED_STORIES_ROUTE) {
|
||||
val userId = it.arguments?.getString("userId") ?: return@composable
|
||||
LoggedStories(context, userId)
|
||||
}
|
||||
|
||||
composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
|
||||
val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
|
||||
val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable
|
||||
val messagePreview = remember {
|
||||
MessagingPreview(context, SocialScope.getByName(scope), id)
|
||||
}
|
||||
LaunchedEffect(key1 = id) {
|
||||
currentMessagingPreview = messagePreview
|
||||
}
|
||||
messagePreview.Content()
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
currentMessagingPreview = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TopBarActions(rowScope: RowScope) {
|
||||
var deleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (deleteConfirmDialog) {
|
||||
currentScopeContent?.let { scopeContent ->
|
||||
Dialog(onDismissRequest = { deleteConfirmDialog = false }) {
|
||||
remember { AlertDialogs(context.translation) }.ConfirmDialog(
|
||||
title = "Are you sure you want to delete this ${scopeContent.scope.key.lowercase()}?",
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
scopeContent.deleteScope(coroutineScope); deleteConfirmDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRoute == MESSAGING_PREVIEW_ROUTE) {
|
||||
currentMessagingPreview?.TopBarAction()
|
||||
}
|
||||
|
||||
if (currentRoute == SocialScope.FRIEND.tabRoute || currentRoute == SocialScope.GROUP.tabRoute) {
|
||||
IconButton(
|
||||
onClick = { deleteConfirmDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteForever,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(scope: SocialScope) {
|
||||
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
|
||||
@ -186,9 +88,10 @@ class SocialSection : Section() {
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.clickable {
|
||||
navController.navigate(
|
||||
scope.tabRoute.replace("{id}", id)
|
||||
)
|
||||
routes.manageScope.navigate {
|
||||
put("id", id)
|
||||
put("scope", scope.key)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
@ -275,9 +178,10 @@ class SocialSection : Section() {
|
||||
}
|
||||
|
||||
FilledIconButton(onClick = {
|
||||
navController.navigate(
|
||||
MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key)
|
||||
)
|
||||
routes.messagingPreview.navigate {
|
||||
put("id", id)
|
||||
put("scope", scope.key)
|
||||
}
|
||||
}) {
|
||||
Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null)
|
||||
}
|
||||
@ -287,10 +191,8 @@ class SocialSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val titles = listOf("Friends", "Groups")
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
@ -302,6 +204,10 @@ class SocialSection : Section() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateScopeLists()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
@ -1,4 +0,0 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections
|
||||
|
||||
class DebugSection {
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import me.rhunk.snapenhance.ui.manager.Section
|
||||
|
||||
class NotImplemented : Section() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = "Not implemented yet!")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.features
|
||||
|
||||
class PickLocation {
|
||||
}
|
@ -1,276 +0,0 @@
|
||||
package me.rhunk.snapenhance.ui.manager.sections.social
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.disk.DiskCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.common.data.FileType
|
||||
import me.rhunk.snapenhance.common.data.StoryData
|
||||
import me.rhunk.snapenhance.common.data.download.*
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.core.util.media.PreviewUtils
|
||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun LoggedStories(
|
||||
context: RemoteSideContext,
|
||||
userId: String
|
||||
) {
|
||||
val stories = remember {
|
||||
mutableStateListOf<StoryData>()
|
||||
}
|
||||
val friendInfo = remember {
|
||||
context.modDatabase.getFriendInfo(userId)
|
||||
}
|
||||
val httpClient = remember { OkHttpClient() }
|
||||
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
|
||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||
var coilCacheFile by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
selectedStory?.let { story ->
|
||||
Dialog(onDismissRequest = {
|
||||
selectedStory = null
|
||||
}) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Posted on ${story.postedAt.let {
|
||||
DateFormat.getDateTimeInstance().format(Date(it))
|
||||
}}")
|
||||
Text(text = "Created at ${story.createdAt.let {
|
||||
DateFormat.getDateTimeInstance().format(Date(it))
|
||||
}}")
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Button(onClick = {
|
||||
context.androidContext.externalCacheDir?.let { cacheDir ->
|
||||
val cacheFile = coilCacheFile ?: run {
|
||||
context.shortToast("Failed to get file")
|
||||
return@Button
|
||||
}
|
||||
val targetFile = File(cacheDir, cacheFile.name)
|
||||
cacheFile.copyTo(targetFile, overwrite = true)
|
||||
context.androidContext.startActivity(Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
setDataAndType(
|
||||
FileProvider.getUriForFile(
|
||||
context.androidContext,
|
||||
"me.rhunk.snapenhance.fileprovider",
|
||||
targetFile
|
||||
),
|
||||
FileType.fromFile(targetFile).mimeType
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
}
|
||||
}) {
|
||||
Text(text = "Open")
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
val mediaAuthor = friendInfo?.mutableUsername ?: userId
|
||||
val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16)
|
||||
|
||||
DownloadProcessor(
|
||||
remoteSideContext = context,
|
||||
callback = object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String?) {
|
||||
context.shortToast("Downloaded to $outputPath")
|
||||
}
|
||||
|
||||
override fun onFailure(message: String?, throwable: String?) {
|
||||
context.shortToast("Failed to download $message")
|
||||
}
|
||||
}
|
||||
).enqueue(DownloadRequest(
|
||||
inputMedias = arrayOf(
|
||||
InputMedia(
|
||||
content = story.url,
|
||||
type = DownloadMediaType.REMOTE_MEDIA,
|
||||
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
|
||||
)
|
||||
)
|
||||
), DownloadMetadata(
|
||||
mediaIdentifier = uniqueHash,
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
uniqueHash,
|
||||
MediaDownloadSource.STORY_LOGGER,
|
||||
mediaAuthor,
|
||||
story.createdAt
|
||||
),
|
||||
iconUrl = null,
|
||||
mediaAuthor = friendInfo?.mutableUsername ?: userId,
|
||||
downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation),
|
||||
))
|
||||
}) {
|
||||
Text(text = "Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stories.isEmpty()) {
|
||||
Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(100.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
items(stories) { story ->
|
||||
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
|
||||
|
||||
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
|
||||
runCatching {
|
||||
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
|
||||
|
||||
snapshot.data.toFile().inputStream().use { inputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
|
||||
mediaList[type] = splitInputStream.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
|
||||
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
|
||||
|
||||
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
|
||||
|
||||
overlay?.also {
|
||||
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
|
||||
}
|
||||
|
||||
imageBitmap = bitmap?.asImageBitmap()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(10000L) {
|
||||
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let {
|
||||
openDiskCacheSnapshot(it)
|
||||
it.close()
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val response = httpClient.newCall(Request(
|
||||
url = story.url.toHttpUrl()
|
||||
)).execute()
|
||||
response.body.byteStream().use {
|
||||
val decrypted = story.key?.let { _ ->
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
|
||||
CipherInputStream(it, cipher)
|
||||
} ?: it
|
||||
|
||||
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
|
||||
data.toFile().outputStream().use { fos ->
|
||||
decrypted.copyTo(fos)
|
||||
}
|
||||
commitAndOpenSnapshot()?.use { snapshot ->
|
||||
openDiskCacheSnapshot(snapshot)
|
||||
snapshot.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to load story", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clickable {
|
||||
selectedStory = story
|
||||
coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use {
|
||||
it?.data?.toFile()
|
||||
}
|
||||
}
|
||||
.heightIn(min = 128.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
imageBitmap?.let {
|
||||
Card {
|
||||
Image(
|
||||
bitmap = it,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
LaunchedEffect(Unit) {
|
||||
context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result ->
|
||||
stories.addAll(result.values)
|
||||
result.keys.minOrNull()?.let {
|
||||
lastStoryTimestamp = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -25,9 +25,7 @@ import com.arthenica.ffmpegkit.Packages.getPackageName
|
||||
import me.rhunk.snapenhance.R
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.ui.createComposeView
|
||||
import me.rhunk.snapenhance.ui.manager.EnumSection
|
||||
import me.rhunk.snapenhance.ui.manager.Navigation
|
||||
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
|
||||
|
||||
|
||||
class SettingsOverlay(
|
||||
@ -55,26 +53,15 @@ class SettingsOverlay(
|
||||
dismissCallback = { navHostController.popBackStack() }
|
||||
}
|
||||
|
||||
val navigation = remember {
|
||||
Navigation(
|
||||
context,
|
||||
mapOf(
|
||||
EnumSection.FEATURES to FeaturesSection().apply {
|
||||
enumSection = EnumSection.FEATURES
|
||||
context = this@SettingsOverlay.context
|
||||
}
|
||||
),
|
||||
navHostController
|
||||
)
|
||||
}
|
||||
val navigation = remember { Navigation(context, navHostController) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = { navigation.TopBar() }
|
||||
) { innerPadding ->
|
||||
navigation.NavigationHost(
|
||||
startDestination = EnumSection.FEATURES,
|
||||
innerPadding = innerPadding
|
||||
navigation.Content(
|
||||
innerPadding,
|
||||
startDestination = navigation.routes.features.routeInfo.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,8 @@
|
||||
"home_settings": "Settings",
|
||||
"home_logs": "Logs",
|
||||
"social": "Social",
|
||||
"manage_scope": "Manage Scope",
|
||||
"messaging_preview": "Preview",
|
||||
"scripts": "Scripts"
|
||||
},
|
||||
"sections": {
|
||||
|
@ -110,7 +110,6 @@ class BridgeClient(
|
||||
return runCatching {
|
||||
block()
|
||||
}.getOrElse {
|
||||
context.log.error("failed to call service", it)
|
||||
if (it is DeadObjectException) {
|
||||
context.softRestartApp()
|
||||
}
|
||||
|
Reference in New Issue
Block a user