feat(tracking): redesign tracking settings

This commit is contained in:
tapframe 2026-07-22 23:38:10 +05:30
parent b817eb5b14
commit 7fb8206107
14 changed files with 1581 additions and 908 deletions

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.simkl
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.nuvio.app.R
@Composable
actual fun simklBrandPainter(asset: SimklBrandAsset): Painter =
painterResource(
id = when (asset) {
SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph
SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark
},
)

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="48dp"
android:height="48dp"
android:viewportWidth="1536"
android:viewportHeight="1536">
<path android:pathData="M245.76,0H1290.24C1425.97,0 1536,110.03 1536,245.76V1290.24C1536,1425.97 1425.97,1536 1290.24,1536H245.76C110.03,1536 0,1425.97 0,1290.24V245.76C0,110.03 110.03,0 245.76,0Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="768"
android:startY="0"
android:endX="768"
android:endY="1536"
android:startColor="#FF000000"
android:endColor="#FF333333"
android:type="linear" />
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M205.832,970.284c0,35.322 1.82,65.9 5.433,91.774 6.709,53.176 28.198,94.359 64.507,123.51 33.579,27.302 88.907,45.656 165.984,55.065 63.178,7.983 165.983,11.993 308.45,11.993 234.287,0 377.455,-7.982 429.428,-23.947 52.859,-15.964 90.936,-45.77 114.232,-89.46 23.295,-44.153 34.944,-113.446 34.944,-207.843 0,-84.525 -11.907,-145.798 -35.645,-183.858 -15.702,-25.836 -36.64,-45.772 -62.885,-59.885 -26.244,-14.074 -61.52,-24.64 -105.9,-31.697 -43.937,-7.018 -145.452,-12.917 -304.58,-17.622 -175.752,-5.63 -274.576,-11.49 -296.545,-17.584 -26.024,-7.519 -39,-28.188 -39,-62.006 0,-35.205 12.312,-57.03 36.972,-65.514 21.97,-7.018 92.115,-10.566 210.476,-10.566 119.65,0 194.955,1.427 225.92,4.203 34.942,3.317 55.584,18.04 61.85,44.26 2.25,8.908 3.835,24.833 4.72,47.777h271c0.368,-22.442 0.589,-38.83 0.589,-49.127 0,-95.475 -21.527,-161.676 -64.58,-198.657 -31.847,-26.683 -83.858,-45.386 -156.032,-56.183 -54.7,-7.944 -148.4,-11.915 -281.099,-11.915 -204.024,0 -333.149,4.704 -387.407,14.075 -60.082,10.797 -104.942,33.047 -134.505,66.748 -39.477,44.538 -59.198,121.158 -59.198,229.823 0,51.555 4.017,93.278 12.09,125.129 14.338,57.649 44.822,98.407 91.451,122.276 36.309,18.741 98.861,29.538 187.622,32.353 34.058,0.926 108.444,4.204 223.228,9.833 111.172,5.63 184.23,8.445 219.212,8.445 50.645,0.926 82.678,8.213 96.168,21.787 9.843,9.832 14.783,26.915 14.783,51.286 0,35.63 -7.852,58.612 -23.518,68.908 -11.206,7.018 -34.502,11.953 -69.887,14.73 -18.837,0.963 -87.84,1.658 -207.011,2.12 -86.954,-0.462 -138.265,-0.924 -153.93,-1.387 -31.369,-0.926 -53.338,-2.584 -65.871,-4.936 -12.532,-2.314 -23.738,-6.44 -33.58,-12.996 -18.835,-13.573 -28.014,-44.036 -27.571,-91.389H205.832v50.477Z" />
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="122dp"
android:height="20dp"
android:viewportWidth="989"
android:viewportHeight="162.133">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M0,115.012a105.623,105.623 0,0 0,0.935 15.345c1.15,8.9 4.959,15.778 11.356,20.556 5.894,4.632 15.669,7.672 29.253,9.192q16.6,2.063 54.337,2.027c41.257,0 66.413,-1.3 75.613,-3.909 9.344,-2.606 16.028,-7.455 20.125,-14.548s6.181,-18.385 6.181,-33.73c0,-13.68 -2.012,-23.668 -6.037,-29.893a27.048,27.048 0,0 0,-10.638 -9.7c-4.456,-2.244 -10.35,-3.981 -17.825,-5.139s-24.581,-2.1 -51.462,-2.9q-44.527,-1.3 -50.025,-2.823c-4.457,-1.158 -6.613,-4.56 -6.613,-10.061q0,-8.577 6.253,-10.64c3.666,-1.158 15.454,-1.737 35.435,-1.737 20.125,0 32.775,0.217 38.022,0.579 5.893,0.507 9.343,2.678 10.421,6.442a34.224,34.224 0,0 1,0.791 6.876h47.941a56.136,56.136 0,0 1,-0.863 -7.962c0,-15.344 -3.737,-26.057 -11.212,-31.992 -5.607,-4.27 -14.663,-7.31 -27.241,-9.047Q140.517,-0.007 105.8,-0.007q-53.259,0 -67.49,2.317C27.816,4.047 20.053,7.666 14.878,13.167 7.978,20.405 4.6,32.926 4.6,50.587c0,8.4 0.647,15.2 2.013,20.412 2.443,9.409 7.547,16.068 15.453,19.9 6.109,3.113 16.675,4.85 31.7,5.284 5.75,0.145 18.256,0.724 37.662,1.592 18.76,0.941 31.122,1.376 37.016,1.376 8.553,0.217 13.944,1.375 16.244,3.546 1.653,1.665 2.515,4.488 2.515,8.4 0,5.791 -1.365,9.555 -3.953,11.219 -1.941,1.158 -5.894,1.955 -11.787,2.389 -3.235,0.145 -14.879,0.289 -34.932,0.362 -14.734,-0.073 -23.431,-0.145 -26.018,-0.217a77.288,77.288 0,0 1,-11.141 -0.8,14.991 14.991,0 0,1 -5.606,-2.243c-3.235,-2.244 -4.816,-7.311 -4.672,-15.273H1.15L0,115.012ZM216.2,162.133h50.6V0h-50.6v162.133ZM289.8,162.133h48.875l-1.222,-120.441h8.7l61.381,120.441h41.113l61.378,-120.441h8.122l-1.222,120.441H565.8V0h-89.412l-48.372,105.676L379.644,0H289.8v162.133ZM588.8,162.133h50.6V97.28h27.169l72.741,64.853h75.18L712.641,76.579 800.71,0h-73.2l-60.938,55.589H639.4V0h-50.6v162.133ZM818.8,162.133H989v-41.691H869.4V0h-50.6v162.133Z" />
<path
android:fillAlpha="0.302"
android:fillColor="#FFFFFFFF"
android:pathData="M928.39,8.912V2.204H887.2v6.708h17.16v40.245h6.87V8.912ZM986.73,49.157 976.44,2.204H973l-15.44,33.538 -15.44,-33.538h-3.44l-10.29,46.953h6.86l5.15,-30.184 15.44,30.184h3.44l15.44,-30.184 5.15,30.184Z" />
</vector>

