mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
Added Rooms in Quest Launcher
- Created dialog_player_profile.xml for displaying player profiles with loading and error states. - Added item_player.xml for individual player items in lists, showing Mii, name, friend code, and VR. - Introduced item_room.xml for room items, displaying game mode, ID, and player count. - Developed page_room_details.xml for detailed room information, including player actions. - Implemented page_rooms.xml for the main rooms page layout, featuring search functionality and room listings. - Added view_vr_history.xml for visualizing player's VR history over time. - Created LiveRoomsTest.kt to validate room and player functionalities, including leaderboard integration and search capabilities.
This commit is contained in:
1 parent
bff6d36bad
commit
18781f2c88
40 files changed
+2791
-505
No files matched your search
@@ -30,8 +30,8 @@ import org.wiicompiled.quest.R
|
||||
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
|
||||
* with a Home page that sets up and starts the game, a My profiles page that shows the licences
|
||||
* of Retro Rewind's save, a Patches page that finds mods for Retro Rewind on GameBanana or imports
|
||||
* them, a My Miis page that makes and edits the game's Miis, and a Settings page that edits
|
||||
* Config.toml.
|
||||
* them, a My Miis page that makes and edits the game's Miis, a Settings page that edits
|
||||
* Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now.
|
||||
*
|
||||
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
|
||||
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
|
||||
@@ -44,7 +44,7 @@ import org.wiicompiled.quest.R
|
||||
*/
|
||||
class LauncherActivity : Activity() {
|
||||
|
||||
private enum class Page { Home, Profiles, Patches, Miis, Settings }
|
||||
private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms }
|
||||
|
||||
/** What Home's main and secondary buttons do. */
|
||||
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
|
||||
@@ -54,11 +54,14 @@ class LauncherActivity : Activity() {
|
||||
private lateinit var navPatches: View
|
||||
private lateinit var navMiis: View
|
||||
private lateinit var navSettings: View
|
||||
private lateinit var navRooms: View
|
||||
private lateinit var navRoomsCount: TextView
|
||||
private lateinit var homePage: View
|
||||
private lateinit var profilesView: View
|
||||
private lateinit var patchesView: View
|
||||
private lateinit var miisView: View
|
||||
private lateinit var settingsView: View
|
||||
private lateinit var roomsView: View
|
||||
private lateinit var trails: WheelTrailsView
|
||||
private lateinit var playButton: View
|
||||
private lateinit var playIcon: ImageView
|
||||
@@ -77,6 +80,18 @@ class LauncherActivity : Activity() {
|
||||
private lateinit var miis: MiisPage
|
||||
private lateinit var profilesPage: ProfilesPage
|
||||
private lateinit var sidebarProfile: SidebarProfileCard
|
||||
private lateinit var rooms: RoomsPage
|
||||
|
||||
/** The sidebar's count of players online (WheelWizard's UpdatePlayerCount). */
|
||||
private val playersOnline: () -> Unit = {
|
||||
val count = LiveRooms.playerCount
|
||||
navRoomsCount.text = count.toString()
|
||||
navRoomsCount.tooltipText = if (count == 0) {
|
||||
getString(R.string.launcher_players_online_none)
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.launcher_players_online, count, count)
|
||||
}
|
||||
}
|
||||
|
||||
/** The games this APK carries a kit for, and the one the player picked. */
|
||||
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
|
||||
@@ -114,11 +129,14 @@ class LauncherActivity : Activity() {
|
||||
navPatches = findViewById(R.id.nav_patches)
|
||||
navMiis = findViewById(R.id.nav_miis)
|
||||
navSettings = findViewById(R.id.nav_settings)
|
||||
navRooms = findViewById(R.id.nav_rooms)
|
||||
navRoomsCount = findViewById(R.id.nav_rooms_count)
|
||||
homePage = findViewById(R.id.page_home)
|
||||
profilesView = findViewById(R.id.page_profiles)
|
||||
patchesView = findViewById(R.id.page_patches)
|
||||
miisView = findViewById(R.id.page_miis)
|
||||
settingsView = findViewById(R.id.page_settings)
|
||||
roomsView = findViewById(R.id.page_rooms)
|
||||
trails = findViewById(R.id.home_trails)
|
||||
playButton = findViewById(R.id.home_play)
|
||||
playIcon = findViewById(R.id.home_play_icon)
|
||||
@@ -162,6 +180,9 @@ class LauncherActivity : Activity() {
|
||||
)
|
||||
sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) }
|
||||
profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) }
|
||||
rooms = RoomsPage(this, roomsView)
|
||||
LiveRooms.addListener(playersOnline)
|
||||
playersOnline()
|
||||
savedInstanceState?.getString(KEY_TAB)?.let { name ->
|
||||
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
|
||||
}
|
||||
@@ -171,6 +192,7 @@ class LauncherActivity : Activity() {
|
||||
navPatches.setOnClickListener { showPage(Page.Patches) }
|
||||
navMiis.setOnClickListener { showPage(Page.Miis) }
|
||||
navSettings.setOnClickListener { showPage(Page.Settings) }
|
||||
navRooms.setOnClickListener { showPage(Page.Rooms) }
|
||||
playButton.setOnClickListener { perform(mainAction) }
|
||||
secondary.setOnClickListener { secondaryAction?.let(::perform) }
|
||||
cancel.setOnClickListener { GameSetup.cancel() }
|
||||
@@ -208,6 +230,7 @@ class LauncherActivity : Activity() {
|
||||
// so does the Mii editor on My Miis.
|
||||
if (page == Page.Patches && patches.back()) return
|
||||
if (page == Page.Miis && miis.back()) return
|
||||
if (page == Page.Rooms && rooms.back()) return
|
||||
@Suppress("DEPRECATION")
|
||||
super.onBackPressed()
|
||||
}
|
||||
@@ -225,6 +248,9 @@ class LauncherActivity : Activity() {
|
||||
// The game may have changed the save while it ran; the profiles page reads it again itself.
|
||||
if (page != Page.Profiles) sidebarProfile.refresh()
|
||||
profilesPage.setVisible(page == Page.Profiles)
|
||||
// Who plays online is followed while the launcher is on screen, for the sidebar's count.
|
||||
LiveRooms.start()
|
||||
rooms.setVisible(page == Page.Rooms)
|
||||
// The game's process can take a moment to go away after its activity
|
||||
// closes, which would still read as running.
|
||||
val runningAtResume = isGameRunning()
|
||||
@@ -237,9 +263,16 @@ class LauncherActivity : Activity() {
|
||||
GameSetup.removeListener(setupListener)
|
||||
patches.detach()
|
||||
profilesPage.setVisible(false)
|
||||
rooms.setVisible(false)
|
||||
LiveRooms.stop()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
LiveRooms.removeListener(playersOnline)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString(KEY_PAGE, page.name)
|
||||
@@ -287,12 +320,15 @@ class LauncherActivity : Activity() {
|
||||
navPatches.isSelected = target == Page.Patches
|
||||
navMiis.isSelected = target == Page.Miis
|
||||
navSettings.isSelected = target == Page.Settings
|
||||
navRooms.isSelected = target == Page.Rooms
|
||||
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
|
||||
profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE
|
||||
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
|
||||
miisView.visibility = if (target == Page.Miis) View.VISIBLE else View.GONE
|
||||
profilesPage.setVisible(target == Page.Profiles)
|
||||
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
|
||||
roomsView.visibility = if (target == Page.Rooms) View.VISIBLE else View.GONE
|
||||
rooms.setVisible(target == Page.Rooms)
|
||||
if (target == Page.Home) {
|
||||
trailsAway = true
|
||||
}
|
||||
@@ -306,6 +342,8 @@ class LauncherActivity : Activity() {
|
||||
Page.Patches -> patches.refresh()
|
||||
Page.Miis -> miis.refresh()
|
||||
Page.Settings -> settings.refresh()
|
||||
// Rooms follow Retro WFC's answers by themselves.
|
||||
Page.Rooms -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Who plays on Retro WFC now, for the Rooms page, the sidebar's player count and the profiles'
|
||||
* Online glow: WheelWizard's RRLiveRooms. While the launcher is on screen it asks every 40 seconds
|
||||
* for the open rooms and the top 50 of the leaderboard, splits the rooms Retro WFC merged by
|
||||
* mistake, and marks the players of the top 50 with their rank.
|
||||
*/
|
||||
object LiveRooms {
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val REFRESH_MS = 40_000L
|
||||
private const val TOP_PLAYERS = 50
|
||||
/** RrLeaderboardSingletonService keeps the top players this long, and the last ones when Retro WFC fails. */
|
||||
private const val LEADERBOARD_FRESH_MS = 90_000L
|
||||
|
||||
/** A player in a room (RrPlayer). Two are the same player when their profile and friend code are. */
|
||||
class Player(
|
||||
val pid: String,
|
||||
val name: String,
|
||||
val friendCode: String,
|
||||
val vr: Int?,
|
||||
val br: Int?,
|
||||
val isOpenHost: Boolean,
|
||||
val isSuspended: Boolean,
|
||||
val mii: Mii?,
|
||||
/** Their place in the top 50, or null outside it. */
|
||||
val leaderboardRank: Int?,
|
||||
) {
|
||||
val vrText: String get() = vr?.toString() ?: "--"
|
||||
val topLabel: String get() = leaderboardRank?.let { "#$it" }.orEmpty()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is Player && pid == other.pid && friendCode == other.friendCode
|
||||
override fun hashCode(): Int = 31 * pid.hashCode() + friendCode.hashCode()
|
||||
}
|
||||
|
||||
/** A room (RrRoom), created at [created] in epoch milliseconds. */
|
||||
class Room(val id: String, val created: Long, val type: String, val rk: String?, val players: List<Player>) {
|
||||
val isPublic: Boolean get() = type != "private"
|
||||
val gameMode: String get() = gameMode(rk, isPublic)
|
||||
|
||||
/** The players' VR averaged and truncated, as the PC shows it; 0 when none has a VR. */
|
||||
val averageVr: Int
|
||||
get() = players.mapNotNull { it.vr }.takeIf { it.isNotEmpty() }?.average()?.toInt() ?: 0
|
||||
}
|
||||
|
||||
enum class TimeUnit { Days, Hours, Minutes, Seconds }
|
||||
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "LiveRooms").apply { isDaemon = true } }
|
||||
private val listeners = LinkedHashSet<() -> Unit>()
|
||||
private var running = false
|
||||
private var fetching = false
|
||||
|
||||
/** The rooms of the last answer, empty until one comes or when Retro WFC failed; main thread only. */
|
||||
var rooms: List<Room> = emptyList()
|
||||
private set
|
||||
|
||||
val playerCount: Int get() = rooms.sumOf { it.players.size }
|
||||
|
||||
/** Everyone in a room, as GameLicenseService.RefreshOnlineStatus finds a licence online. */
|
||||
val onlineFriendCodes: Set<String> get() = rooms.flatMap { room -> room.players.map { it.friendCode } }.toSet()
|
||||
|
||||
// The leaderboard's cache, on the worker thread only.
|
||||
private var leaderboard: List<RetroWfc.LeaderboardEntry>? = null
|
||||
private var leaderboardAt = 0L
|
||||
|
||||
private val ticker = object : Runnable {
|
||||
override fun run() {
|
||||
fetch()
|
||||
main.postDelayed(this, REFRESH_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** [listener] is called on the main thread after every answer, until it is removed. */
|
||||
fun addListener(listener: () -> Unit) {
|
||||
listeners += listener
|
||||
}
|
||||
|
||||
fun removeListener(listener: () -> Unit) {
|
||||
listeners -= listener
|
||||
}
|
||||
|
||||
/** Asks now, then every 40 seconds, while the launcher is on screen. */
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
main.post(ticker)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
main.removeCallbacks(ticker)
|
||||
}
|
||||
|
||||
private fun fetch() {
|
||||
if (fetching) return
|
||||
fetching = true
|
||||
worker.execute {
|
||||
val fresh = try {
|
||||
build(RetroWfc.roomStatus(), topPlayers())
|
||||
} catch (e: IOException) {
|
||||
Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
main.post {
|
||||
fetching = false
|
||||
rooms = fresh
|
||||
listeners.toList().forEach { it() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun topPlayers(): List<RetroWfc.LeaderboardEntry>? {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
leaderboard?.let { if (now - leaderboardAt < LEADERBOARD_FRESH_MS) return it }
|
||||
return try {
|
||||
RetroWfc.topPlayers(TOP_PLAYERS).also {
|
||||
leaderboard = it
|
||||
leaderboardAt = now
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.i(TAG, "Retro WFC's leaderboard is unavailable: ${e.message}")
|
||||
leaderboard
|
||||
}
|
||||
}
|
||||
|
||||
// What the pages show, worked out apart from Android so it can be tested.
|
||||
|
||||
/** RRLiveRooms.ExecuteTaskAsync: rooms split where they were merged, their players with their Miis and ranks. */
|
||||
internal fun build(rooms: List<RetroWfc.Room>, leaderboard: List<RetroWfc.LeaderboardEntry>?): List<Room> {
|
||||
val byPid = leaderboard.orEmpty().filter { it.pid.isNotBlank() }.groupBy { it.pid }.mapValues { it.value.first() }
|
||||
val byFriendCode = leaderboard.orEmpty().filter { it.friendCode.isNotBlank() }.groupBy { it.friendCode }
|
||||
.mapValues { it.value.first() }
|
||||
return splitMergedRooms(rooms).map { room ->
|
||||
Room(
|
||||
id = room.id,
|
||||
created = room.created,
|
||||
type = room.type,
|
||||
rk = room.rk,
|
||||
players = room.players.map { player ->
|
||||
val entry = byPid[player.pid] ?: player.friendCode.takeIf { it.isNotBlank() }?.let { byFriendCode[it] }
|
||||
Player(
|
||||
pid = player.pid,
|
||||
name = player.name,
|
||||
friendCode = player.friendCode,
|
||||
vr = player.vr,
|
||||
br = player.br,
|
||||
isOpenHost = player.isOpenHost,
|
||||
isSuspended = player.isSuspended,
|
||||
mii = player.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() },
|
||||
leaderboardRank = entry?.rank ?: entry?.activeRank,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitMergedRooms (after kevinvg207's rr-rooms): players only linked both ways in their
|
||||
* connection maps share a room, so a room whose players fall into several such groups is
|
||||
* really several rooms. A room without any link is left whole.
|
||||
*/
|
||||
internal fun splitMergedRooms(rooms: List<RetroWfc.Room>): List<RetroWfc.Room> = rooms.flatMap { room ->
|
||||
val count = room.players.size
|
||||
val links = List(count) { ArrayList<Int>() }
|
||||
var anyLink = false
|
||||
for (i in 0 until count) {
|
||||
val map = room.players[i].connectionMap
|
||||
if (map.isEmpty()) continue
|
||||
// The map usually lists every player, the player included, but can leave them out.
|
||||
val includesSelf = map.size == count
|
||||
val excludesSelf = map.size == count - 1
|
||||
if (!includesSelf && !excludesSelf) continue
|
||||
for (j in map.indices) {
|
||||
if ((map[j].firstOrNull() ?: '0') == '0') continue
|
||||
if (includesSelf && j == i) continue
|
||||
val other = if (includesSelf) j else if (j >= i) j + 1 else j
|
||||
if (other !in 0 until count) continue
|
||||
links[i] += other
|
||||
anyLink = true
|
||||
}
|
||||
}
|
||||
if (!anyLink) return@flatMap listOf(room)
|
||||
|
||||
val seen = BooleanArray(count)
|
||||
val groups = ArrayList<List<Int>>()
|
||||
for (start in 0 until count) {
|
||||
if (seen[start]) continue
|
||||
val stack = ArrayDeque<Int>().apply { addLast(start) }
|
||||
val group = ArrayList<Int>()
|
||||
while (stack.isNotEmpty()) {
|
||||
val u = stack.removeLast()
|
||||
if (seen[u]) continue
|
||||
seen[u] = true
|
||||
group += u
|
||||
for (v in links[u]) if (u in links[v]) stack.addLast(v)
|
||||
}
|
||||
groups += group.sorted()
|
||||
}
|
||||
if (groups.size == 1) {
|
||||
listOf(room)
|
||||
} else {
|
||||
groups.map { group -> RetroWfc.Room(room.id, room.type, room.created, room.rk, room.suspended, group.map { room.players[it] }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** RoomsPage.PerformSearch: every player whose name or friend code holds [query], once each. */
|
||||
fun search(rooms: List<Room>, query: String): List<Player> {
|
||||
val wanted = query.trim()
|
||||
if (wanted.isEmpty()) return emptyList()
|
||||
return rooms.flatMap { it.players }
|
||||
.filter { it.name.contains(wanted, ignoreCase = true) || it.friendCode.contains(wanted, ignoreCase = true) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/** tTime: a duration by its two largest units, the second left out when it is zero. */
|
||||
fun timeParts(millis: Long): List<Pair<TimeUnit, Int>> {
|
||||
val total = maxOf(0L, millis / 1000)
|
||||
val days = (total / 86_400).toInt()
|
||||
val hours = (total / 3_600 % 24).toInt()
|
||||
val minutes = (total / 60 % 60).toInt()
|
||||
val seconds = (total % 60).toInt()
|
||||
fun pair(first: Pair<TimeUnit, Int>, second: Pair<TimeUnit, Int>) = if (second.second == 0) listOf(first) else listOf(first, second)
|
||||
return when {
|
||||
days >= 1 -> pair(TimeUnit.Days to days, TimeUnit.Hours to hours)
|
||||
total >= 3_600 -> pair(TimeUnit.Hours to hours, TimeUnit.Minutes to minutes)
|
||||
total >= 60 -> pair(TimeUnit.Minutes to minutes, TimeUnit.Seconds to seconds)
|
||||
else -> listOf(TimeUnit.Seconds to seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/** RrRoom.GameMode: the name of a room's ranking, which tells the pack and the mode. */
|
||||
fun gameMode(rk: String?, isPublic: Boolean): String = GAME_MODES[rk] ?: if (isPublic) "Unknown Mode" else "Private Room"
|
||||
|
||||
private val GAME_MODES = mapOf(
|
||||
// Retro Rewind
|
||||
"vs_10" to "RR 150CC",
|
||||
"vs_11" to "RR Time Tr",
|
||||
"vs_12" to "RR 200CC",
|
||||
"vs_13" to "RR Item Rain",
|
||||
"vs_14" to "RR Battle",
|
||||
"vs_15" to "RR Elim Battle",
|
||||
"vs_20" to "RR 150CC CTs",
|
||||
"vs_21" to "RR Vanilla",
|
||||
// CTGP
|
||||
"vs_668" to "CTGP-C",
|
||||
// Insane Kart Wii
|
||||
"vs_69" to "Insane Kart",
|
||||
"vs_70" to "Ultras VS",
|
||||
"vs_71" to "Crazy Items",
|
||||
"vs_72" to "Bob-omb Blast",
|
||||
"vs_73" to "Inf Accel",
|
||||
"vs_74" to "Banan Slip",
|
||||
"vs_75" to "Rand Items",
|
||||
"vs_76" to "Unfair Items",
|
||||
"vs_77" to "Blue Madness",
|
||||
"vs_78" to "Mush Dash",
|
||||
"vs_79" to "Bumper Karts",
|
||||
"vs_80" to "Item Rampage",
|
||||
"vs_81" to "Item Rain",
|
||||
"vs_82" to "Shell Break",
|
||||
"vs_83" to "Riibalanced",
|
||||
// Luminous
|
||||
"vs_666" to "Luminous",
|
||||
"vs_667" to "Luminous TT",
|
||||
// OptPack
|
||||
"vs_875" to "OP 150",
|
||||
"vs_876" to "OP TT",
|
||||
"vs_877" to "OP R1",
|
||||
"vs_878" to "OP R2",
|
||||
"vs_879" to "OP R3",
|
||||
"vs_880" to "OP R4",
|
||||
// WTP
|
||||
"vs_1312" to "WTP 150CC",
|
||||
"vs_1313" to "WTP 200CC",
|
||||
"vs_1314" to "WTP Time Trial",
|
||||
"vs_1315" to "WTP Item Rain",
|
||||
"vs_1316" to "WTP STYD",
|
||||
// Generic
|
||||
"vs_751" to "Versus",
|
||||
"vs_-1" to "Regular",
|
||||
"vs" to "Regular",
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,10 @@ object MiiImages {
|
||||
private val previewGeneration = AtomicLong()
|
||||
/** Changes with [clear]: a picture drawn before is shown but not kept. */
|
||||
private val cacheGeneration = AtomicInteger()
|
||||
private val viewer = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "MiiViewer").apply { isDaemon = true } }
|
||||
private val viewGeneration = AtomicLong()
|
||||
/** The newest View Mii picture shown; main thread only. */
|
||||
private var viewShown = 0L
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
/**
|
||||
@@ -102,6 +106,37 @@ object MiiImages {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The View Mii window's picture: the whole Mii turned as [pose], [size] pixels square, for
|
||||
* [done] on the main thread (null when it cannot be drawn). Requests overtaken by newer ones
|
||||
* are skipped and nothing is kept, so dragging the Mii round costs one render at a time.
|
||||
*/
|
||||
fun view(context: Context, mii: Mii, size: Int, pose: MiiRenderer.Pose, done: (Bitmap?) -> Unit) {
|
||||
val app = context.applicationContext
|
||||
val generation = viewGeneration.incrementAndGet()
|
||||
val copy = mii.copy()
|
||||
viewer.execute {
|
||||
if (viewGeneration.get() != generation) return@execute
|
||||
val bitmap = try {
|
||||
MiiRenderResource.load(app)?.let { resource ->
|
||||
val pixels = MiiRenderer.render(resource, copy, size, pose, MiiRenderResource.bodies(app), fullBody = true)
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Cannot draw a Mii picture", e)
|
||||
null
|
||||
}
|
||||
main.post {
|
||||
// Each picture is newer than the one before it: a render still running when the
|
||||
// drag moved on is shown on the way.
|
||||
if (generation > viewShown) {
|
||||
viewShown = generation
|
||||
done(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets every picture, for when the parts were just installed and placeholders can become Miis. */
|
||||
fun clear() {
|
||||
cacheGeneration.incrementAndGet()
|
||||
|
||||
@@ -35,9 +35,11 @@ object MiiRenderer {
|
||||
val cameraX: Float,
|
||||
val cameraY: Float,
|
||||
val cameraZ: Float,
|
||||
/** The camera's distance as a share of the PC's (its CameraZoom): below 1 the Mii fills more. */
|
||||
val zoom: Float = 1f,
|
||||
) {
|
||||
/** Tells pictures of the same Mii in different poses apart. */
|
||||
val key: String get() = "$characterX,$characterY,$characterZ,$cameraX,$cameraY,$cameraZ"
|
||||
val key: String get() = "$characterX,$characterY,$characterZ,$cameraX,$cameraY,$cameraZ,$zoom"
|
||||
|
||||
companion object {
|
||||
/** Straight ahead, as My Miis and the editor show a Mii. */
|
||||
@@ -50,11 +52,18 @@ object MiiRenderer {
|
||||
|
||||
/**
|
||||
* The Mii at [size] x [size], as non-premultiplied ARGB colours, transparent around it: the
|
||||
* head, with the upper body below it when [bodies] are given.
|
||||
* head, with the upper body below it when [bodies] are given, or with [fullBody] the whole Mii
|
||||
* (the PC's all_body) when they are.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun render(resource: FflResource, mii: Mii, size: Int, pose: Pose = Pose.FRONT, bodies: MiiBodies? = null): IntArray =
|
||||
render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies)
|
||||
fun render(
|
||||
resource: FflResource,
|
||||
mii: Mii,
|
||||
size: Int,
|
||||
pose: Pose = Pose.FRONT,
|
||||
bodies: MiiBodies? = null,
|
||||
fullBody: Boolean = false,
|
||||
): IntArray = render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies, fullBody = fullBody)
|
||||
|
||||
@Throws(IOException::class)
|
||||
internal fun render(
|
||||
@@ -64,6 +73,7 @@ object MiiRenderer {
|
||||
expression: Int = 0,
|
||||
pose: Pose = Pose.FRONT,
|
||||
bodies: MiiBodies? = null,
|
||||
fullBody: Boolean = false,
|
||||
): IntArray {
|
||||
require(size in 16..4096 && size % 2 == 0) { "Unsupported picture size $size" }
|
||||
val resolution = if (size <= 384) 256 else 512
|
||||
@@ -76,8 +86,10 @@ object MiiRenderer {
|
||||
// The PC's face view: 15 degrees of field of view on the head, the camera orbiting it
|
||||
// (CalculateCameraOrbitPosition) and the head turned about its own origin. With a body
|
||||
// the head sits on its shoulders, and the camera rises with it; the body turns about its feet.
|
||||
val y = 4.805f / 0.14f
|
||||
val z = 57.553f / 0.14f
|
||||
// Its all_body view stands further back, looking at the whole Mii from a fixed place.
|
||||
val wholeBody = fullBody && body != null
|
||||
val y = if (wholeBody) 90f else 4.805f / 0.14f
|
||||
val z = (if (wholeBody) 760f else 57.553f / 0.14f) * pose.zoom
|
||||
val camera = radians(pose.cameraX, pose.cameraY, pose.cameraZ)
|
||||
val position = floatArrayOf(
|
||||
z * -sin(camera[1]) * cos(camera[0]),
|
||||
@@ -85,8 +97,8 @@ object MiiRenderer {
|
||||
z * cos(camera[1]) * cos(camera[0]),
|
||||
)
|
||||
position[1] += y
|
||||
val lookAt = floatArrayOf(0f, y, 0f)
|
||||
if (body != null) {
|
||||
val lookAt = floatArrayOf(0f, if (wholeBody) 95f else y, 0f)
|
||||
if (body != null && !wholeBody) {
|
||||
for (c in 0 until 3) {
|
||||
position[c] += body.headTranslation[c]
|
||||
lookAt[c] += body.headTranslation[c]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* WheelWizard's MiiCarouselWindow with its Mii3DRender: the whole Mii, which dragging turns (left
|
||||
* and right about the Mii, up and down about the camera) and the thumbstick brings nearer or
|
||||
* further. While it moves it is drawn small, then sharp once it rests, as the PC does.
|
||||
*/
|
||||
class MiiViewDialog private constructor(private val activity: Activity, private val mii: Mii) {
|
||||
|
||||
private val view = activity.layoutInflater.inflate(R.layout.dialog_mii_view, null)
|
||||
private val image: ImageView = view.findViewById(R.id.mii_view_image)
|
||||
private val loading: View = view.findViewById(R.id.mii_view_loading)
|
||||
private val message: TextView = view.findViewById(R.id.mii_view_message)
|
||||
private val density = activity.resources.displayMetrics.density
|
||||
private val size = (PICTURE_DP * density).toInt() and 1.inv()
|
||||
private val previewSize = (size / 4) and 1.inv()
|
||||
private lateinit var dialog: AlertDialog
|
||||
|
||||
private var yaw = 0f
|
||||
private var pitch = 0f
|
||||
private var zoom = 1f
|
||||
private var lastX = 0f
|
||||
private var lastY = 0f
|
||||
private val settle = Runnable { draw(size) }
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun open() {
|
||||
dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(mii.name)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.dialog_close, null)
|
||||
.create()
|
||||
dialog.setOnDismissListener { image.removeCallbacks(settle) }
|
||||
dialog.show()
|
||||
dialog.window?.setLayout(PatchesWidgets.dp(activity, 520), ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
if (!MiiRenderResource.installed(activity)) {
|
||||
loading.visibility = View.GONE
|
||||
message.visibility = View.VISIBLE
|
||||
message.setText(R.string.mii_view_no_parts)
|
||||
return
|
||||
}
|
||||
image.setOnTouchListener { _, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
// Mii3DRender turns 0.8 degrees per pixel dragged, in the PC's device-independent pixels.
|
||||
yaw += (event.x - lastX) / density * DRAG_DEGREES
|
||||
pitch += (event.y - lastY) / density * DRAG_DEGREES
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
draw(previewSize)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> draw(size)
|
||||
}
|
||||
true
|
||||
}
|
||||
image.setOnGenericMotionListener { _, event ->
|
||||
if (event.actionMasked != MotionEvent.ACTION_SCROLL) return@setOnGenericMotionListener false
|
||||
val steps = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (steps == 0f) return@setOnGenericMotionListener false
|
||||
zoom = (zoom - steps * ZOOM_STEP).coerceIn(MIN_ZOOM, MAX_ZOOM)
|
||||
draw(previewSize)
|
||||
image.removeCallbacks(settle)
|
||||
image.postDelayed(settle, SETTLE_MS)
|
||||
true
|
||||
}
|
||||
draw(size)
|
||||
}
|
||||
|
||||
private fun draw(pixels: Int) {
|
||||
if (pixels == size) image.removeCallbacks(settle)
|
||||
MiiImages.view(activity, mii, pixels, MiiRenderer.Pose(0f, yaw, 0f, pitch, 0f, 0f, zoom)) { bitmap ->
|
||||
if (!dialog.isShowing) return@view
|
||||
loading.visibility = View.GONE
|
||||
if (bitmap != null) {
|
||||
image.setImageBitmap(bitmap)
|
||||
} else if (image.drawable == null) {
|
||||
message.visibility = View.VISIBLE
|
||||
message.setText(R.string.mii_view_no_parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The picture's height in dialog_mii_view.xml. */
|
||||
private const val PICTURE_DP = 300
|
||||
private const val DRAG_DEGREES = 0.8f
|
||||
private const val ZOOM_STEP = 0.1f
|
||||
private const val MIN_ZOOM = 0.35f
|
||||
private const val MAX_ZOOM = 1.5f
|
||||
/** Mii3DRender's HighQualitySettleDelayMs. */
|
||||
private const val SETTLE_MS = 90L
|
||||
|
||||
fun show(activity: Activity, mii: Mii) = MiiViewDialog(activity, mii).open()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* WheelWizard's PlayerProfileWindow (Views/Popups/PlayerProfileWindow.axaml.cs): what Retro WFC
|
||||
* knows of a player, by friend code: their Mii, rank, VR and how it moved lately, and their VR
|
||||
* history. Titled with the friend code until the profile names the player.
|
||||
*/
|
||||
object PlayerProfileDialog {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "PlayerProfile").apply { isDaemon = true } }
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
fun show(activity: Activity, friendCode: String) {
|
||||
val view = activity.layoutInflater.inflate(R.layout.dialog_player_profile, null)
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(friendCode)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.dialog_close, null)
|
||||
.create()
|
||||
dialog.show()
|
||||
dialog.window?.setLayout(PatchesWidgets.dp(activity, 640), ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val history = VrHistoryPanel(activity, view.findViewById(R.id.player_profile_history))
|
||||
worker.execute {
|
||||
val result = runCatching { RetroWfc.playerProfile(friendCode) }
|
||||
main.post {
|
||||
if (!dialog.isShowing || activity.isDestroyed) return@post
|
||||
view.findViewById<View>(R.id.player_loading).visibility = View.GONE
|
||||
val profile = result.getOrNull()
|
||||
if (profile == null) {
|
||||
view.findViewById<View>(R.id.player_error).visibility = View.VISIBLE
|
||||
view.findViewById<TextView>(R.id.player_error_text).text =
|
||||
result.exceptionOrNull()?.message ?: activity.getString(R.string.player_profile_unknown)
|
||||
return@post
|
||||
}
|
||||
dialog.setTitle(profile.name)
|
||||
fill(activity, view, profile)
|
||||
history.show(profile.friendCode.ifEmpty { friendCode })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fill(activity: Activity, view: View, profile: RetroWfc.PlayerProfile) {
|
||||
view.findViewById<View>(R.id.player_content).visibility = View.VISIBLE
|
||||
view.findViewById<TextView>(R.id.player_profile_name).text = profile.name
|
||||
view.findViewById<TextView>(R.id.player_profile_code).text = profile.friendCode
|
||||
view.findViewById<TextView>(R.id.player_profile_rank).apply {
|
||||
text = activity.getString(R.string.player_profile_rank, profile.rank)
|
||||
visibility = if (profile.rank > 0) View.VISIBLE else View.GONE
|
||||
}
|
||||
view.findViewById<View>(R.id.player_profile_suspicious).visibility = if (profile.isSuspicious) View.VISIBLE else View.GONE
|
||||
view.findViewById<TextView>(R.id.player_profile_vr).text = grouped(profile.vr)
|
||||
view.findViewById<TextView>(R.id.player_profile_seen).text = lastSeen(activity, profile.lastSeen)
|
||||
val stats = profile.vrStats
|
||||
view.findViewById<TextView>(R.id.player_profile_day).text = stats?.let { signed(it.last24Hours) } ?: "--"
|
||||
view.findViewById<TextView>(R.id.player_profile_week).text = stats?.let { signed(it.lastWeek) } ?: "--"
|
||||
view.findViewById<TextView>(R.id.player_profile_month).text = stats?.let { signed(it.lastMonth) } ?: "--"
|
||||
|
||||
val mii = profile.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() }
|
||||
val picture = view.findViewById<ImageView>(R.id.player_profile_mii)
|
||||
val drawn = mii != null && MiiRenderResource.installed(activity)
|
||||
view.findViewById<View>(R.id.player_profile_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE
|
||||
if (drawn) MiiImages.head(activity, picture, mii!!, (2 * 84 * activity.resources.displayMetrics.density).toInt() and 1.inv())
|
||||
}
|
||||
|
||||
/** LastSeenText: whole days, hours or minutes ago. */
|
||||
private fun lastSeen(activity: Activity, time: Long?): String {
|
||||
if (time == null) return "--"
|
||||
val minutes = (System.currentTimeMillis() - time) / 60_000
|
||||
return when {
|
||||
minutes >= 24 * 60 -> activity.getString(R.string.player_profile_days_ago, (minutes / (24 * 60)).toInt())
|
||||
minutes >= 60 -> activity.getString(R.string.player_profile_hours_ago, (minutes / 60).toInt())
|
||||
minutes >= 1 -> activity.getString(R.string.player_profile_minutes_ago, minutes.toInt())
|
||||
else -> activity.getString(R.string.player_profile_just_now)
|
||||
}
|
||||
}
|
||||
|
||||
/** FormatSignedValue: a gain with its plus sign. */
|
||||
private fun signed(value: Int): String = if (value > 0) "+${grouped(value)}" else grouped(value)
|
||||
|
||||
private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -16,9 +15,9 @@ import org.wiicompiled.quest.R
|
||||
/**
|
||||
* What the profiles page and the sidebar's profile card show: the licences of Retro Rewind's save
|
||||
* and their Miis, which one is primary, and what Retro WFC knows of them (the Miis they last played
|
||||
* with, who is in a room now, their VR history), plus WheelWizard's badges. Files are read and the
|
||||
* network asked off the main thread, and every answer arrives on it. What Retro WFC saw of a Mii is
|
||||
* also kept on disk, so the sidebar shows it at once, even offline.
|
||||
* with, their VR history), plus WheelWizard's badges; who is online comes from [LiveRooms]. Files
|
||||
* are read and the network asked off the main thread, and every answer arrives on it. What Retro
|
||||
* WFC saw of a Mii is also kept on disk, so the sidebar shows it at once, even offline.
|
||||
*/
|
||||
object ProfileStore {
|
||||
|
||||
@@ -40,8 +39,6 @@ object ProfileStore {
|
||||
private const val IMAGE_DIRECTORY = "profile-miis"
|
||||
/** SettingValues.NoName: the name the game's guest Miis carry. */
|
||||
private const val GUEST_NAME = "no name"
|
||||
/** Who is in a room is asked again after this long; WheelWizard polls its live rooms similarly. */
|
||||
private const val ONLINE_REFRESH_MS = 30_000L
|
||||
|
||||
private val worker = Executors.newFixedThreadPool(2) { runnable -> Thread(runnable, "Profiles").apply { isDaemon = true } }
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
@@ -50,8 +47,6 @@ object ProfileStore {
|
||||
private val remotes = HashMap<String, Remote>()
|
||||
private val remotesAsked = HashSet<String>()
|
||||
private val waiting = HashMap<String, MutableList<(Remote?) -> Unit>>()
|
||||
private var online: Set<String> = emptySet()
|
||||
private var onlineAt = 0L
|
||||
private var badges: Map<String, List<RetroWfc.Badge>>? = null
|
||||
|
||||
/** Reads Retro Rewind's save; [done] gets null when there is none, or it is not readable. */
|
||||
@@ -210,29 +205,6 @@ object ProfileStore {
|
||||
|
||||
private val NONE = ByteArray(0)
|
||||
|
||||
/** The friend codes in a room now, asked again when the last answer is old; the last known ones when that fails. */
|
||||
fun online(done: (Set<String>) -> Unit) {
|
||||
if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) {
|
||||
done(online)
|
||||
return
|
||||
}
|
||||
worker.execute {
|
||||
val fresh = try {
|
||||
RetroWfc.onlineFriendCodes()
|
||||
} catch (e: IOException) {
|
||||
Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
main.post {
|
||||
if (fresh != null) {
|
||||
online = fresh
|
||||
onlineAt = SystemClock.elapsedRealtime()
|
||||
}
|
||||
done(online)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WheelWizard's badges by friend code, fetched once a session. */
|
||||
fun badges(done: (Map<String, List<RetroWfc.Badge>>) -> Unit) {
|
||||
badges?.let {
|
||||
|
||||
@@ -3,28 +3,16 @@ package org.wiicompiled.quest.launcher
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RadioButton
|
||||
import android.widget.Spinner
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import java.text.NumberFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
@@ -63,23 +51,7 @@ class ProfilesPage(
|
||||
private val statsPage: View = root.findViewById(R.id.profiles_stats)
|
||||
private val dots: List<View> = listOf(root.findViewById(R.id.profiles_dot0), root.findViewById(R.id.profiles_dot1))
|
||||
|
||||
private val historyStart: TextView = root.findViewById(R.id.history_start)
|
||||
private val historyEnd: TextView = root.findViewById(R.id.history_end)
|
||||
private val historyChange: TextView = root.findViewById(R.id.history_change)
|
||||
private val historyRange: TextView = root.findViewById(R.id.history_range)
|
||||
private val historyLoading: View = root.findViewById(R.id.history_loading)
|
||||
private val historyMatches: Switch = root.findViewById(R.id.history_matches)
|
||||
private val historyGraph: View = root.findViewById(R.id.history_graph)
|
||||
private val historyEmpty: View = root.findViewById(R.id.history_empty)
|
||||
private val historyEmptyMessage: TextView = root.findViewById(R.id.history_empty_message)
|
||||
private val historyMax: TextView = root.findViewById(R.id.history_max)
|
||||
private val historyMin: TextView = root.findViewById(R.id.history_min)
|
||||
private val historyChart: VrHistoryChart = root.findViewById(R.id.history_chart)
|
||||
private val historyLabels: List<TextView> = listOf(
|
||||
root.findViewById(R.id.history_label_start),
|
||||
root.findViewById(R.id.history_label_mid),
|
||||
root.findViewById(R.id.history_label_end),
|
||||
)
|
||||
private val history = VrHistoryPanel(activity, historyPage)
|
||||
|
||||
private val statsVr: TextView = root.findViewById(R.id.stats_vr)
|
||||
private val statsBr: TextView = root.findViewById(R.id.stats_br)
|
||||
@@ -89,23 +61,11 @@ class ProfilesPage(
|
||||
private var snapshot: ProfileStore.Snapshot? = null
|
||||
private var slot = -1
|
||||
private val current: RksysProfiles.License? get() = snapshot?.licenses?.getOrNull(slot)
|
||||
private var online: Set<String> = emptySet()
|
||||
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
|
||||
private var carouselPage = 0
|
||||
private var days = DEFAULT_DAYS
|
||||
private var history: RetroWfc.History? = null
|
||||
/** The friend code and period the history on screen, or on its way, is for. */
|
||||
private var historyFor: Pair<String, Int>? = null
|
||||
private var historyGeneration = 0
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var visible = false
|
||||
private val onlineTicker = object : Runnable {
|
||||
override fun run() {
|
||||
refreshOnline()
|
||||
main.postDelayed(this, ONLINE_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
private val roomsChanged: () -> Unit = { renderOnline() }
|
||||
|
||||
private val slotTabs: List<TextView> = (0 until RksysProfiles.SLOTS).map { index ->
|
||||
TextView(activity).apply {
|
||||
@@ -134,38 +94,19 @@ class ProfilesPage(
|
||||
root.findViewById<View>(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) }
|
||||
root.findViewById<View>(R.id.profiles_next).setOnClickListener { moveCarousel(1) }
|
||||
|
||||
PatchesWidgets.styleSwitch(activity, historyMatches)
|
||||
historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() }
|
||||
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() }
|
||||
|
||||
root.findViewById<Spinner>(R.id.history_days).apply {
|
||||
background = activity.getDrawable(R.drawable.bg_dropdown)
|
||||
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
|
||||
dropDownVerticalOffset = dp(4)
|
||||
adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply {
|
||||
setDropDownViewResource(R.layout.item_dropdown_popup)
|
||||
}
|
||||
setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false)
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val chosen = DAY_OPTIONS[position]
|
||||
if (chosen == days) return
|
||||
days = chosen
|
||||
current?.let(::loadHistory)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
}
|
||||
renderCarousel()
|
||||
}
|
||||
|
||||
/** While the page is on screen, who is online is asked again now and then, as the PC polls its rooms. */
|
||||
/** While the page is on screen, who is online follows Retro WFC's rooms, as the PC's does. */
|
||||
fun setVisible(shown: Boolean) {
|
||||
if (shown == visible) return
|
||||
visible = shown
|
||||
main.removeCallbacks(onlineTicker)
|
||||
if (shown) main.post(onlineTicker)
|
||||
if (shown) {
|
||||
LiveRooms.addListener(roomsChanged)
|
||||
renderOnline()
|
||||
} else {
|
||||
LiveRooms.removeListener(roomsChanged)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the save again, which the game may have changed since, and shows the licence chosen. */
|
||||
@@ -173,8 +114,7 @@ class ProfilesPage(
|
||||
ProfileStore.load(activity) { loaded ->
|
||||
if (activity.isDestroyed) return@load
|
||||
snapshot = loaded
|
||||
// VrHistoryGraph reloads each time it is shown: races since then count.
|
||||
historyFor = null
|
||||
history.forget()
|
||||
val licenses = loaded?.licenses.orEmpty()
|
||||
if (licenses.getOrNull(slot) == null) {
|
||||
// UserProfilePage opens on the focused user, here the primary licence.
|
||||
@@ -216,7 +156,7 @@ class ProfilesPage(
|
||||
statsBr.text = license.br.toString()
|
||||
statsWins.text = license.wins.toString()
|
||||
statsRaces.text = license.races.toString()
|
||||
loadHistory(license)
|
||||
history.show(license.friendCode)
|
||||
}
|
||||
|
||||
/** The licence's Mii, drawn at the size it is shown at (UserProfilePage's CurrentUserSideProfile). */
|
||||
@@ -238,16 +178,10 @@ class ProfilesPage(
|
||||
|
||||
// Online, as UserProfilePage.UpdateOnlineBorders shows it.
|
||||
|
||||
private fun refreshOnline() {
|
||||
ProfileStore.online { codes ->
|
||||
online = codes
|
||||
if (!activity.isDestroyed) renderOnline()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderOnline() {
|
||||
if (activity.isDestroyed) return
|
||||
val license = current ?: return
|
||||
val isOnline = license.friendCode.isNotEmpty() && license.friendCode in online
|
||||
val isOnline = license.friendCode.isNotEmpty() && license.friendCode in LiveRooms.onlineFriendCodes
|
||||
card.background = box(R.color.neutral_900, if (isOnline) R.color.primary_400 else R.color.neutral_900, 8)
|
||||
face.background = box(R.color.neutral_950, if (isOnline) R.color.primary_400 else R.color.neutral_600, 4)
|
||||
glow.visibility = if (isOnline) View.VISIBLE else View.GONE
|
||||
@@ -300,108 +234,6 @@ class ProfilesPage(
|
||||
}
|
||||
}
|
||||
|
||||
// VR history, as VrHistoryGraph loads and draws it.
|
||||
|
||||
private fun loadHistory(license: RksysProfiles.License) {
|
||||
val friendCode = license.friendCode
|
||||
if (historyFor == friendCode to days) return
|
||||
historyFor = friendCode to days
|
||||
val generation = ++historyGeneration
|
||||
history = null
|
||||
if (friendCode.isEmpty() || friendCode.none { it in '1'..'9' }) {
|
||||
// SetNoFriendCodeState: a licence never taken online has nothing to show.
|
||||
historyLoading.visibility = View.GONE
|
||||
showHistoryState(activity.getString(R.string.history_no_code))
|
||||
historyRange.text = ""
|
||||
return
|
||||
}
|
||||
historyLoading.visibility = View.VISIBLE
|
||||
ProfileStore.history(friendCode, days) { result ->
|
||||
if (generation != historyGeneration || activity.isDestroyed) return@history
|
||||
historyLoading.visibility = View.GONE
|
||||
result.onSuccess { loaded ->
|
||||
history = loaded
|
||||
renderHistory()
|
||||
}.onFailure { failure ->
|
||||
historyRange.text = ""
|
||||
showHistoryState(failure.message ?: failure.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** VrHistoryGraph.ApplyHistoryData: the totals, the range, then the plot by time or by match. */
|
||||
private fun renderHistory() {
|
||||
val data = history ?: return
|
||||
historyStart.text = grouped(data.starting)
|
||||
historyEnd.text = grouped(data.ending)
|
||||
historyChange.text = if (data.totalChange > 0) "+${grouped(data.totalChange)}" else grouped(data.totalChange)
|
||||
historyChange.setTextColor(
|
||||
activity.getColor(
|
||||
when {
|
||||
data.totalChange > 0 -> R.color.primary_400
|
||||
data.totalChange < 0 -> R.color.danger_400
|
||||
else -> R.color.neutral_100
|
||||
},
|
||||
),
|
||||
)
|
||||
historyRange.text = if (days == LIFETIME_DAYS) {
|
||||
activity.getString(R.string.history_range_all)
|
||||
} else {
|
||||
val fromPattern = if (year(data.from) != year(data.to)) "MMM d, yyyy" else "MMM d"
|
||||
activity.getString(R.string.history_range, days, date(data.from, fromPattern), date(data.to, "MMM d"))
|
||||
}
|
||||
|
||||
val entries = data.entries
|
||||
if (entries.isEmpty()) {
|
||||
showHistoryState(activity.getString(R.string.history_none_range))
|
||||
return
|
||||
}
|
||||
historyGraph.visibility = View.VISIBLE
|
||||
historyEmpty.visibility = View.GONE
|
||||
val start = entries.first().time
|
||||
val span = maxOf(1_000L, entries.last().time - start)
|
||||
val lowest = entries.minOf { it.total }
|
||||
val highest = entries.maxOf { it.total }
|
||||
val vrRange = maxOf(1, highest - lowest).toFloat()
|
||||
historyMax.text = grouped(highest)
|
||||
historyMin.text = grouped(lowest)
|
||||
val byMatch = historyMatches.isChecked
|
||||
val labels = if (byMatch) {
|
||||
listOf(1, entries.size / 2 + 1, entries.size).map { activity.getString(R.string.history_match, it) }
|
||||
} else {
|
||||
val pattern = if (days >= 7) "MMM d" else "MMM d HH:mm"
|
||||
listOf(start, start + span / 2, entries.last().time).map { date(it, pattern) }
|
||||
}
|
||||
historyLabels.zip(labels).forEach { (view, text) -> view.text = text }
|
||||
historyChart.setPoints(
|
||||
entries.mapIndexed { index, entry ->
|
||||
val x = if (byMatch) {
|
||||
if (entries.size > 1) index.toFloat() / (entries.size - 1) else 0f
|
||||
} else {
|
||||
(entry.time - start).toFloat() / span
|
||||
}
|
||||
PointF(x, (entry.total - lowest) / vrRange)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** VrHistoryGraph's empty and error states, which share one look. */
|
||||
private fun showHistoryState(message: String) {
|
||||
historyGraph.visibility = View.GONE
|
||||
historyEmpty.visibility = View.VISIBLE
|
||||
historyEmptyMessage.text = message
|
||||
for (view in listOf(historyStart, historyEnd, historyChange)) view.text = "0"
|
||||
historyChange.setTextColor(activity.getColor(R.color.neutral_100))
|
||||
historyChart.setPoints(emptyList())
|
||||
}
|
||||
|
||||
private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value)
|
||||
|
||||
private fun date(time: Long, pattern: String): String =
|
||||
DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(time))
|
||||
|
||||
private fun year(time: Long): Int = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).year
|
||||
|
||||
private fun box(fill: Int, stroke: Int, radius: Int) = GradientDrawable().apply {
|
||||
setColor(activity.getColor(fill))
|
||||
setStroke(dp(1), activity.getColor(stroke))
|
||||
@@ -414,12 +246,5 @@ class ProfilesPage(
|
||||
/** The Mii picture's frame in page_profiles.xml. */
|
||||
const val PICTURE_DP = 320
|
||||
const val CAROUSEL_PAGES = 2
|
||||
const val DEFAULT_DAYS = 30
|
||||
/** What VrHistoryGraph asks for "Lifetime". */
|
||||
const val LIFETIME_DAYS = 999
|
||||
val DAY_OPTIONS = listOf(1, 7, 30, 60, LIFETIME_DAYS)
|
||||
val DAY_LABELS = listOf(R.string.history_days_1, R.string.history_days_7, R.string.history_days_30, R.string.history_days_60, R.string.history_days_all)
|
||||
/** How often who is online is asked again while the page is shown. */
|
||||
const val ONLINE_REFRESH_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
|
||||
/**
|
||||
* Retro WFC, the server Retro Rewind plays online on, asked what WheelWizard's profile page asks it
|
||||
* (Features/RrRooms/IRwfcApi): a player's profile, which carries a picture of their Mii, their VR
|
||||
* history, and the rooms open now. Also WheelWizard's own badges (badges.json), by friend code.
|
||||
* Retro WFC, the server Retro Rewind plays online on, asked what WheelWizard asks it
|
||||
* (Features/RrRooms/IRwfcApi): the rooms open now, the top of the leaderboard, a player's profile,
|
||||
* which carries their Mii, and their VR history. Also WheelWizard's own badges (badges.json), by
|
||||
* friend code.
|
||||
*
|
||||
* Values are read with plain `opt` and type checks, as in [GameBanana], so Android's org.json and
|
||||
* the JVM one the tests use agree.
|
||||
@@ -60,8 +61,51 @@ object RetroWfc {
|
||||
Leafstruck_BronzeWinner("Leafstruck Tournament Runner-Up"),
|
||||
}
|
||||
|
||||
/** A player in an open room (RwfcRoomStatusPlayer); [mii] is the 74 bytes of their Mii. */
|
||||
class RoomPlayer(
|
||||
val pid: String,
|
||||
val name: String,
|
||||
val friendCode: String,
|
||||
val vr: Int?,
|
||||
val br: Int?,
|
||||
val isOpenHost: Boolean,
|
||||
val isSuspended: Boolean,
|
||||
val mii: ByteArray?,
|
||||
val connectionMap: List<String>,
|
||||
)
|
||||
|
||||
/** An open room (RwfcRoomStatusRoom), created at [created] in epoch milliseconds. */
|
||||
class Room(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val created: Long,
|
||||
val rk: String?,
|
||||
val suspended: Boolean,
|
||||
val players: List<RoomPlayer>,
|
||||
)
|
||||
|
||||
/** A row of the leaderboard (RwfcLeaderboardEntry), as far as the rooms use it. */
|
||||
class LeaderboardEntry(val pid: String, val friendCode: String, val rank: Int?, val activeRank: Int?)
|
||||
|
||||
/** RwfcLeaderboardVrStats: the VR won or lost lately. */
|
||||
class VrStats(val last24Hours: Int, val lastWeek: Int, val lastMonth: Int)
|
||||
|
||||
/** PlayerProfileResponse: what Retro WFC knows of a player; [mii] is the 74 bytes of their Mii. */
|
||||
class PlayerProfile(
|
||||
val name: String,
|
||||
val friendCode: String,
|
||||
val vr: Int,
|
||||
val rank: Int,
|
||||
val lastSeen: Long?,
|
||||
val isSuspicious: Boolean,
|
||||
val vrStats: VrStats?,
|
||||
val mii: ByteArray?,
|
||||
)
|
||||
|
||||
fun profileUrl(friendCode: String): String = "$API/leaderboard/player/${encode(friendCode)}"
|
||||
|
||||
fun topUrl(limit: Int): String = "$API/leaderboard/top/$limit"
|
||||
|
||||
fun historyUrl(friendCode: String, days: Int): String = "${profileUrl(friendCode)}/history?days=$days"
|
||||
|
||||
private fun encode(text: String): String = URLEncoder.encode(text, "UTF-8")
|
||||
@@ -75,14 +119,7 @@ object RetroWfc {
|
||||
* The 74 bytes of the Mii a profile last played with (the Wii's RFLCharData, as the game sent
|
||||
* it), or null without one.
|
||||
*/
|
||||
fun parseMiiData(json: String): ByteArray? = parsing {
|
||||
val encoded = JSONObject(json).text("miiData") ?: return@parsing null
|
||||
try {
|
||||
Base64.getMimeDecoder().decode(encoded).takeIf { it.size >= MiiData.SIZE }?.copyOf(MiiData.SIZE)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
fun parseMiiData(json: String): ByteArray? = parsing { miiBytes(JSONObject(json).text("miiData")) }
|
||||
|
||||
/** The PNG of the player's Mii a profile carries (64 by 64 pixels), or null without one. */
|
||||
fun parseMiiImage(json: String): ByteArray? = parsing {
|
||||
@@ -118,15 +155,79 @@ object RetroWfc {
|
||||
)
|
||||
}
|
||||
|
||||
/** The friend codes of everyone in a room now (RRLiveRooms, as GameLicenseService.RefreshOnlineStatus uses it). */
|
||||
fun parseOnlineFriendCodes(json: String): Set<String> = parsing {
|
||||
val rooms = JSONObject(json).opt("rooms") as? JSONArray ?: return@parsing emptySet()
|
||||
(0 until rooms.length()).flatMap { index ->
|
||||
val players = (rooms.opt(index) as? JSONObject)?.opt("players") as? JSONArray ?: return@flatMap emptyList()
|
||||
(0 until players.length()).mapNotNull { (players.opt(it) as? JSONObject)?.text("friendCode") }
|
||||
}.toSet()
|
||||
/**
|
||||
* The rooms open now (RwfcRoomStatusResponse). A room or player without its ID, or a room
|
||||
* without its type or creation time, which the PC could not read either, is left out.
|
||||
*/
|
||||
fun parseRoomStatus(json: String): List<Room> = parsing {
|
||||
objects(JSONObject(json).opt("rooms")).mapNotNull { room ->
|
||||
Room(
|
||||
id = room.text("id") ?: return@mapNotNull null,
|
||||
type = room.text("type") ?: return@mapNotNull null,
|
||||
created = time(room.text("created")) ?: return@mapNotNull null,
|
||||
rk = room.text("rk"),
|
||||
// The PC's "suspend"; Retro WFC now calls it "isSuspended".
|
||||
suspended = (room.opt("isSuspended") ?: room.opt("suspend")) as? Boolean ?: false,
|
||||
players = objects(room.opt("players")).mapNotNull { player ->
|
||||
// The Mii is one object, or was a list of them.
|
||||
val mii = player.opt("mii").let { it as? JSONObject ?: objects(it).firstOrNull() }
|
||||
RoomPlayer(
|
||||
pid = player.id("pid") ?: return@mapNotNull null,
|
||||
name = player.opt("name") as? String ?: "",
|
||||
friendCode = player.opt("friendCode") as? String ?: "",
|
||||
vr = (player.opt("vr") as? Number)?.toInt(),
|
||||
br = (player.opt("br") as? Number)?.toInt(),
|
||||
isOpenHost = player.opt("isOpenHost") as? Boolean ?: false,
|
||||
isSuspended = player.opt("isSuspended") as? Boolean ?: false,
|
||||
mii = miiBytes(mii?.text("data")),
|
||||
connectionMap = (player.opt("connectionMap") as? JSONArray)?.let { map ->
|
||||
(0 until map.length()).map { map.opt(it)?.toString().orEmpty() }
|
||||
}.orEmpty(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The top of the leaderboard, best first. */
|
||||
fun parseLeaderboard(json: String): List<LeaderboardEntry> = parsing {
|
||||
objects(JSONArray(json)).mapNotNull { entry ->
|
||||
LeaderboardEntry(
|
||||
pid = entry.id("pid") ?: return@mapNotNull null,
|
||||
friendCode = entry.opt("friendCode") as? String ?: "",
|
||||
rank = (entry.opt("rank") as? Number)?.toInt(),
|
||||
activeRank = (entry.opt("activeRank") as? Number)?.toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePlayerProfile(json: String): PlayerProfile = parsing {
|
||||
val root = JSONObject(json)
|
||||
PlayerProfile(
|
||||
name = root.opt("name") as? String ?: "",
|
||||
friendCode = root.opt("friendCode") as? String ?: "",
|
||||
vr = root.number("vr"),
|
||||
rank = root.number("rank"),
|
||||
lastSeen = time(root.text("lastSeen")),
|
||||
isSuspicious = root.opt("isSuspicious") as? Boolean ?: false,
|
||||
vrStats = (root.opt("vrStats") as? JSONObject)?.let { stats ->
|
||||
VrStats(stats.number("last24Hours"), stats.number("lastWeek"), stats.number("lastMonth"))
|
||||
},
|
||||
mii = miiBytes(root.text("miiData")),
|
||||
)
|
||||
}
|
||||
|
||||
/** The 74 bytes of a Mii Retro WFC sent in Base64, or null for none or anything shorter. */
|
||||
private fun miiBytes(encoded: String?): ByteArray? = try {
|
||||
// The MIME decoder skips line breaks and anything else that is not Base64.
|
||||
encoded?.let { Base64.getMimeDecoder().decode(it) }?.takeIf { it.size >= MiiData.SIZE }?.copyOf(MiiData.SIZE)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun objects(value: Any?): List<JSONObject> =
|
||||
(value as? JSONArray)?.let { array -> (0 until array.length()).mapNotNull { array.opt(it) as? JSONObject } }.orEmpty()
|
||||
|
||||
/** WhWzDataSingletonService.LoadBadgesAsync: friend code to badges, unknown badge names left out. */
|
||||
fun parseBadges(json: String): Map<String, List<Badge>> = parsing {
|
||||
val root = JSONObject(json)
|
||||
@@ -153,6 +254,13 @@ object RetroWfc {
|
||||
private fun JSONObject.text(key: String): String? = (opt(key) as? String)?.takeIf { it.isNotBlank() }
|
||||
private fun JSONObject.number(key: String): Int = (opt(key) as? Number)?.toInt() ?: 0
|
||||
|
||||
/** An ID Retro WFC sends as text, or might send as a number. */
|
||||
private fun JSONObject.id(key: String): String? = when (val value = opt(key)) {
|
||||
is String -> value.takeIf { it.isNotBlank() }
|
||||
is Number -> value.toLong().toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Network; everything below blocks, so callers run it off the main thread.
|
||||
|
||||
/** The Mii of [friendCode]'s profile, or null when Retro WFC has never seen it. */
|
||||
@@ -165,7 +273,13 @@ object RetroWfc {
|
||||
return parseHistory(json)
|
||||
}
|
||||
|
||||
fun onlineFriendCodes(): Set<String> = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}")
|
||||
fun roomStatus(): List<Room> = parseRoomStatus(get(ROOM_STATUS_URL) ?: "{}")
|
||||
|
||||
fun topPlayers(limit: Int): List<LeaderboardEntry> = parseLeaderboard(get(topUrl(limit)) ?: "[]")
|
||||
|
||||
/** What Retro WFC knows of [friendCode], or null when it has never seen them. */
|
||||
fun playerProfile(friendCode: String): PlayerProfile? =
|
||||
get(profileUrl(friendCode), missingIsNull = true)?.let(::parsePlayerProfile)
|
||||
|
||||
fun badges(): Map<String, List<Badge>> = parseBadges(get(BADGES_URL) ?: "{}")
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ListPopupWindow
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The launcher's Rooms page, WheelWizard's RoomsPage and RoomDetailsPage: the rooms open on Retro
|
||||
* WFC ([LiveRooms]), or with a search the players whose name or friend code holds it. A room opens
|
||||
* its details in place of the list, and a player there their actions: copy their friend code, see
|
||||
* their Mii, or their profile. As on the PC, nothing here joins a room.
|
||||
*
|
||||
* The PC's Add Friend waits for the Friends page, which reads and writes the save's friend list.
|
||||
*/
|
||||
class RoomsPage(private val activity: Activity, root: View) {
|
||||
|
||||
private val main: View = root.findViewById(R.id.rooms_main)
|
||||
private val search: EditText = root.findViewById(R.id.rooms_search)
|
||||
private val empty: View = root.findViewById(R.id.rooms_empty)
|
||||
private val list: View = root.findViewById(R.id.rooms_list)
|
||||
private val listTitle: TextView = root.findViewById(R.id.rooms_list_title)
|
||||
private val listCount: TextView = root.findViewById(R.id.rooms_list_count)
|
||||
private val items: ListView = root.findViewById(R.id.rooms_items)
|
||||
|
||||
private val details: View = root.findViewById(R.id.rooms_details)
|
||||
private val detailsLock: View = root.findViewById(R.id.room_lock)
|
||||
private val detailsId: TextView = root.findViewById(R.id.room_id)
|
||||
private val detailsTime: TextView = root.findViewById(R.id.room_time)
|
||||
private val detailsType: TextView = root.findViewById(R.id.room_type)
|
||||
private val detailsVr: TextView = root.findViewById(R.id.room_average_vr)
|
||||
private val detailsCount: TextView = root.findViewById(R.id.room_player_count)
|
||||
private val detailsPlayers: ListView = root.findViewById(R.id.room_players)
|
||||
|
||||
private val roomAdapter = RoomAdapter()
|
||||
private val searchAdapter = PlayerAdapter { player -> roomOf(player)?.let(::openRoom) }
|
||||
private val detailsAdapter = PlayerAdapter(::showActions)
|
||||
|
||||
private var visible = false
|
||||
private var query = ""
|
||||
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
|
||||
/** The room whose details are open, as last shown; null on the list. */
|
||||
private var detailsRoom: LiveRooms.Room? = null
|
||||
private val roomsChanged: () -> Unit = { render() }
|
||||
|
||||
private val miiSize = (2 * MII_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
|
||||
|
||||
init {
|
||||
detailsPlayers.adapter = detailsAdapter
|
||||
root.findViewById<View>(R.id.room_back).setOnClickListener { closeRoom() }
|
||||
search.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(text: Editable?) {
|
||||
query = text?.toString().orEmpty()
|
||||
renderList()
|
||||
}
|
||||
})
|
||||
search.setOnEditorActionListener { view, action, _ ->
|
||||
if (action != EditorInfo.IME_ACTION_SEARCH) return@setOnEditorActionListener false
|
||||
activity.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** While the page is on screen it follows every answer from Retro WFC. */
|
||||
fun setVisible(shown: Boolean) {
|
||||
if (shown == visible) return
|
||||
visible = shown
|
||||
if (!shown) {
|
||||
LiveRooms.removeListener(roomsChanged)
|
||||
return
|
||||
}
|
||||
LiveRooms.addListener(roomsChanged)
|
||||
ProfileStore.badges { loaded ->
|
||||
badges = loaded
|
||||
if (!activity.isDestroyed) render()
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Back closes a room's details before leaving the page. */
|
||||
fun back(): Boolean {
|
||||
if (detailsRoom == null) return false
|
||||
closeRoom()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
if (activity.isDestroyed) return
|
||||
renderList()
|
||||
renderRoom()
|
||||
}
|
||||
|
||||
// The list: rooms, or the players the search finds (RoomsPage.OnUpdate and PerformSearch).
|
||||
|
||||
private fun renderList() {
|
||||
val rooms = LiveRooms.rooms
|
||||
empty.visibility = if (rooms.isEmpty()) View.VISIBLE else View.GONE
|
||||
list.visibility = if (rooms.isEmpty()) View.GONE else View.VISIBLE
|
||||
if (query.isBlank()) {
|
||||
roomAdapter.rooms = rooms
|
||||
listTitle.setText(R.string.rooms_list_rooms)
|
||||
listCount.text = rooms.size.toString()
|
||||
if (items.adapter !== roomAdapter) items.adapter = roomAdapter
|
||||
roomAdapter.notifyDataSetChanged()
|
||||
} else {
|
||||
searchAdapter.players = LiveRooms.search(rooms, query)
|
||||
listTitle.setText(R.string.rooms_list_players)
|
||||
listCount.text = searchAdapter.players.size.toString()
|
||||
if (items.adapter !== searchAdapter) items.adapter = searchAdapter
|
||||
searchAdapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/** The room a player is in, as the PC finds it for a player picked in the search. */
|
||||
private fun roomOf(player: LiveRooms.Player): LiveRooms.Room? = LiveRooms.rooms.firstOrNull { player in it.players }
|
||||
|
||||
// A room's details (RoomDetailsPage).
|
||||
|
||||
private fun openRoom(room: LiveRooms.Room) {
|
||||
detailsRoom = room
|
||||
main.visibility = View.GONE
|
||||
details.visibility = View.VISIBLE
|
||||
detailsPlayers.setSelection(0)
|
||||
renderRoom()
|
||||
}
|
||||
|
||||
private fun closeRoom() {
|
||||
detailsRoom = null
|
||||
details.visibility = View.GONE
|
||||
main.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun renderRoom() {
|
||||
val shown = detailsRoom ?: return
|
||||
// The room as Retro WFC now has it; a room it split shares its ID, so the one with the most
|
||||
// players in common. A room that closed takes the page back to the list, as on the PC.
|
||||
val room = LiveRooms.rooms.filter { it.id == shown.id }.maxByOrNull { candidate -> candidate.players.count { it in shown.players } }
|
||||
if (room == null) {
|
||||
closeRoom()
|
||||
return
|
||||
}
|
||||
detailsRoom = room
|
||||
detailsLock.visibility = if (room.isPublic) View.GONE else View.VISIBLE
|
||||
detailsId.text = room.id
|
||||
detailsTime.text = timeOnline(room)
|
||||
detailsType.text = room.gameMode
|
||||
detailsVr.text = room.averageVr.toString()
|
||||
detailsCount.text = room.players.size.toString()
|
||||
detailsAdapter.players = room.players
|
||||
detailsAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/** RoomDetailsPage's context menu, opened on the player's row. */
|
||||
private fun showActions(player: LiveRooms.Player, row: View) {
|
||||
val actions = listOf(
|
||||
R.string.room_copy_friend_code to { copyFriendCode(player) },
|
||||
R.string.room_view_mii to { viewMii(player) },
|
||||
R.string.room_view_profile to { if (player.friendCode.isNotEmpty()) PlayerProfileDialog.show(activity, player.friendCode) },
|
||||
)
|
||||
ListPopupWindow(activity).apply {
|
||||
anchorView = row
|
||||
setAdapter(ArrayAdapter(activity, R.layout.item_dropdown_popup, actions.map { activity.getString(it.first) }))
|
||||
setBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
|
||||
width = dp(220)
|
||||
horizontalOffset = dp(60)
|
||||
isModal = true
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
dismiss()
|
||||
actions[position].second()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFriendCode(player: LiveRooms.Player) {
|
||||
activity.getSystemService(ClipboardManager::class.java)
|
||||
?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), player.friendCode))
|
||||
Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun viewMii(player: LiveRooms.Player) {
|
||||
val mii = player.mii
|
||||
if (mii == null) {
|
||||
Toast.makeText(activity, R.string.room_no_mii, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MiiViewDialog.show(activity, mii)
|
||||
}
|
||||
|
||||
// Rows
|
||||
|
||||
/** RrRoom.TimeOnline: how long ago Retro WFC opened the room. */
|
||||
private fun timeOnline(room: LiveRooms.Room): String =
|
||||
LiveRooms.timeParts(System.currentTimeMillis() - room.created).joinToString(" ") { (unit, count) ->
|
||||
val plural = when (unit) {
|
||||
LiveRooms.TimeUnit.Days -> R.plurals.time_days
|
||||
LiveRooms.TimeUnit.Hours -> R.plurals.time_hours
|
||||
LiveRooms.TimeUnit.Minutes -> R.plurals.time_minutes
|
||||
LiveRooms.TimeUnit.Seconds -> R.plurals.time_seconds
|
||||
}
|
||||
activity.resources.getQuantityString(plural, count, count)
|
||||
}
|
||||
|
||||
private inner class RoomAdapter : BaseAdapter() {
|
||||
var rooms: List<LiveRooms.Room> = emptyList()
|
||||
|
||||
override fun getCount(): Int = rooms.size
|
||||
override fun getItem(position: Int): Any = rooms[position]
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_room, parent, false)
|
||||
val room = rooms[position]
|
||||
row.findViewById<ImageView>(R.id.room_icon).setImageResource(if (room.isPublic) R.drawable.ic_road else R.drawable.ic_road_locked)
|
||||
row.findViewById<TextView>(R.id.room_mode).text = room.gameMode
|
||||
row.findViewById<TextView>(R.id.room_code).text = room.id
|
||||
row.findViewById<TextView>(R.id.room_age).text = timeOnline(room)
|
||||
row.findViewById<TextView>(R.id.room_count).text = room.players.size.toString()
|
||||
row.setOnClickListener { openRoom(room) }
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows of PlayerListItem; [picked] gets the player and their row. */
|
||||
private inner class PlayerAdapter(private val picked: (LiveRooms.Player, View) -> Unit) : BaseAdapter() {
|
||||
var players: List<LiveRooms.Player> = emptyList()
|
||||
|
||||
constructor(picked: (LiveRooms.Player) -> Unit) : this({ player, _ -> picked(player) })
|
||||
|
||||
override fun getCount(): Int = players.size
|
||||
override fun getItem(position: Int): Any = players[position]
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_player, parent, false)
|
||||
val player = players[position]
|
||||
val picture = row.findViewById<ImageView>(R.id.player_mii)
|
||||
val mii = player.mii
|
||||
val drawn = mii != null && MiiRenderResource.installed(activity)
|
||||
row.findViewById<View>(R.id.player_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE
|
||||
if (drawn) {
|
||||
MiiImages.head(activity, picture, mii!!, miiSize)
|
||||
} else {
|
||||
picture.setTag(R.id.mii_image_key, null)
|
||||
picture.setImageDrawable(null)
|
||||
}
|
||||
row.findViewById<TextView>(R.id.player_name).text = player.name
|
||||
row.findViewById<TextView>(R.id.player_code).text = player.friendCode
|
||||
row.findViewById<TextView>(R.id.player_vr).text = player.vrText
|
||||
|
||||
val badgeList = badges[player.friendCode].orEmpty()
|
||||
row.findViewById<View>(R.id.player_badges_box).visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE
|
||||
row.findViewById<LinearLayout>(R.id.player_badges).apply {
|
||||
removeAllViews()
|
||||
badgeList.forEachIndexed { index, badge ->
|
||||
addView(BadgeView(activity, badge), LinearLayout.LayoutParams(dp(30), dp(30)).apply { if (index > 0) marginStart = dp(3) })
|
||||
}
|
||||
}
|
||||
row.findViewById<TextView>(R.id.player_top).apply {
|
||||
text = player.topLabel
|
||||
visibility = if (player.leaderboardRank != null) View.VISIBLE else View.GONE
|
||||
}
|
||||
row.findViewById<View>(R.id.player_host).visibility = if (player.isOpenHost) View.VISIBLE else View.GONE
|
||||
row.setOnClickListener { picked(player, row) }
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
|
||||
|
||||
private companion object {
|
||||
/** The Mii's circle in item_player.xml, drawn at twice its pixels for smooth edges. */
|
||||
const val MII_DP = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.LinearLayout
|
||||
|
||||
/**
|
||||
* A list row that takes every tap itself. A view with a tooltip, or a badge strip that scrolls,
|
||||
* would otherwise swallow a tap on it; the pointer's hover still reaches them, so their tips show.
|
||||
*/
|
||||
class TapRow(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
|
||||
override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.PointF
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Spinner
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import java.text.NumberFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* WheelWizard's VrHistoryGraph (Views/Patterns/VrHistoryGraph): a player's VR over the period
|
||||
* chosen, with its start, end and change, drawn by time or by match. [root] is a view_vr_history
|
||||
* layout; My profiles shows its licence's, the Rooms page a player's.
|
||||
*/
|
||||
class VrHistoryPanel(private val activity: Activity, root: View) {
|
||||
|
||||
private val start: TextView = root.findViewById(R.id.history_start)
|
||||
private val end: TextView = root.findViewById(R.id.history_end)
|
||||
private val change: TextView = root.findViewById(R.id.history_change)
|
||||
private val range: TextView = root.findViewById(R.id.history_range)
|
||||
private val loading: ProgressBar = root.findViewById(R.id.history_loading)
|
||||
private val matches: Switch = root.findViewById(R.id.history_matches)
|
||||
private val graph: View = root.findViewById(R.id.history_graph)
|
||||
private val empty: View = root.findViewById(R.id.history_empty)
|
||||
private val emptyMessage: TextView = root.findViewById(R.id.history_empty_message)
|
||||
private val max: TextView = root.findViewById(R.id.history_max)
|
||||
private val min: TextView = root.findViewById(R.id.history_min)
|
||||
private val chart: VrHistoryChart = root.findViewById(R.id.history_chart)
|
||||
private val labels: List<TextView> = listOf(
|
||||
root.findViewById(R.id.history_label_start),
|
||||
root.findViewById(R.id.history_label_mid),
|
||||
root.findViewById(R.id.history_label_end),
|
||||
)
|
||||
|
||||
private var days = DEFAULT_DAYS
|
||||
private var friendCode: String? = null
|
||||
private var history: RetroWfc.History? = null
|
||||
/** The friend code and period the history on screen, or on its way, is for. */
|
||||
private var shownFor: Pair<String, Int>? = null
|
||||
private var generation = 0
|
||||
|
||||
init {
|
||||
PatchesWidgets.styleSwitch(activity, matches)
|
||||
matches.setOnCheckedChangeListener { _, _ -> render() }
|
||||
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { matches.toggle() }
|
||||
|
||||
root.findViewById<Spinner>(R.id.history_days).apply {
|
||||
background = activity.getDrawable(R.drawable.bg_dropdown)
|
||||
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
|
||||
dropDownVerticalOffset = PatchesWidgets.dp(activity, 4)
|
||||
adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply {
|
||||
setDropDownViewResource(R.layout.item_dropdown_popup)
|
||||
}
|
||||
setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false)
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val chosen = DAY_OPTIONS[position]
|
||||
if (chosen == days) return
|
||||
days = chosen
|
||||
friendCode?.let(::show)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows [code]'s history, loading it unless it is already the one shown for this period. */
|
||||
fun show(code: String) {
|
||||
friendCode = code
|
||||
if (shownFor == code to days) return
|
||||
shownFor = code to days
|
||||
val loadGeneration = ++generation
|
||||
history = null
|
||||
if (code.isEmpty() || code.none { it in '1'..'9' }) {
|
||||
// SetNoFriendCodeState: a licence never taken online has nothing to show.
|
||||
loading.visibility = View.GONE
|
||||
showState(activity.getString(R.string.history_no_code))
|
||||
range.text = ""
|
||||
return
|
||||
}
|
||||
loading.visibility = View.VISIBLE
|
||||
ProfileStore.history(code, days) { result ->
|
||||
if (loadGeneration != generation || activity.isDestroyed) return@history
|
||||
loading.visibility = View.GONE
|
||||
result.onSuccess { loaded ->
|
||||
history = loaded
|
||||
render()
|
||||
}.onFailure { failure ->
|
||||
range.text = ""
|
||||
showState(failure.message ?: failure.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** VrHistoryGraph reloads each time it is shown: races since then count. */
|
||||
fun forget() {
|
||||
shownFor = null
|
||||
}
|
||||
|
||||
/** VrHistoryGraph.ApplyHistoryData: the totals, the range, then the plot by time or by match. */
|
||||
private fun render() {
|
||||
val data = history ?: return
|
||||
start.text = grouped(data.starting)
|
||||
end.text = grouped(data.ending)
|
||||
change.text = if (data.totalChange > 0) "+${grouped(data.totalChange)}" else grouped(data.totalChange)
|
||||
change.setTextColor(
|
||||
activity.getColor(
|
||||
when {
|
||||
data.totalChange > 0 -> R.color.primary_400
|
||||
data.totalChange < 0 -> R.color.danger_400
|
||||
else -> R.color.neutral_100
|
||||
},
|
||||
),
|
||||
)
|
||||
range.text = if (days == LIFETIME_DAYS) {
|
||||
activity.getString(R.string.history_range_all)
|
||||
} else {
|
||||
val fromPattern = if (year(data.from) != year(data.to)) "MMM d, yyyy" else "MMM d"
|
||||
activity.getString(R.string.history_range, days, date(data.from, fromPattern), date(data.to, "MMM d"))
|
||||
}
|
||||
|
||||
val entries = data.entries
|
||||
if (entries.isEmpty()) {
|
||||
showState(activity.getString(R.string.history_none_range))
|
||||
return
|
||||
}
|
||||
graph.visibility = View.VISIBLE
|
||||
empty.visibility = View.GONE
|
||||
val first = entries.first().time
|
||||
val span = maxOf(1_000L, entries.last().time - first)
|
||||
val lowest = entries.minOf { it.total }
|
||||
val highest = entries.maxOf { it.total }
|
||||
val vrRange = maxOf(1, highest - lowest).toFloat()
|
||||
max.text = grouped(highest)
|
||||
min.text = grouped(lowest)
|
||||
val byMatch = matches.isChecked
|
||||
val texts = if (byMatch) {
|
||||
listOf(1, entries.size / 2 + 1, entries.size).map { activity.getString(R.string.history_match, it) }
|
||||
} else {
|
||||
val pattern = if (days >= 7) "MMM d" else "MMM d HH:mm"
|
||||
listOf(first, first + span / 2, entries.last().time).map { date(it, pattern) }
|
||||
}
|
||||
labels.zip(texts).forEach { (view, text) -> view.text = text }
|
||||
chart.setPoints(
|
||||
entries.mapIndexed { index, entry ->
|
||||
val x = if (byMatch) {
|
||||
if (entries.size > 1) index.toFloat() / (entries.size - 1) else 0f
|
||||
} else {
|
||||
(entry.time - first).toFloat() / span
|
||||
}
|
||||
PointF(x, (entry.total - lowest) / vrRange)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** VrHistoryGraph's empty and error states, which share one look. */
|
||||
private fun showState(message: String) {
|
||||
graph.visibility = View.GONE
|
||||
empty.visibility = View.VISIBLE
|
||||
emptyMessage.text = message
|
||||
for (view in listOf(start, end, change)) view.text = "0"
|
||||
change.setTextColor(activity.getColor(R.color.neutral_100))
|
||||
chart.setPoints(emptyList())
|
||||
}
|
||||
|
||||
private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value)
|
||||
|
||||
private fun date(time: Long, pattern: String): String =
|
||||
DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(time))
|
||||
|
||||
private fun year(time: Long): Int = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).year
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_DAYS = 30
|
||||
/** What VrHistoryGraph asks for "Lifetime". */
|
||||
const val LIFETIME_DAYS = 999
|
||||
val DAY_OPTIONS = listOf(1, 7, 30, 60, LIFETIME_DAYS)
|
||||
val DAY_LABELS = listOf(R.string.history_days_1, R.string.history_days_7, R.string.history_days_30, R.string.history_days_60, R.string.history_days_all)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- PlayerListItem's badge strip: a thin outline around the badges. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The round frame of an online player's Mii (PlayerListItem, PlayerProfileWindow). -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@color/neutral_950" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's StateBox, its Danger variant: a player Retro WFC finds suspicious. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/danger_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's StateBox, its Dark variant: a room's player count. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_950" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's StateBox, its Success variant: a player's rank. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/primary_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- PlayerListItem's leaderboard place, such as #12. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/primary_600" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's InfoTip icon (Font Awesome, 512x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Lock icon (Font Awesome, 448x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's LockOpen icon (Font Awesome, 576x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="576"
|
||||
android:viewportHeight="576">
|
||||
<group android:translateY="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M352 144c0-44.2 35.8-80 80-80s80 35.8 80 80l0 48c0 17.7 14.3 32 32 32s32-14.3 32-32l0-48C576 64.5 511.5 0 432 0S288 64.5 288 144l0 48L64 192c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-192c0-35.3-28.7-64-64-64l-32 0 0-48z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Road icon (Font Awesome, 576x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="576"
|
||||
android:viewportHeight="576">
|
||||
<group android:translateY="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M256 32l-74.8 0c-27.1 0-51.3 17.1-60.3 42.6L3.1 407.2C1.1 413 0 419.2 0 425.4C0 455.5 24.5 480 54.6 480L256 480l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64 201.4 0c30.2 0 54.6-24.5 54.6-54.6c0-6.2-1.1-12.4-3.1-18.2L455.1 74.6C446 49.1 421.9 32 394.8 32L320 32l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64zm64 192l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's RoadLocked icon (Font Awesome, 640x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="640"
|
||||
android:viewportHeight="640">
|
||||
<group android:translateY="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M288 32l-74.8 0c-27.1 0-51.3 17.1-60.3 42.6L35.1 407.2c-2.1 5.9-3.1 12-3.1 18.2C32 455.5 56.5 480 86.6 480L288 480l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64 32 0 0-128c0-23.7 12.9-44.4 32-55.4l0-24.6c0-58.3 44.6-106.2 101.5-111.5L487.1 74.6C478 49.1 453.9 32 426.8 32L352 32l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64zm64 192l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32zm176 16c17.7 0 32 14.3 32 32l0 48-64 0 0-48c0-17.7 14.3-32 32-32zm-80 32l0 48c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32l160 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's RoomUsers icon (Font Awesome, 640x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="640"
|
||||
android:viewportHeight="640">
|
||||
<group android:translateY="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M96 0C43 0 0 43 0 96L0 416c0 53 43 96 96 96l448 0c53 0 96-43 96-96l0-320c0-53-43-96-96-96L96 0zM64 96c0-17.7 14.3-32 32-32l448 0c17.7 0 32 14.3 32 32l0 320c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32L64 96zm159.8 80a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM96 309.3c0 14.7 11.9 26.7 26.7 26.7l56.1 0c8-34.1 32.8-61.7 65.2-73.6c-7.5-4.1-16.2-6.4-25.3-6.4l-69.3 0C119.9 256 96 279.9 96 309.3zM461.2 336l56.1 0c14.7 0 26.7-11.9 26.7-26.7c0-29.5-23.9-53.3-53.3-53.3l-69.3 0c-9.2 0-17.8 2.3-25.3 6.4c32.4 11.9 57.2 39.5 65.2 73.6zM372 289c-3.9-.7-7.9-1-12-1l-80 0c-4.1 0-8.1 .3-12 1c-26 4.4-47.3 22.7-55.9 47c-2.7 7.5-4.1 15.6-4.1 24c0 13.3 10.7 24 24 24l176 0c13.3 0 24-10.7 24-24c0-8.4-1.4-16.5-4.1-24c-8.6-24.3-29.9-42.6-55.9-47zM512 176a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM320 256a64 64 0 1 0 0-128 64 64 0 1 0 0 128z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's UserCouple icon (Font Awesome, 640x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="640"
|
||||
android:viewportHeight="640">
|
||||
<group android:translateY="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -180,6 +180,39 @@
|
||||
android:text="@string/launcher_nav_settings" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/launcher_section_online" />
|
||||
|
||||
<!-- A TapRow, so a tap on the count opens the page while the pointer still shows its tip. -->
|
||||
<org.wiicompiled.quest.launcher.TapRow
|
||||
android:id="@+id/nav_rooms"
|
||||
style="@style/Launcher.NavItem">
|
||||
|
||||
<ImageView
|
||||
style="@style/Launcher.NavIcon"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_room_users" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.NavLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/launcher_nav_rooms" />
|
||||
|
||||
<!-- WheelWizard's BoxText: the players online now. -->
|
||||
<TextView
|
||||
android:id="@+id/nav_rooms_count"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:drawableStart="@drawable/ic_user_couple"
|
||||
android:text="0" />
|
||||
</org.wiicompiled.quest.launcher.TapRow>
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
@@ -229,5 +262,10 @@
|
||||
android:id="@+id/page_settings"
|
||||
layout="@layout/page_settings"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_rooms"
|
||||
layout="@layout/page_rooms"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's MiiCarouselWindow (Views/Popups/MiiManagement/MiiCarouselWindow.axaml): the whole
|
||||
Mii, turned by dragging it (Mii3DRender). -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="440dp"
|
||||
android:layout_height="300dp">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/mii_view_loading"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminateTint="@color/neutral_700" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mii_view_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:maxWidth="360dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/mii_view_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/room_view_mii"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/mii_view_hint"
|
||||
android:textColor="@color/neutral_500"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's PlayerProfileWindow (Views/Popups/PlayerProfileWindow.axaml): what Retro WFC
|
||||
knows of a player, with their VR history. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="380dp"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:indeterminateTint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/player_profile_loading"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_error"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="54dp"
|
||||
android:layout_height="54dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_warning"
|
||||
android:tint="@color/danger_600" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_error_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="440dp"
|
||||
android:textColor="@color/danger_600"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/player_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="84dp"
|
||||
android:layout_height="84dp"
|
||||
android:background="@drawable/bg_mii_circle"
|
||||
android:clipToOutline="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_profile_placeholder"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_profile_mii"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_rank"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:background="@drawable/bg_state_box_success"
|
||||
android:textColor="@color/primary_200" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_suspicious"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:background="@drawable/bg_state_box_danger"
|
||||
android:text="@string/player_profile_suspicious"
|
||||
android:textColor="@color/danger_200" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/room_vr"
|
||||
android:tooltipText="@string/room_vr_full" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_vr"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/player_profile_vr_week" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_week"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/player_profile_last_seen" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_seen"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/player_profile_vr_month" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_month"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/player_profile_vr_day" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_profile_day"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<include
|
||||
android:id="@+id/player_profile_history"
|
||||
layout="@layout/view_vr_history"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="270dp"
|
||||
android:layout_marginTop="12dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A player in a room or in the search (WheelWizard's PlayerListItem): their Mii, name and friend
|
||||
code, VR, badges, leaderboard place and whether they host the room. -->
|
||||
<org.wiicompiled.quest.launcher.TapRow xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_browser_item"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginBottom="3dp"
|
||||
android:background="@drawable/bg_mii_circle"
|
||||
android:clipToOutline="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_placeholder"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_mii"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="170dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_name"
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="90dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:text="@string/room_vr"
|
||||
android:tooltipText="@string/room_vr_full" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_vr"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/player_badges_box"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:background="@drawable/bg_badge_box"
|
||||
android:padding="4dp"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_badges"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal" />
|
||||
</HorizontalScrollView>
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_top"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:background="@drawable/bg_top_label"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_host"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="@string/room_open_host"
|
||||
android:src="@drawable/ic_lock_open"
|
||||
android:tint="@color/primary_400"
|
||||
android:tooltipText="@string/room_open_host" />
|
||||
</org.wiicompiled.quest.launcher.TapRow>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A room on the Rooms page (RoomsPage's item template): open or locked, its game mode and ID,
|
||||
how long it has been open, and its player count. -->
|
||||
<org.wiicompiled.quest.launcher.TapRow xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_browser_item"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/room_icon"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginBottom="3dp"
|
||||
android:padding="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:tint="@color/neutral_500" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="180dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_mode"
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:text="@string/rooms_time_online" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_age"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_count"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:background="@drawable/bg_state_box_dark"
|
||||
android:drawableStart="@drawable/ic_user_couple"
|
||||
android:textColor="@color/neutral_200" />
|
||||
</org.wiicompiled.quest.launcher.TapRow>
|
||||
@@ -220,249 +220,11 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
<include
|
||||
android:id="@+id/profiles_history"
|
||||
layout="@layout/view_vr_history"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_start" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_start"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_end"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_change" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_change"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/history_title" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/history_loading"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:indeterminateTint="@color/neutral_500"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_matches_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Switch
|
||||
android:id="@+id/history_matches"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/history_matches"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/history_days"
|
||||
android:layout_width="185dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_range"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_graph"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_row_single"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_max"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_min"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="22dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<org.wiicompiled.quest.launcher.VrHistoryChart
|
||||
android:id="@+id/history_chart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_start"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_mid"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_end"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_chart"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/history_none_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_empty_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="320dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_stats"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's RoomDetailsPage (Views/Pages/RoomDetailsPage.axaml): a room's ID, time online,
|
||||
game mode and average VR, and its players; a player opens their actions. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="88dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_back"
|
||||
style="@style/Launcher.LinkButton"
|
||||
android:layout_height="36dp"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="-6dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:clickable="true"
|
||||
android:drawableStart="@drawable/ic_arrow_left"
|
||||
android:drawablePadding="6dp"
|
||||
android:drawableTint="@color/neutral_300"
|
||||
android:focusable="true"
|
||||
android:text="@string/launcher_nav_rooms"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:text="@string/room_details_title" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/room_lock"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="3dp"
|
||||
android:layout_marginBottom="13dp"
|
||||
android:contentDescription="@string/rooms_private"
|
||||
android:src="@drawable/ic_lock"
|
||||
android:tint="@color/neutral_300"
|
||||
android:tooltipText="@string/rooms_private"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/room_id" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_id"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/room_type" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="@string/rooms_time_online" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/room_average_vr" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_average_vr"
|
||||
style="@style/Launcher.StateBox" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="38dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:text="@string/rooms_list_players" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/room_player_count"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Above the players' VR column (item_player.xml). -->
|
||||
<TextView
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="248dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:text="@string/room_vr"
|
||||
android:tooltipText="@string/room_vr_full" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/room_players"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="6dp"
|
||||
android:listSelector="@android:color/transparent"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="12dp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Rooms page (Views/Pages/RoomsPage.axaml): the rooms open on Retro WFC, or the
|
||||
players whose name or friend code holds the search, with a room's details
|
||||
(page_room_details.xml) opened in place of the list. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rooms_main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/launcher_nav_rooms" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginBottom="9dp"
|
||||
android:contentDescription="@string/rooms_disclaimer"
|
||||
android:src="@drawable/ic_info"
|
||||
android:tint="@color/neutral_500"
|
||||
android:tooltipText="@string/rooms_disclaimer" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/rooms_search"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:background="@drawable/bg_search_field"
|
||||
android:hint="@string/rooms_search_hint"
|
||||
android:imeOptions="actionSearch"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="text"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textColorHint="@color/neutral_500"
|
||||
android:textSize="15sp" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rooms_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_road"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/rooms_empty_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="440dp"
|
||||
android:text="@string/rooms_empty_message"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rooms_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rooms_list_title"
|
||||
style="@style/Launcher.FieldLabel"
|
||||
android:layout_marginBottom="0dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rooms_list_count"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/rooms_items"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="6dp"
|
||||
android:listSelector="@android:color/transparent"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="12dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<include
|
||||
android:id="@+id/rooms_details"
|
||||
layout="@layout/page_room_details"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,245 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's VrHistoryGraph (VrHistoryPanel): a player's VR over a chosen period, by time or
|
||||
by match, on My profiles and in the Rooms page's player profiles. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_start" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_start"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_end"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_change" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_change"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/history_title" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/history_loading"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:indeterminateTint="@color/neutral_500"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_matches_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Switch
|
||||
android:id="@+id/history_matches"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/history_matches"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/history_days"
|
||||
android:layout_width="185dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_range"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_graph"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_row_single"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_max"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_min"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="22dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<org.wiicompiled.quest.launcher.VrHistoryChart
|
||||
android:id="@+id/history_chart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_start"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_mid"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_end"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_chart"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/history_none_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_empty_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="320dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -31,9 +31,12 @@
|
||||
<color name="warning_600">#D19200</color>
|
||||
<color name="warning_700">#A66802</color>
|
||||
<color name="warning_800">#89510A</color>
|
||||
<color name="danger_200">#FFC5CF</color>
|
||||
<color name="danger_300">#FF9DAF</color>
|
||||
<color name="danger_400">#FF6581</color>
|
||||
<color name="danger_500">#FE2048</color>
|
||||
<color name="danger_600">#EC163D</color>
|
||||
<color name="danger_700">#C70E2F</color>
|
||||
|
||||
<!-- The mod browser's look for mods that use patches (Views/Patterns/ModBrowserListItem.axaml). -->
|
||||
<color name="patches_item">#173726</color>
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
<string name="launcher_nav_patches">Patches</string>
|
||||
<string name="launcher_nav_miis">My Miis</string>
|
||||
<string name="launcher_nav_settings">Settings</string>
|
||||
<string name="launcher_section_online">Online</string>
|
||||
<string name="launcher_nav_rooms">Rooms</string>
|
||||
<string name="launcher_players_online_none">There are currently no players online</string>
|
||||
<plurals name="launcher_players_online">
|
||||
<item quantity="one">There is currently %d player online</item>
|
||||
<item quantity="other">There are currently %d players online</item>
|
||||
</plurals>
|
||||
<string name="launcher_version">v%1$s</string>
|
||||
|
||||
<!-- Home -->
|
||||
@@ -158,6 +165,59 @@
|
||||
<string name="history_end">End VR</string>
|
||||
<string name="history_change">Net Change</string>
|
||||
<string name="history_title">VR History</string>
|
||||
|
||||
<!-- Rooms (WheelWizard's RoomsPage, RoomDetailsPage and PlayerProfileWindow) -->
|
||||
<string name="rooms_disclaimer">Please note that this page does not offer any functionality to join a room directly. To join a specific room, you\'ll need to either join through a friend, or hope to join it by joining the online servers.</string>
|
||||
<string name="rooms_search_hint">Search For Players...</string>
|
||||
<string name="rooms_empty_title">No rooms found</string>
|
||||
<string name="rooms_empty_message">You might not have internet connection, or no-one might be playing.</string>
|
||||
<string name="rooms_list_rooms">Rooms</string>
|
||||
<string name="rooms_list_players">Players</string>
|
||||
<string name="rooms_time_online">Time Online</string>
|
||||
<string name="rooms_private">Private room</string>
|
||||
<string name="room_details_title">Room Details</string>
|
||||
<string name="room_id">ID</string>
|
||||
<string name="room_type">Type</string>
|
||||
<string name="room_average_vr">Average VR</string>
|
||||
<string name="room_vr">VR</string>
|
||||
<string name="room_vr_full">Versus Rating</string>
|
||||
<string name="room_open_host">Open Host</string>
|
||||
<string name="room_copy_friend_code">Copy Friend Code</string>
|
||||
<string name="room_view_mii">View Mii</string>
|
||||
<string name="room_view_profile">View Profile</string>
|
||||
<string name="room_no_mii">This player has no valid Mii data.</string>
|
||||
<plurals name="time_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<plurals name="time_hours">
|
||||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</plurals>
|
||||
<plurals name="time_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<plurals name="time_seconds">
|
||||
<item quantity="one">%d second</item>
|
||||
<item quantity="other">%d seconds</item>
|
||||
</plurals>
|
||||
<string name="player_profile_title">Player Profile</string>
|
||||
<string name="player_profile_loading">Loading profile...</string>
|
||||
<string name="player_profile_unknown">Retro WFC does not know this player.</string>
|
||||
<string name="player_profile_rank">Rank #%1$d</string>
|
||||
<string name="player_profile_suspicious">Suspicious</string>
|
||||
<string name="player_profile_last_seen">Last Seen</string>
|
||||
<string name="player_profile_vr_day">VR (24h)</string>
|
||||
<string name="player_profile_vr_week">VR (Week)</string>
|
||||
<string name="player_profile_vr_month">VR (Month)</string>
|
||||
<string name="player_profile_days_ago">%1$d day(s) ago</string>
|
||||
<string name="player_profile_hours_ago">%1$d hour(s) ago</string>
|
||||
<string name="player_profile_minutes_ago">%1$d minute(s) ago</string>
|
||||
<string name="player_profile_just_now">Just now</string>
|
||||
<string name="mii_view_hint">Drag to turn the Mii; the thumbstick zooms.</string>
|
||||
<string name="mii_view_no_parts">Download the Mii parts on My Miis to see this Mii here.</string>
|
||||
<string name="dialog_close">Close</string>
|
||||
<string name="history_matches">Matches</string>
|
||||
<string name="history_days_1">Last 24 hours</string>
|
||||
<string name="history_days_7">Last 7 days</string>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The rooms are worked out from Retro WFC's answers as WheelWizard's RRLiveRooms and RrRoom do. */
|
||||
class LiveRoomsTest {
|
||||
|
||||
private fun player(pid: String, friendCode: String = "", name: String = pid, vr: Int? = null, map: List<String> = emptyList(), mii: ByteArray? = null) =
|
||||
RetroWfc.RoomPlayer(pid, name, friendCode, vr, null, isOpenHost = false, isSuspended = false, mii = mii, connectionMap = map)
|
||||
|
||||
private fun room(id: String, vararg players: RetroWfc.RoomPlayer, type: String = "anybody", rk: String? = "vs_10") =
|
||||
RetroWfc.Room(id, type, created = 0L, rk = rk, suspended = false, players = players.toList())
|
||||
|
||||
@Test
|
||||
fun roomsRetroWfcMergedAreSplitWhereTheirPlayersAreNotLinked() {
|
||||
// Two pairs linked both ways, the map listing every player, each player included.
|
||||
val merged = room(
|
||||
"MERGED",
|
||||
player("a", map = listOf("0", "1", "0", "0")),
|
||||
player("b", map = listOf("1", "0", "0", "0")),
|
||||
// c points at a, but a does not point back: no link.
|
||||
player("c", map = listOf("1", "0", "0", "1")),
|
||||
player("d", map = listOf("0", "0", "1", "0")),
|
||||
)
|
||||
val split = LiveRooms.splitMergedRooms(listOf(merged))
|
||||
assertEquals(listOf(listOf("a", "b"), listOf("c", "d")), split.map { r -> r.players.map { it.pid } })
|
||||
assertEquals(listOf("MERGED", "MERGED"), split.map { it.id })
|
||||
|
||||
// A map can leave the player out: b's first entry is a, a's first is b.
|
||||
val withoutSelf = room(
|
||||
"THREE",
|
||||
player("a", map = listOf("1", "0")),
|
||||
player("b", map = listOf("1", "0")),
|
||||
player("c", map = listOf("0", "0")),
|
||||
)
|
||||
assertEquals(listOf(listOf("a", "b"), listOf("c")), LiveRooms.splitMergedRooms(listOf(withoutSelf)).map { r -> r.players.map { it.pid } })
|
||||
|
||||
// Without any link, or all linked, a room stays whole.
|
||||
val unlinked = room("WHOLE", player("a"), player("b"))
|
||||
assertEquals(listOf(unlinked), LiveRooms.splitMergedRooms(listOf(unlinked)))
|
||||
val linked = room("PAIR", player("a", map = listOf("0", "1")), player("b", map = listOf("1", "0")))
|
||||
assertEquals(listOf(linked), LiveRooms.splitMergedRooms(listOf(linked)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playersCarryTheirLeaderboardPlaceAndMii() {
|
||||
val mii = MiiFactory.male("Racer")
|
||||
val rooms = listOf(
|
||||
room(
|
||||
"ROOM",
|
||||
player("1", "1111-1111-1111", vr = 9000, mii = MiiData.serialize(mii)),
|
||||
player("2", "2222-2222-2222", vr = 5001, mii = ByteArray(MiiData.SIZE) { 0xFF.toByte() }),
|
||||
player("3", "3333-3333-3333"),
|
||||
),
|
||||
)
|
||||
val leaderboard = listOf(
|
||||
RetroWfc.LeaderboardEntry("1", "", rank = 4, activeRank = null),
|
||||
// Found by friend code, placed by its active rank.
|
||||
RetroWfc.LeaderboardEntry("99", "2222-2222-2222", rank = null, activeRank = 12),
|
||||
)
|
||||
val (first, second, third) = LiveRooms.build(rooms, leaderboard).single().players
|
||||
assertEquals(4, first.leaderboardRank)
|
||||
assertEquals("#4", first.topLabel)
|
||||
assertEquals(mii, first.mii)
|
||||
assertEquals(12, second.leaderboardRank)
|
||||
// Bytes the PC cannot read as a Mii are no Mii.
|
||||
assertNull(second.mii)
|
||||
assertNull(third.leaderboardRank)
|
||||
assertEquals("", third.topLabel)
|
||||
assertEquals("--", third.vrText)
|
||||
// Without the leaderboard, nobody has a place.
|
||||
assertEquals(listOf(null, null, null), LiveRooms.build(rooms, null).single().players.map { it.leaderboardRank })
|
||||
// The average leaves out players without a VR and drops the fraction.
|
||||
assertEquals(7000, LiveRooms.build(rooms, null).single().averageVr)
|
||||
assertEquals(0, LiveRooms.build(listOf(room("EMPTY")), null).single().averageVr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFindsNamesAndFriendCodesOnce() {
|
||||
val rooms = LiveRooms.build(
|
||||
listOf(
|
||||
room("A", player("1", "1111-2222-3333", name = "Mario"), player("2", "4444-5555-6666", name = "Luigi")),
|
||||
room("B", player("1", "1111-2222-3333", name = "Mario"), player("3", "7777-8888-9999", name = "Peach")),
|
||||
),
|
||||
null,
|
||||
)
|
||||
assertEquals(listOf("Mario"), LiveRooms.search(rooms, "mar").map { it.name })
|
||||
assertEquals(listOf("Luigi"), LiveRooms.search(rooms, "5555").map { it.name })
|
||||
assertEquals(emptyList<LiveRooms.Player>(), LiveRooms.search(rooms, " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeOnlineIsItsTwoLargestUnits() {
|
||||
fun parts(seconds: Long) = LiveRooms.timeParts(seconds * 1000)
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Seconds to 45), parts(45))
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Minutes to 1, LiveRooms.TimeUnit.Seconds to 30), parts(90))
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Hours to 1), parts(3_600))
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Hours to 23, LiveRooms.TimeUnit.Minutes to 59), parts(86_399))
|
||||
// A day and five minutes shows the day alone: its hours are zero.
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Days to 1), parts(86_700))
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Days to 2, LiveRooms.TimeUnit.Hours to 3), parts(2 * 86_400 + 3 * 3_600 + 59))
|
||||
// A room from a clock ahead of the headset's has just opened.
|
||||
assertEquals(listOf(LiveRooms.TimeUnit.Seconds to 0), parts(-5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gameModesAreThePcs() {
|
||||
assertEquals("RR 150CC", LiveRooms.gameMode("vs_10", isPublic = true))
|
||||
assertEquals("RR Elim Battle", LiveRooms.gameMode("vs_15", isPublic = true))
|
||||
assertEquals("Regular", LiveRooms.gameMode("vs", isPublic = true))
|
||||
assertEquals("Unknown Mode", LiveRooms.gameMode("unknown", isPublic = true))
|
||||
assertEquals("Private Room", LiveRooms.gameMode(null, isPublic = false))
|
||||
val private = LiveRooms.build(listOf(room("P", type = "private", rk = "unknown")), null).single()
|
||||
assertEquals(false, private.isPublic)
|
||||
assertEquals("Private Room", private.gameMode)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,21 @@ class MiiRendererTest {
|
||||
assertTrue(MiiRenderer.render(resource(), girl, 64, bodies = bodies()).contentEquals(MiiRenderer.render(resource(), girl, 64)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWholeMiiIsSeenFromFurtherBack() {
|
||||
val mii = MiiFactory.male("Square").apply { faceShape = 5 }
|
||||
val face = MiiRenderer.render(resource(), mii, 64, bodies = bodies())
|
||||
val whole = MiiRenderer.render(resource(), mii, 64, bodies = bodies(), fullBody = true)
|
||||
// From further back the Mii is smaller, all of it in the picture...
|
||||
assertTrue(!whole.contentEquals(face))
|
||||
assertTrue(opaqueIn(whole, 0 until 64) < opaqueIn(face, 0 until 64))
|
||||
// ...and without a body there is no whole Mii to show: the face view stands in.
|
||||
assertTrue(MiiRenderer.render(resource(), mii, 64, fullBody = true).contentEquals(MiiRenderer.render(resource(), mii, 64)))
|
||||
// Nearer is bigger.
|
||||
val near = MiiRenderer.render(resource(), mii, 64, MiiRenderer.Pose(0f, 0f, 0f, 0f, 0f, 0f, zoom = 0.5f), bodies(), fullBody = true)
|
||||
assertTrue(opaqueIn(near, 0 until 64) > opaqueIn(whole, 0 until 64))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMiiWithoutItsFacelineCannotBeDrawn() {
|
||||
val mii = MiiFactory.male("Missing").apply { faceShape = 1 }
|
||||
|
||||
@@ -33,17 +33,89 @@ class RetroWfcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlineIsBeingInARoomNow() {
|
||||
fun roomsAreReadWithTheirPlayersAndMiis() {
|
||||
val mii = MiiData.serialize(MiiFactory.male("Racer").apply { miiId = 0x80000001L })
|
||||
val json = """
|
||||
{"rooms": [
|
||||
{"id": "XESURM", "players": []},
|
||||
{"id": "A", "players": [{"pid": "166930", "friendCode": "4123-1702-7346", "mii": {"data": "", "name": "x"}},
|
||||
{"pid": "204981", "friendCode": "4939-2144-4021"}]},
|
||||
{"id": "B", "players": [{"pid": "1", "friendCode": null}]}],
|
||||
"totalPlayers": 3}
|
||||
{"id": "EVFAQV", "type": "anybody", "created": "2026-09-23T22:48:15.1641988Z", "host": "29", "rk": "vs_10",
|
||||
"players": [
|
||||
{"pid": "166930", "name": "Racer", "friendCode": "1111-2222-3333", "vr": 13967, "br": 9999, "isOpenHost": true,
|
||||
"isSuspended": false, "mii": {"data": "${Base64.getEncoder().encodeToString(mii)}", "name": "Racer"},
|
||||
"connectionMap": ["0", "1"], "slotId": "29"},
|
||||
{"pid": 204981, "name": "Other", "friendCode": "4444-5555-6666", "vr": null, "mii": [{"data": "AAEC"}],
|
||||
"connectionMap": []}],
|
||||
"averageVR": 13967, "race": {"num": 10, "course": 324, "cc": 2, "trackName": "GCN Wario Colosseum"},
|
||||
"roomType": "Retro Tracks", "isPublic": true, "isJoinable": false, "isSuspended": true},
|
||||
{"id": "XESURM", "type": "private", "created": "2026-09-13T18:56:58Z", "rk": "unknown", "players": [], "suspend": false},
|
||||
{"type": "anybody", "created": "2026-09-13T18:56:58Z", "players": []},
|
||||
{"id": "BADDAY", "type": "anybody", "created": "not a date", "players": []}],
|
||||
"timestamp": "2026-09-23T23:22:18.9478744Z", "totalPlayers": 2}
|
||||
""".trimIndent()
|
||||
assertEquals(setOf("4123-1702-7346", "4939-2144-4021"), RetroWfc.parseOnlineFriendCodes(json))
|
||||
assertEquals(emptySet<String>(), RetroWfc.parseOnlineFriendCodes("""{"rooms": null}"""))
|
||||
val rooms = RetroWfc.parseRoomStatus(json)
|
||||
// Rooms without their ID or creation time are left out, as the PC could not read them.
|
||||
assertEquals(listOf("EVFAQV", "XESURM"), rooms.map { it.id })
|
||||
val room = rooms[0]
|
||||
assertEquals("anybody", room.type)
|
||||
assertEquals(Instant.parse("2026-09-23T22:48:15.1641988Z").toEpochMilli(), room.created)
|
||||
assertEquals("vs_10", room.rk)
|
||||
assertEquals(true, room.suspended)
|
||||
val (racer, other) = room.players
|
||||
assertEquals("166930", racer.pid)
|
||||
assertEquals("Racer", racer.name)
|
||||
assertEquals("1111-2222-3333", racer.friendCode)
|
||||
assertEquals(13967, racer.vr)
|
||||
assertEquals(9999, racer.br)
|
||||
assertEquals(true, racer.isOpenHost)
|
||||
assertArrayEquals(mii, racer.mii)
|
||||
assertEquals(listOf("0", "1"), racer.connectionMap)
|
||||
// A numeric ID, no VR, and a Mii too short to be one.
|
||||
assertEquals("204981", other.pid)
|
||||
assertNull(other.vr)
|
||||
assertNull(other.br)
|
||||
assertNull(other.mii)
|
||||
assertEquals(false, rooms[1].suspended)
|
||||
assertEquals(emptyList<RetroWfc.Room>(), RetroWfc.parseRoomStatus("""{"rooms": null}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLeaderboardGivesRanksByProfileAndFriendCode() {
|
||||
val json = """
|
||||
[{"pid": "592986326", "name": "s", "friendCode": "1111-2222-3333", "vr": 161570, "rank": 1, "isSuspicious": false},
|
||||
{"pid": "2", "friendCode": "", "rank": null, "activeRank": 7},
|
||||
{"name": "no pid", "rank": 3}]
|
||||
""".trimIndent()
|
||||
val entries = RetroWfc.parseLeaderboard(json)
|
||||
assertEquals(listOf("592986326", "2"), entries.map { it.pid })
|
||||
assertEquals(1, entries[0].rank)
|
||||
assertEquals("1111-2222-3333", entries[0].friendCode)
|
||||
assertNull(entries[1].rank)
|
||||
assertEquals(7, entries[1].activeRank)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlayersProfileIsReadAsThePcReadsIt() {
|
||||
val mii = MiiData.serialize(MiiFactory.female("Profile"))
|
||||
val json = """
|
||||
{"pid": "601298307", "name": "Profile", "friendCode": "1111-2222-3333", "vr": 6050, "rank": 38439,
|
||||
"lastSeen": "2026-09-21T02:43:42.584862Z", "isSuspicious": true,
|
||||
"vrStats": {"last24Hours": 0, "lastWeek": -4967, "lastMonth": 257},
|
||||
"miiImageBase64": null, "miiData": "${Base64.getEncoder().encodeToString(mii)}", "badges": null}
|
||||
""".trimIndent()
|
||||
val profile = RetroWfc.parsePlayerProfile(json)
|
||||
assertEquals("Profile", profile.name)
|
||||
assertEquals("1111-2222-3333", profile.friendCode)
|
||||
assertEquals(6050, profile.vr)
|
||||
assertEquals(38439, profile.rank)
|
||||
assertEquals(Instant.parse("2026-09-21T02:43:42.584862Z").toEpochMilli(), profile.lastSeen)
|
||||
assertEquals(true, profile.isSuspicious)
|
||||
assertEquals(-4967, profile.vrStats!!.lastWeek)
|
||||
assertEquals(257, profile.vrStats!!.lastMonth)
|
||||
assertArrayEquals(mii, profile.mii)
|
||||
// A profile without stats or a Mii still reads.
|
||||
val bare = RetroWfc.parsePlayerProfile("""{"name": "x", "friendCode": "1111-2222-3333"}""")
|
||||
assertNull(bare.vrStats)
|
||||
assertNull(bare.mii)
|
||||
assertNull(bare.lastSeen)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,6 +159,7 @@ class RetroWfcTest {
|
||||
fun requestsAreTheOnesWheelWizardMakes() {
|
||||
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675", RetroWfc.profileUrl("0349-6103-6675"))
|
||||
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675/history?days=30", RetroWfc.historyUrl("0349-6103-6675", 30))
|
||||
assertEquals("https://rwfc.net/api/leaderboard/top/50", RetroWfc.topUrl(50))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user