Files
Matewave/app/src/main/java/cc/skysparkle/matewave/MainActivity.kt
T
Claude 6ac5a582db Add app source for F-Droid release (v1.0.43)
Adds the full Android source tree with unit tests, fastlane store
metadata and the Gradle wrapper. The archive shipped without
gradle-wrapper.jar, so the official Gradle 9.7.1 wrapper jar is
included to make ./gradlew work from a fresh clone. .gitignore now
also excludes build output and keystores. README gains build
instructions and an updated structure tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-06 18:23:20 +00:00

358 lines
15 KiB
Kotlin

package cc.skysparkle.matewave
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import cc.skysparkle.matewave.audio.SoundManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.network.TransportType
import cc.skysparkle.matewave.ui.screens.AccountScreen
import cc.skysparkle.matewave.ui.screens.ConnectionScreen
import cc.skysparkle.matewave.ui.screens.FriendsScreen
import cc.skysparkle.matewave.ui.screens.GameScreen
import cc.skysparkle.matewave.ui.screens.LicensesScreen
import cc.skysparkle.matewave.ui.screens.MainMenuScreen
import cc.skysparkle.matewave.ui.screens.AppearanceScreen
import cc.skysparkle.matewave.ui.screens.PermissionsScreen
import cc.skysparkle.matewave.ui.screens.PuzzleSolveScreen
import cc.skysparkle.matewave.ui.screens.SettingsScreen
import cc.skysparkle.matewave.ui.screens.ShareApkScreen
import cc.skysparkle.matewave.ui.screens.VsAiSetupScreen
import cc.skysparkle.matewave.ui.theme.MatewaveTheme
import cc.skysparkle.matewave.viewmodel.GameViewModel
import cc.skysparkle.matewave.presence.PresenceHub
class MainActivity : ComponentActivity() {
/** Asks for the highest refresh rate at the current resolution, for smoother animations. */
private fun enableHighRefreshRate() {
runCatching {
val screen: android.view.Display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay
}
val current = screen.mode
val best = screen.supportedModes
.filter { it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight }
.maxByOrNull { it.refreshRate } ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = best.modeId }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The app has one dark look: light system bar icons over the background photo.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
enableHighRefreshRate()
SoundManager.init(this)
val appContext = applicationContext
kotlin.concurrent.thread(name = "warmup", isDaemon = true) {
runCatching { cc.skysparkle.matewave.data.FriendsStore.preload() }
runCatching { cc.skysparkle.matewave.puzzles.PuzzleRepository.load(appContext) }
}
setContent {
MatewaveTheme {
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize()
) {
cc.skysparkle.matewave.ui.components.AppBackgroundImage()
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier
.fillMaxSize()
.background(cc.skysparkle.matewave.ui.theme.PhotoScrim)
)
AppNavHost()
}
}
}
}
override fun onResume() {
super.onResume()
SoundManager.onAppResumed()
}
override fun onPause() {
super.onPause()
SoundManager.onAppPaused()
}
}
@Composable
private fun AppNavHost() {
val navController = rememberNavController()
val viewModel: GameViewModel = viewModel()
val studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel = viewModel()
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.init(context) }
// Presence is shared with the background service, so the lists stay the same in both.
val presence = remember { PresenceHub.get(context) }
androidx.compose.runtime.DisposableEffect(Unit) {
if (cc.skysparkle.matewave.presence.PresenceSettings(context).backgroundEnabled) {
cc.skysparkle.matewave.presence.PresenceService.start(context)
}
PresenceHub.acquire(context)
onDispose { PresenceHub.release(context) }
}
NavHost(
navController = navController,
startDestination = "menu",
enterTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
else sharedAxisIn(forward = true)
},
exitTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
else sharedAxisOut(forward = true)
},
popEnterTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
else sharedAxisIn(forward = false)
},
popExitTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
else sharedAxisOut(forward = false)
}
) {
composable("menu") {
MainMenuScreen(
viewModel = viewModel,
studyViewModel = studyViewModel,
onContinueGame = {
if (viewModel.resumeVsAi()) navController.navigate("game")
},
onQuickGame = {
// Quick game repeats the last setup against the AI.
val last = cc.skysparkle.matewave.settings.LastGameSetup(context)
val color = last.aiColor
viewModel.startVsAi(
last.aiDifficulty,
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
last.aiTime,
randomColor = color == null
)
navController.navigate("game")
},
onVsAiClick = { navController.navigate("vsAiSetup") },
onPuzzlesClick = {
val puzzle = cc.skysparkle.matewave.puzzles.PuzzleRepository.pickForRating(
context,
cc.skysparkle.matewave.data.ProfileStore.get().elo,
cc.skysparkle.matewave.stats.Stats.solvedPuzzles()
)
if (puzzle != null) {
viewModel.startPuzzle(puzzle)
navController.navigate("puzzleSolve")
}
},
onStudyClick = { navController.navigate("openingStudy") },
onVsPlayerClick = { navController.navigate("connectionNearby") },
onSettingsClick = { navController.navigate("settings") },
onAccountClick = { navController.navigate("account") },
onFriendsClick = { navController.navigate("friends") }
)
}
composable("settings") {
SettingsScreen(
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onFriendsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("friends")
},
onAccountClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("account")
},
onOpenPermissions = { navController.navigate("permissions") },
onOpenAppearance = { navController.navigate("appearance") },
onOpenLicenses = { navController.navigate("licenses") },
onOpenShare = { navController.navigate("shareApk") }
)
}
composable("shareApk") {
ShareApkScreen(onBack = { navController.popBackStack() })
}
composable("licenses") {
LicensesScreen(onBack = { navController.popBackStack() })
}
composable("permissions") {
PermissionsScreen(onBack = { navController.popBackStack() })
}
composable("appearance") {
AppearanceScreen(onBack = { navController.popBackStack() })
}
composable("account") {
AccountScreen(
studyViewModel = studyViewModel,
onStudyClick = { navController.navigate("openingStudy") },
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onFriendsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("friends")
},
onSettingsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("settings")
}
)
}
composable("friends") {
FriendsScreen(
presence = presence,
onPlay = { navController.navigate("connectionNearby") },
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onSettingsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("settings")
},
onAccountClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("account")
}
)
}
composable("vsAiSetup") {
VsAiSetupScreen(
onBack = { navController.popBackStack() },
onStart = { difficulty, color, timeControl ->
viewModel.startVsAi(
difficulty,
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
timeControl,
randomColor = color == null
)
navController.navigate("game")
}
)
}
composable("game") {
GameScreen(
viewModel = viewModel,
onExit = { navController.popBackStack("menu", inclusive = false) }
)
}
composable("openingStudy") {
cc.skysparkle.matewave.ui.screens.OpeningStudyScreen(
viewModel = studyViewModel,
onBack = { navController.popBackStack() },
onOpenTrainer = { navController.navigate("openingTrainer") }
)
}
composable("openingTrainer") {
cc.skysparkle.matewave.ui.screens.OpeningTrainerScreen(
viewModel = studyViewModel,
onBack = { navController.popBackStack() }
)
}
composable("puzzleSolve") {
PuzzleSolveScreen(
viewModel = viewModel,
onBack = { navController.popBackStack("menu", inclusive = false) },
onNextPuzzle = { next -> viewModel.startPuzzle(next) }
)
}
composable("connectionNearby") {
val bleTransport = remember { cc.skysparkle.matewave.network.Transports.ble(context) }
val lanTransport = remember { cc.skysparkle.matewave.network.Transports.lan(context) }
ConnectionScreen(
presence = presence,
onStartHost = { timeControl ->
// Wait for the opponent on Wi-Fi and, if allowed, Bluetooth at once.
val transports = buildList<cc.skysparkle.matewave.network.ConnectionTransport> {
add(lanTransport)
if (cc.skysparkle.matewave.network.AppPermissions.allGranted(context, cc.skysparkle.matewave.network.AppPermissions.nearby)) {
add(bleTransport)
}
}
val transport = if (transports.size == 1) lanTransport
else cc.skysparkle.matewave.network.DualHostTransport(transports)
viewModel.startVsPlayerNetwork(transport, PieceColor.WHITE, timeControl, isHost = true)
navController.navigate("game")
},
onJoin = { type, timeControl, targetId ->
val transport = if (type == TransportType.BLUETOOTH) bleTransport else lanTransport
viewModel.startVsPlayerNetwork(transport, PieceColor.BLACK, timeControl, isHost = false, targetId = targetId)
navController.navigate("game")
},
onBack = { navController.popBackStack() }
)
}
}
}
private val TAB_ROUTES = setOf("menu", "friends", "settings", "account")
private fun isTabSwitch(from: String?, to: String?): Boolean =
from in TAB_ROUTES && to in TAB_ROUTES && from != to
private val MotionEasing = androidx.compose.animation.core.CubicBezierEasing(0.2f, 0f, 0f, 1f)
private fun fadeThroughIn(): androidx.compose.animation.EnterTransition =
androidx.compose.animation.fadeIn(
androidx.compose.animation.core.tween(durationMillis = 260, delayMillis = 90, easing = MotionEasing)
) + androidx.compose.animation.scaleIn(
initialScale = 0.97f,
animationSpec = androidx.compose.animation.core.tween(durationMillis = 300, delayMillis = 90, easing = MotionEasing)
)
private fun fadeThroughOut(): androidx.compose.animation.ExitTransition =
androidx.compose.animation.fadeOut(
androidx.compose.animation.core.tween(durationMillis = 110, easing = androidx.compose.animation.core.LinearEasing)
)
private fun sharedAxisIn(forward: Boolean): androidx.compose.animation.EnterTransition =
androidx.compose.animation.slideInHorizontally(
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
) { width -> if (forward) width / 14 else -width / 14 } +
androidx.compose.animation.fadeIn(
androidx.compose.animation.core.tween(durationMillis = 240, delayMillis = 80, easing = MotionEasing)
)
private fun sharedAxisOut(forward: Boolean): androidx.compose.animation.ExitTransition =
androidx.compose.animation.slideOutHorizontally(
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
) { width -> if (forward) -width / 14 else width / 14 } +
androidx.compose.animation.fadeOut(
androidx.compose.animation.core.tween(durationMillis = 120, easing = androidx.compose.animation.core.LinearEasing)
)