View file

@ -0,0 +1 @@
<svg style="background-color:#ffffff00" xmlns="http://www.w3.org/2000/svg" width="1536" height="1536"><defs><linearGradient id="a" x1="49.89%" y1="-.045%" x2="49.89%" y2="99.858%"><stop offset="0%"/><stop stop-color="#333" offset="100%"/></linearGradient></defs><rect width="1536" height="1536" fill="url(#a)" rx="245.76" ry="245.76"/><path d="M205.832 970.284c0 35.322 1.82 65.9 5.433 91.774 6.709 53.176 28.198 94.359 64.507 123.51 33.579 27.302 88.907 45.656 165.984 55.065 63.178 7.983 165.983 11.993 308.45 11.993 234.287 0 377.455-7.982 429.428-23.947 52.859-15.964 90.936-45.77 114.232-89.46 23.295-44.153 34.944-113.446 34.944-207.843 0-84.525-11.907-145.798-35.645-183.858-15.702-25.836-36.64-45.772-62.885-59.885-26.244-14.074-61.52-24.64-105.9-31.697-43.937-7.018-145.452-12.917-304.58-17.622-175.752-5.63-274.576-11.49-296.545-17.584-26.024-7.519-39-28.188-39-62.006 0-35.205 12.312-57.03 36.972-65.514 21.97-7.018 92.115-10.566 210.476-10.566 119.65 0 194.955 1.427 225.92 4.203 34.942 3.317 55.584 18.04 61.85 44.26 2.25 8.908 3.835 24.833 4.72 47.777h271c.368-22.442.589-38.83.589-49.127 0-95.475-21.527-161.676-64.58-198.657-31.847-26.683-83.858-45.386-156.032-56.183-54.7-7.944-148.4-11.915-281.099-11.915-204.024 0-333.149 4.704-387.407 14.075-60.082 10.797-104.942 33.047-134.505 66.748-39.477 44.538-59.198 121.158-59.198 229.823 0 51.555 4.017 93.278 12.09 125.129 14.338 57.649 44.822 98.407 91.451 122.276 36.309 18.741 98.861 29.538 187.622 32.353 34.058.926 108.444 4.204 223.228 9.833 111.172 5.63 184.23 8.445 219.212 8.445 50.645.926 82.678 8.213 96.168 21.787 9.843 9.832 14.783 26.915 14.783 51.286 0 35.63-7.852 58.612-23.518 68.908-11.206 7.018-34.502 11.953-69.887 14.73-18.837.963-87.84 1.658-207.011 2.12-86.954-.462-138.265-.924-153.93-1.387-31.369-.926-53.338-2.584-65.871-4.936-12.532-2.314-23.738-6.44-33.58-12.996-18.835-13.573-28.014-44.036-27.571-91.389H205.832v50.824-.347z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="989" height="162.133"><g fill="#fff"><path d="M0 115.012a105.623 105.623 0 0 0 .935 15.345c1.15 8.9 4.959 15.778 11.356 20.556 5.894 4.632 15.669 7.672 29.253 9.192q16.6 2.063 54.337 2.027c41.257 0 66.413-1.3 75.613-3.909 9.344-2.606 16.028-7.455 20.125-14.548s6.181-18.385 6.181-33.73c0-13.68-2.012-23.668-6.037-29.893a27.048 27.048 0 0 0-10.638-9.7c-4.456-2.244-10.35-3.981-17.825-5.139s-24.581-2.1-51.462-2.9q-44.527-1.3-50.025-2.823c-4.457-1.158-6.613-4.56-6.613-10.061q0-8.577 6.253-10.64c3.666-1.158 15.454-1.737 35.435-1.737 20.125 0 32.775.217 38.022.579 5.893.507 9.343 2.678 10.421 6.442a34.224 34.224 0 0 1 .791 6.876h47.941a56.136 56.136 0 0 1-.863-7.962c0-15.344-3.737-26.057-11.212-31.992-5.607-4.27-14.663-7.31-27.241-9.047Q140.517-.007 105.8-.007q-53.259 0-67.49 2.317C27.816 4.047 20.053 7.666 14.878 13.167 7.978 20.405 4.6 32.926 4.6 50.587c0 8.4.647 15.2 2.013 20.412 2.443 9.409 7.547 16.068 15.453 19.9 6.109 3.113 16.675 4.85 31.7 5.284 5.75.145 18.256.724 37.662 1.592 18.76.941 31.122 1.376 37.016 1.376 8.553.217 13.944 1.375 16.244 3.546 1.653 1.665 2.515 4.488 2.515 8.4 0 5.791-1.365 9.555-3.953 11.219-1.941 1.158-5.894 1.955-11.787 2.389-3.235.145-14.879.289-34.932.362-14.734-.073-23.431-.145-26.018-.217a77.288 77.288 0 0 1-11.141-.8 14.991 14.991 0 0 1-5.606-2.243c-3.235-2.244-4.816-7.311-4.672-15.273H1.15L0 115.012ZM216.2 162.133h50.6V0h-50.6v162.133ZM289.8 162.133h48.875l-1.222-120.441h8.7l61.381 120.441h41.113l61.378-120.441h8.122l-1.222 120.441H565.8V0h-89.412l-48.372 105.676L379.644 0H289.8v162.133ZM588.8 162.133h50.6V97.28h27.169l72.741 64.853h75.18L712.641 76.579 800.71 0h-73.2l-60.938 55.589H639.4V0h-50.6v162.133ZM818.8 162.133H989v-41.691H869.4V0h-50.6v162.133Z"/><g opacity=".302"><path d="M928.39 8.912V2.204H887.2v6.708h17.16v40.245h6.87V8.912ZM986.73 49.157 976.44 2.204H973l-15.44 33.538-15.44-33.538h-3.44l-10.29 46.953h6.86l5.15-30.184 15.44 30.184h3.44l15.44-30.184 5.15 30.184Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104.55 45">
<svg xmlns="http://www.w3.org/2000/svg" width="104.55" height="45" viewBox="0 0 104.55 45">
<g transform="translate(-65.25 0)">
<path fill="#FFFFFF" d="M65.25,19.5199v-5.73h18.18v5.73H65.25zM76.93,44.7099c-2.42,0 -4.3,-0.62 -5.64,-1.86c-1.34,-1.24 -2.01,-3.01 -2.01,-5.32l-0.03,-15.44c0,-0.6 0.13,-1.14 0.41,-1.63c0.27,-0.49 0.65,-0.8 1.13,-0.93l-1.54,-4.52v-0.81l1.51,-9.1299h5V35.8999c0,1.08 0.24,1.83 0.71,2.26c0.48,0.43 1.36,0.64 2.67,0.64c0.84,0 1.61,0 2.32,-0.01c0.71,-0.01 1.35,-0.02 1.93,-0.04v5.61c-0.99,0.19 -2.05,0.29 -3.2,0.32c-1.15,0.03 -2.23,0.04 -3.25,0.04zM86,44.2599v-30.47h5.64l0.73,11.73l-0.59,-1.62c0.13,-1.23 0.38,-2.48 0.75,-3.74c0.37,-1.27 0.89,-2.43 1.56,-3.49c0.67,-1.06 1.54,-1.91 2.62,-2.56c1.08,-0.64 2.38,-0.96 3.91,-0.96c0.39,0 0.77,0.02 1.13,0.06c0.36,0.04 0.7,0.1 1.02,0.2v7.04c-0.63,-0.22 -1.3,-0.36 -2.01,-0.42c-0.71,-0.06 -1.37,-0.08 -1.98,-0.08c-0.73,0 -1.51,0.14 -2.35,0.43c-0.84,0.29 -1.61,0.72 -2.32,1.3c-0.71,0.58 -1.22,1.29 -1.54,2.15v20.44H86zM112.7,44.6c-1.82,0 -3.46,-0.35 -4.9,-1.05c-1.44,-0.7 -2.57,-1.68 -3.38,-2.93c-0.81,-1.26 -1.21,-2.7 -1.21,-4.34c0,-1.79 0.33,-3.26 0.98,-4.43c0.65,-1.16 1.59,-2.1 2.82,-2.82c1.23,-0.72 2.7,-1.25 4.41,-1.61c1.17,-0.26 2.42,-0.4701 3.74,-0.6301c1.32,-0.16 2.56,-0.2899 3.7,-0.3899c1.14,-0.1001 2.02,-0.17 2.64,-0.21v-1.26c0,-2.08 -0.41,-3.64 -1.24,-4.68c-0.83,-1.03 -2.23,-1.55 -4.2,-1.55c-1.27,0 -2.33,0.16 -3.18,0.47c-0.86,0.32 -1.5,0.9 -1.94,1.75c-0.44,0.85 -0.67,2.07 -0.71,3.67h-6c-0.21,-2.62 0.17,-4.78 1.13,-6.46c0.96,-1.68 2.39,-2.9301 4.3,-3.73c1.91,-0.8 4.17,-1.2 6.77,-1.2c1.53,0 2.98,0.17 4.36,0.52c1.38,0.35 2.6,0.93 3.66,1.76c1.06,0.83 1.9,1.96 2.51,3.39c0.61,1.43 0.92,3.25 0.92,5.45v19.94h-6.45l0.08,-6.8101c-0.35,2.05 -1.25,3.75 -2.69,5.11c-1.44,1.3601 -3.48,2.0401 -6.1,2.0401zM114.49,39.6c0.97,0 1.96,-0.1601 2.96,-0.4901c1,-0.33 1.88,-0.8 2.64,-1.4399c0.75,-0.63 1.22,-1.38 1.41,-2.23v-5.59c-0.74,0.08 -1.62,0.2 -2.64,0.38c-1.02,0.18 -1.89,0.35 -2.61,0.52c-2.08,0.41 -3.59,0.96 -4.52,1.66c-0.93,0.7 -1.4,1.77 -1.4,3.23c0,0.82 0.2,1.53 0.59,2.12c0.39,0.6 0.9,1.06 1.54,1.38c0.63,0.33 1.31,0.48 2.04,0.46zM129.44,44.2599v-30.48h6.67v30.48H129.44zM135.4,36.6499l-0.67,-5.81l3.71,-6.46l7.53,-10.61h7.8l-10.42,14.23l-2.66,2.01l-5.28,6.62zM146.84,44.2599l-8.89,-15.43l5.03,-3.86l11.8,19.28H146.84z"/>
<path fill="#FFFFFF" d="M151.62,19.5199v-5.73h18.18v5.73H151.62zM163.29,44.7099c-2.42,0 -4.3,-0.62 -5.64,-1.86c-1.34,-1.24 -2.01,-3.01 -2.01,-5.32l-0.03,-15.44c0,-0.6 0.13,-1.14 0.41,-1.63c0.27,-0.49 0.65,-0.8 1.13,-0.93l-1.54,-4.52v-0.81l1.51,-9.1299h5V35.8999c0,1.08 0.24,1.83 0.71,2.26c0.48,0.43 1.36,0.64 2.67,0.64c0.84,0 1.61,0 2.32,-0.01c0.71,-0.01 1.35,-0.02 1.93,-0.04v5.61c-0.99,0.19 -2.05,0.29 -3.2,0.32c-1.15,0.03 -2.23,0.04 -3.25,0.04z"/>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -1121,10 +1121,24 @@
<string name="settings_trakt_open_login">Open Trakt Login</string>
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
<string name="settings_tracking_intro_description">Connect one or more tracking services. Playback events are sent to every connected tracker, while you choose one source for library and watch progress views.</string>
<string name="settings_tracking_data_sources">DATA SOURCES</string>
<string name="settings_tracking_features">SOURCE PREFERENCES</string>
<string name="settings_tracking_viewing_discovery">VIEWING AND DISCOVERY</string>
<string name="settings_tracking_services">TRACKING SERVICES</string>
<string name="settings_tracking_approval_redirect">After approval, you will be redirected back automatically.</string>
<string name="settings_tracking_connect_first">Connect %1$s first</string>
<string name="settings_tracking_disconnect_title">Disconnect %1$s?</string>
<string name="settings_tracking_disconnect_description">This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio.</string>
<string name="settings_tracking_source_fallback">%1$s is unavailable. Using %2$s until it reconnects.</string>
<string name="settings_tracking_progress_refresh_failed">The source changed, but the latest watch progress could not be refreshed.</string>
<string name="settings_tracking_nuvio_library_description">Keep saved titles in your Nuvio library.</string>
<string name="settings_tracking_trakt_library_description">Use your Trakt watchlist and personal lists.</string>
<string name="settings_tracking_simkl_library_description">Use your Simkl plan-to-watch and status lists.</string>
<string name="settings_tracking_nuvio_progress_description">Resume with progress stored by Nuvio Sync.</string>
<string name="settings_tracking_trakt_progress_description">Resume with playback progress from Trakt.</string>
<string name="settings_tracking_simkl_progress_description">Resume with playback progress from Simkl.</string>
<string name="settings_tracking_tmdb_recommendations_description">Use TMDB recommendations on details pages.</string>
<string name="settings_tracking_trakt_recommendations_description">Use personalized related titles from Trakt.</string>
<string name="settings_simkl_connect">Connect Simkl</string>
<string name="settings_simkl_connected_as">Connected as %1$s</string>
<string name="settings_simkl_default_user">Simkl user</string>

