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
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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)
|
||||
)
|
||||
Reference in New Issue
Block a user