View file

@ -230,11 +230,12 @@ internal fun SettingsSection(
@Composable
internal fun SettingsNavigationRow(
title: String,
description: String,
description: String?,
icon: ImageVector? = null,
iconPainter: Painter? = null,
enabled: Boolean = true,
isTablet: Boolean,
trailingContent: (@Composable RowScope.() -> Unit)? = null,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -294,15 +295,18 @@ internal fun SettingsNavigationRow(
color = tokens.colors.textPrimary,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
modifier = Modifier.alpha(0.92f),
)
if (!description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
modifier = Modifier.alpha(0.92f),
)
}
}
}
trailingContent?.invoke(this)
}
}

View file

@ -0,0 +1,255 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvio
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_close
import nuvio.composeapp.generated.resources.cd_selected
import org.jetbrains.compose.resources.stringResource
internal data class TrackingPickerOption<T>(
val value: T,
val title: String,
val description: String? = null,
val enabled: Boolean = true,
val unavailableReason: String? = null,
)
@Composable
internal fun <T> TrackingAdaptivePicker(
isTablet: Boolean,
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
if (isTablet) {
TrackingPickerDialog(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
onSelected = onSelected,
onDismiss = onDismiss,
)
} else {
TrackingPickerBottomSheet(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
onSelected = onSelected,
onDismiss = onDismiss,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> TrackingPickerBottomSheet(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
fun dismiss() {
scope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
}
NuvioModalBottomSheet(
onDismissRequest = ::dismiss,
sheetState = sheetState,
) {
TrackingPickerContent(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
isTablet = false,
onSelected = { value ->
onSelected(value)
dismiss()
},
onDismiss = ::dismiss,
modifier = Modifier.navigationBarsPadding(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> TrackingPickerDialog(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = tokens.components.dialogMaxWidth),
shape = tokens.shapes.dialog,
color = tokens.colors.surfaceDialog,
) {
TrackingPickerContent(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
isTablet = true,
onSelected = { value ->
onSelected(value)
onDismiss()
},
onDismiss = onDismiss,
)
}
}
}
@Composable
private fun <T> TrackingPickerContent(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
isTablet: Boolean,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val horizontalPadding = if (isTablet) tokens.spacing.dialogPadding else tokens.spacing.screenHorizontal
Column(
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = if (isTablet) tokens.spacing.dialogPadding else 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 520.dp)
.verticalScroll(rememberScrollState()),
) {
options.forEachIndexed { index, option ->
if (index > 0) {
NuvioBottomSheetDivider()
}
TrackingPickerOptionRow(
option = option,
selected = option.value == selectedValue,
isTablet = isTablet,
onClick = { onSelected(option.value) },
)
}
}
if (isTablet) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_close))
}
}
}
}
}
@Composable
private fun <T> TrackingPickerOptionRow(
option: TrackingPickerOption<T>,
selected: Boolean,
isTablet: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val description = listOfNotNull(
option.description?.takeIf(String::isNotBlank),
option.unavailableReason?.takeIf(String::isNotBlank),
).joinToString("\n").takeIf(String::isNotBlank)
SettingsNavigationRow(
title = option.title,
description = description,
enabled = option.enabled,
isTablet = isTablet,
trailingContent = {
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = stringResource(Res.string.cd_selected),
tint = tokens.colors.accent,
modifier = Modifier.size(24.dp),
)
}
},
onClick = onClick,
)
}

View file

@ -0,0 +1,765 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.simkl.SimklAuthError
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklAuthUiState
import com.nuvio.app.features.simkl.SimklBrandAsset
import com.nuvio.app.features.simkl.SimklConnectionMode
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.simkl.simklBrandPainter
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.traktBrandPainter
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired
import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked
import nuvio.composeapp.generated.resources.settings_simkl_connect
import nuvio.composeapp.generated.resources.settings_simkl_connected_as
import nuvio.composeapp.generated.resources.settings_simkl_connected_description
import nuvio.composeapp.generated.resources.settings_simkl_default_user
import nuvio.composeapp.generated.resources.settings_simkl_disconnect
import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in
import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback
import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials
import nuvio.composeapp.generated.resources.settings_simkl_open_login
import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description
import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action
import nuvio.composeapp.generated.resources.settings_simkl_sync_now
import nuvio.composeapp.generated.resources.settings_simkl_visit
import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect
import nuvio.composeapp.generated.resources.settings_tracking_disconnect_description
import nuvio.composeapp.generated.resources.settings_tracking_disconnect_title
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_connect
import nuvio.composeapp.generated.resources.settings_trakt_connected_as
import nuvio.composeapp.generated.resources.settings_trakt_default_user
import nuvio.composeapp.generated.resources.settings_trakt_disconnect
import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser
import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in
import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
import nuvio.composeapp.generated.resources.settings_trakt_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
import org.jetbrains.compose.resources.stringResource
internal enum class TrackingBrand(val displayName: String) {
NUVIO("Nuvio"),
TRAKT("Trakt"),
SIMKL("Simkl"),
TMDB("TMDB"),
}
internal enum class TrackingConnectionCardMode {
DISCONNECTED,
AWAITING_APPROVAL,
CONNECTED,
}
internal fun isTrackingBrandAvailable(
brand: TrackingBrand,
traktConnected: Boolean,
simklConnected: Boolean,
): Boolean = when (brand) {
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> true
TrackingBrand.TRAKT -> traktConnected
TrackingBrand.SIMKL -> simklConnected
}
internal fun TraktConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) {
TraktConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED
TraktConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL
TraktConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED
}
internal fun SimklConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) {
SimklConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED
SimklConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL
SimklConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED
}
@Composable
internal fun TrackingProviderCards(
isTablet: Boolean,
traktUiState: TraktAuthUiState,
simklUiState: SimklAuthUiState,
) {
val syncState by remember {
SimklSyncRepository.ensureLoaded()
SimklSyncRepository.state
}.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showSyncInfo by rememberSaveable { mutableStateOf(false) }
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val useTwoColumns = maxWidth >= 600.dp
if (useTwoColumns) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
TraktProviderCard(
uiState = traktUiState,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
SimklProviderCard(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = {
scope.launch {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
}
},
onInfoRequested = { showSyncInfo = true },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
) {
TraktProviderCard(
uiState = traktUiState,
modifier = Modifier.fillMaxWidth(),
)
SimklProviderCard(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = {
scope.launch {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
}
},
onInfoRequested = { showSyncInfo = true },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
if (showSyncInfo) {
SimklSyncInfoDialog(onDismiss = { showSyncInfo = false })
}
}
@Composable
private fun TraktProviderCard(
uiState: TraktAuthUiState,
modifier: Modifier,
) {
TrackingProviderCard(
brand = TrackingBrand.TRAKT,
mode = uiState.mode.toTrackingConnectionCardMode(),
credentialsConfigured = uiState.credentialsConfigured,
isLoading = uiState.isLoading,
connectedLabel = stringResource(
Res.string.settings_trakt_connected_as,
uiState.username ?: stringResource(Res.string.settings_trakt_default_user),
),
connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description),
signInDescription = stringResource(Res.string.settings_trakt_sign_in_description),
finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in),
approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect),
connectLabel = stringResource(Res.string.settings_trakt_connect),
openLoginLabel = stringResource(Res.string.settings_trakt_open_login),
disconnectLabel = stringResource(Res.string.settings_trakt_disconnect),
missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials),
statusMessage = uiState.statusMessage.takeUnless {
uiState.mode == TraktConnectionMode.CONNECTED
},
errorMessage = uiState.errorMessage,
onConnectRequested = TraktAuthRepository::onConnectRequested,
onResumeAuthorization = {
TraktAuthRepository.pendingAuthorizationUrl()
?: TraktAuthRepository.onConnectRequested()
},
onCancelAuthorization = TraktAuthRepository::onCancelAuthorization,
onDisconnect = TraktAuthRepository::onDisconnectRequested,
modifier = modifier,
)
}
@Composable
private fun SimklProviderCard(
uiState: SimklAuthUiState,
isSyncing: Boolean,
syncErrorMessage: String?,
onSyncRequested: () -> Unit,
onInfoRequested: () -> Unit,
modifier: Modifier,
) {
TrackingProviderCard(
brand = TrackingBrand.SIMKL,
mode = uiState.mode.toTrackingConnectionCardMode(),
credentialsConfigured = uiState.credentialsConfigured,
isLoading = uiState.isLoading,
connectedLabel = stringResource(
Res.string.settings_simkl_connected_as,
uiState.username ?: stringResource(Res.string.settings_simkl_default_user),
),
connectedDescription = stringResource(Res.string.settings_simkl_connected_description),
signInDescription = stringResource(Res.string.settings_simkl_sign_in_description),
finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in),
approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect),
connectLabel = stringResource(Res.string.settings_simkl_connect),
openLoginLabel = stringResource(Res.string.settings_simkl_open_login),
disconnectLabel = stringResource(Res.string.settings_simkl_disconnect),
syncLabel = stringResource(Res.string.settings_simkl_sync_now),
infoLabel = stringResource(Res.string.settings_simkl_sync_info_action),
isSyncing = isSyncing,
missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials),
errorMessage = simklErrorMessage(uiState.error) ?: syncErrorMessage,
websiteLabel = stringResource(Res.string.settings_simkl_visit),
websiteUrl = SIMKL_WEBSITE_URL,
onConnectRequested = SimklAuthRepository::onConnectRequested,
onResumeAuthorization = {
SimklAuthRepository.pendingAuthorizationUrl()
?: SimklAuthRepository.onConnectRequested()
},
onCancelAuthorization = SimklAuthRepository::onCancelAuthorization,
onSyncRequested = onSyncRequested,
onInfoRequested = onInfoRequested,
onDisconnect = SimklAuthRepository::onDisconnectRequested,
modifier = modifier,
)
}
@Composable
private fun TrackingProviderCard(
brand: TrackingBrand,
mode: TrackingConnectionCardMode,
credentialsConfigured: Boolean,
isLoading: Boolean,
connectedLabel: String,
connectedDescription: String,
signInDescription: String,
finishSignInLabel: String,
approvalDescription: String,
connectLabel: String,
openLoginLabel: String,
disconnectLabel: String,
missingCredentialsMessage: String,
modifier: Modifier = Modifier,
syncLabel: String? = null,
infoLabel: String? = null,
isSyncing: Boolean = false,
statusMessage: String? = null,
errorMessage: String? = null,
websiteLabel: String? = null,
websiteUrl: String? = null,
onConnectRequested: () -> String?,
onResumeAuthorization: () -> String?,
onCancelAuthorization: () -> Unit,
onSyncRequested: (() -> Unit)? = null,
onInfoRequested: (() -> Unit)? = null,
onDisconnect: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val uriHandler = LocalUriHandler.current
val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser)
var browserError by rememberSaveable { mutableStateOf(false) }
var showDisconnectDialog by rememberSaveable { mutableStateOf(false) }
fun openUrl(url: String?) {
if (url.isNullOrBlank()) return
browserError = false
runCatching { uriHandler.openUri(url) }
.onFailure { browserError = true }
}
Box(
modifier = modifier
.clip(tokens.shapes.card)
.background(brand.cardBrush(), tokens.shapes.card)
.border(
width = tokens.borders.hairline,
color = Color.White.copy(alpha = 0.2f),
shape = tokens.shapes.card,
),
) {
TrackingBrandGlyph(
brand = brand,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp)
.size(150.dp)
.alpha(0.08f),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(if (mode == TrackingConnectionCardMode.CONNECTED) 20.dp else 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
TrackingBrandWordmark(
brand = brand,
contentDescription = brand.displayName,
)
when (mode) {
TrackingConnectionCardMode.CONNECTED -> {
TrackingConnectedIdentity(
label = connectedLabel,
description = connectedDescription,
)
if (syncLabel != null && onSyncRequested != null) {
TrackingBrandPrimaryButton(
label = syncLabel,
loading = isSyncing,
enabled = !isLoading && !isSyncing,
onClick = onSyncRequested,
showSyncIcon = true,
)
}
}
TrackingConnectionCardMode.AWAITING_APPROVAL -> {
Text(
text = finishSignInLabel,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold,
)
Text(
text = approvalDescription,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.78f),
)
TrackingBrandPrimaryButton(
label = openLoginLabel,
loading = isLoading,
enabled = !isLoading,
onClick = { openUrl(onResumeAuthorization()) },
)
OutlinedButton(
onClick = onCancelAuthorization,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.44f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White,
disabledContentColor = Color.White.copy(alpha = 0.45f),
),
) {
Text(stringResource(Res.string.action_cancel))
}
}
TrackingConnectionCardMode.DISCONNECTED -> {
Text(
text = signInDescription,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.82f),
)
TrackingBrandPrimaryButton(
label = connectLabel,
loading = isLoading,
enabled = credentialsConfigured && !isLoading,
onClick = { openUrl(onConnectRequested()) },
)
if (!credentialsConfigured) {
TrackingBrandMessage(
text = missingCredentialsMessage,
isError = true,
)
}
}
}
statusMessage?.takeIf(String::isNotBlank)?.let { message ->
TrackingBrandMessage(text = message, isError = false)
}
errorMessage?.takeIf(String::isNotBlank)?.let { message ->
TrackingBrandMessage(text = message, isError = true)
}
if (browserError) {
TrackingBrandMessage(text = failedOpenBrowserMessage, isError = true)
}
val hasWebsiteAction = !websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank()
val hasDisconnectAction = mode == TrackingConnectionCardMode.CONNECTED
val hasInfoAction = mode == TrackingConnectionCardMode.CONNECTED &&
infoLabel != null && onInfoRequested != null
val footerActionCount = listOf(
hasWebsiteAction,
hasDisconnectAction,
hasInfoAction,
).count { it }
if (footerActionCount > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasWebsiteAction) {
TextButton(
onClick = { openUrl(websiteUrl) },
modifier = if (footerActionCount > 1) Modifier.weight(0.95f) else Modifier,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color.White),
) {
Text(
text = websiteLabel.orEmpty(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (hasDisconnectAction) {
TextButton(
onClick = { showDisconnectDialog = true },
modifier = if (footerActionCount > 1) Modifier.weight(1.05f) else Modifier,
enabled = !isLoading && !isSyncing,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Color.White.copy(alpha = 0.84f),
disabledContentColor = Color.White.copy(alpha = 0.38f),
),
) {
Text(
text = disconnectLabel,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (hasInfoAction) {
TextButton(
onClick = { onInfoRequested?.invoke() },
modifier = if (footerActionCount > 1) Modifier.weight(1.55f) else Modifier,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color.White),
) {
Text(
text = infoLabel.orEmpty(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
if (showDisconnectDialog) {
TrackingDisconnectDialog(
brand = brand,
onConfirm = {
showDisconnectDialog = false
onDisconnect()
},
onDismiss = { showDisconnectDialog = false },
)
}
}
@Composable
private fun TrackingConnectedIdentity(
label: String,
description: String,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = Color.White,
fontWeight = FontWeight.SemiBold,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.76f),
)
}
}
@Composable
private fun TrackingBrandPrimaryButton(
label: String,
loading: Boolean,
enabled: Boolean,
onClick: () -> Unit,
showSyncIcon: Boolean = false,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF171717),
disabledContainerColor = Color.White.copy(alpha = 0.34f),
disabledContentColor = Color.White.copy(alpha = 0.7f),
),
) {
when {
loading -> NuvioLoadingIndicator(
color = Color(0xFF171717),
modifier = Modifier.size(18.dp),
)
showSyncIcon -> Icon(
imageVector = Icons.Rounded.Sync,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
if (loading || showSyncIcon) {
Spacer(modifier = Modifier.width(8.dp))
}
Text(label)
}
}
@Composable
private fun TrackingBrandMessage(
text: String,
isError: Boolean,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (isError) TrackingErrorColor.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(NuvioTokens.Radius.md),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = MaterialTheme.typography.bodySmall,
color = if (isError) TrackingErrorColor else Color.White.copy(alpha = 0.82f),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TrackingDisconnectDialog(
brand: TrackingBrand,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = tokens.components.dialogMaxWidth),
shape = tokens.shapes.dialog,
color = tokens.colors.surfaceDialog,
) {
Column(
modifier = Modifier.padding(tokens.spacing.dialogPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.settings_tracking_disconnect_title, brand.displayName),
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.settings_tracking_disconnect_description, brand.displayName),
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_cancel))
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(Res.string.settings_trakt_disconnect))
}
}
}
}
}
}
@Composable
internal fun TrackingBrandGlyph(
brand: TrackingBrand,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
when (brand) {
TrackingBrand.TRAKT -> Image(
painter = traktBrandPainter(TraktBrandAsset.Glyph),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.SIMKL -> Image(
painter = simklBrandPainter(SimklBrandAsset.Glyph),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.TMDB -> Image(
painter = integrationLogoPainter(IntegrationLogo.Tmdb),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.NUVIO -> Icon(
imageVector = Icons.Rounded.Sync,
contentDescription = contentDescription,
modifier = modifier,
tint = MaterialTheme.nuvio.colors.accent,
)
}
}
@Composable
private fun TrackingBrandWordmark(
brand: TrackingBrand,
contentDescription: String,
) {
val painter: Painter = when (brand) {
TrackingBrand.TRAKT -> traktBrandPainter(TraktBrandAsset.Wordmark)
TrackingBrand.SIMKL -> simklBrandPainter(SimklBrandAsset.Wordmark)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> return
}
Image(
painter = painter,
contentDescription = contentDescription,
modifier = when (brand) {
TrackingBrand.TRAKT -> Modifier
.width(86.dp)
.height(38.dp)
TrackingBrand.SIMKL -> Modifier
.width(124.dp)
.height(30.dp)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> Modifier
},
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
)
}
private fun TrackingBrand.cardBrush(): Brush = when (this) {
TrackingBrand.TRAKT -> Brush.linearGradient(
colors = listOf(Color(0xFF7D279B), Color(0xFFD61F56), Color(0xFFF22125)),
)
TrackingBrand.SIMKL -> Brush.linearGradient(
colors = listOf(Color(0xFF050505), Color(0xFF292929), Color(0xFF111111)),
)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> Brush.linearGradient(colors = listOf(Color(0xFF242424), Color(0xFF111111)))
}
@Composable
private fun simklErrorMessage(error: SimklAuthError?): String? = when (error) {
null, SimklAuthError.MISSING_CLIENT_ID -> null
SimklAuthError.INVALID_CALLBACK,
SimklAuthError.INVALID_CALLBACK_STATE,
-> stringResource(Res.string.settings_simkl_invalid_callback)
SimklAuthError.AUTHORIZATION_EXPIRED ->
stringResource(Res.string.settings_simkl_authorization_expired)
SimklAuthError.TOKEN_EXCHANGE_FAILED,
SimklAuthError.INVALID_TOKEN_RESPONSE,
-> stringResource(Res.string.settings_simkl_sign_in_failed)
SimklAuthError.AUTHORIZATION_REVOKED ->
stringResource(Res.string.settings_simkl_authorization_revoked)
}
private val TrackingErrorColor = Color(0xFFFFDAD6)
private const val SIMKL_WEBSITE_URL = "https://simkl.com"

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.simkl
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
enum class SimklBrandAsset {
Glyph,
Wordmark,
}
@Composable
expect fun simklBrandPainter(asset: SimklBrandAsset): Painter

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.settings
import com.nuvio.app.features.simkl.SimklConnectionMode
import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference
import com.nuvio.app.features.trakt.TraktConnectionMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TrackingSettingsPresentationTest {
@Test
fun `provider connection modes map to matching card modes`() {
assertEquals(
TrackingConnectionCardMode.DISCONNECTED,
TraktConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(),
)
assertEquals(
TrackingConnectionCardMode.AWAITING_APPROVAL,
TraktConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(),
)
assertEquals(
TrackingConnectionCardMode.CONNECTED,
TraktConnectionMode.CONNECTED.toTrackingConnectionCardMode(),
)
assertEquals(
TrackingConnectionCardMode.DISCONNECTED,
SimklConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(),
)
assertEquals(
TrackingConnectionCardMode.AWAITING_APPROVAL,
SimklConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(),
)
assertEquals(
TrackingConnectionCardMode.CONNECTED,
SimklConnectionMode.CONNECTED.toTrackingConnectionCardMode(),
)
}
@Test
fun `remote brands are available only while their provider is connected`() {
assertTrue(isTrackingBrandAvailable(TrackingBrand.NUVIO, false, false))
assertTrue(isTrackingBrandAvailable(TrackingBrand.TMDB, false, false))
assertFalse(isTrackingBrandAvailable(TrackingBrand.TRAKT, false, true))
assertTrue(isTrackingBrandAvailable(TrackingBrand.TRAKT, true, false))
assertFalse(isTrackingBrandAvailable(TrackingBrand.SIMKL, true, false))
assertTrue(isTrackingBrandAvailable(TrackingBrand.SIMKL, false, true))
}
@Test
fun `Trakt recommendations fall back visually without rewriting preference`() {
val stored = MoreLikeThisSourcePreference.TRAKT
assertEquals(
MoreLikeThisSourcePreference.TMDB,
effectiveTrackingRecommendationsSource(stored, traktConnected = false),
)
assertEquals(
MoreLikeThisSourcePreference.TRAKT,
effectiveTrackingRecommendationsSource(stored, traktConnected = true),
)
assertEquals(MoreLikeThisSourcePreference.TRAKT, stored)
}
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.simkl
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.simkl_logo_glyph
import nuvio.composeapp.generated.resources.simkl_logo_wordmark
import org.jetbrains.compose.resources.painterResource
@Composable
actual fun simklBrandPainter(asset: SimklBrandAsset): Painter =
when (asset) {
SimklBrandAsset.Glyph -> painterResource(Res.drawable.simkl_logo_glyph)
SimklBrandAsset.Wordmark -> painterResource(Res.drawable.simkl_logo_wordmark)
}