mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
ref(ios): native tab to use app icons and avatars
This commit is contained in:
parent
8b2a635174
commit
38a786850c
6 changed files with 477 additions and 8 deletions
|
|
@ -7,3 +7,12 @@ internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Un
|
||||||
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
|
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
|
||||||
|
|
||||||
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
|
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
|
||||||
|
|
||||||
|
internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
|
||||||
|
|
||||||
|
internal actual fun publishNativeProfileTabIcon(
|
||||||
|
name: String?,
|
||||||
|
avatarColorHex: String?,
|
||||||
|
avatarImageUrl: String?,
|
||||||
|
avatarBackgroundColorHex: String?,
|
||||||
|
) = Unit
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,13 @@ import com.nuvio.app.features.player.PlayerRoute
|
||||||
import com.nuvio.app.features.player.PlayerScreen
|
import com.nuvio.app.features.player.PlayerScreen
|
||||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||||
|
import com.nuvio.app.features.profiles.AvatarRepository
|
||||||
import com.nuvio.app.features.profiles.NuvioProfile
|
import com.nuvio.app.features.profiles.NuvioProfile
|
||||||
import com.nuvio.app.features.profiles.ProfileEditScreen
|
import com.nuvio.app.features.profiles.ProfileEditScreen
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
||||||
|
import com.nuvio.app.features.profiles.avatarStorageUrl
|
||||||
import com.nuvio.app.features.search.SearchScreen
|
import com.nuvio.app.features.search.SearchScreen
|
||||||
import com.nuvio.app.features.settings.SettingsScreen
|
import com.nuvio.app.features.settings.SettingsScreen
|
||||||
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
||||||
|
|
@ -316,9 +318,33 @@ fun App() {
|
||||||
|
|
||||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||||
|
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by remember {
|
val networkStatusUiState by remember {
|
||||||
NetworkStatusRepository.uiState
|
NetworkStatusRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
profileState.activeProfile?.profileIndex,
|
||||||
|
profileState.activeProfile?.name,
|
||||||
|
profileState.activeProfile?.avatarColorHex,
|
||||||
|
profileState.activeProfile?.avatarId,
|
||||||
|
profileAvatars,
|
||||||
|
) {
|
||||||
|
val activeProfile = profileState.activeProfile
|
||||||
|
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
|
||||||
|
profileAvatars.find { it.id == avatarId }
|
||||||
|
}
|
||||||
|
NativeTabBridge.publishProfileTabIcon(
|
||||||
|
name = activeProfile?.name,
|
||||||
|
avatarColorHex = activeProfile?.avatarColorHex,
|
||||||
|
avatarImageUrl = avatarItem
|
||||||
|
?.storagePath
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let(::avatarStorageUrl),
|
||||||
|
avatarBackgroundColorHex = avatarItem?.bgColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
||||||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||||
var isNewProfile by remember { mutableStateOf(false) }
|
var isNewProfile by remember { mutableStateOf(false) }
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,24 @@ internal object NativeTabBridge {
|
||||||
fun publishLiquidGlassEnabled(enabled: Boolean) {
|
fun publishLiquidGlassEnabled(enabled: Boolean) {
|
||||||
publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
|
publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun publishAccentColor(hexColor: String) {
|
||||||
|
publishNativeTabAccentColor(hexColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishProfileTabIcon(
|
||||||
|
name: String?,
|
||||||
|
avatarColorHex: String?,
|
||||||
|
avatarImageUrl: String?,
|
||||||
|
avatarBackgroundColorHex: String?,
|
||||||
|
) {
|
||||||
|
publishNativeProfileTabIcon(
|
||||||
|
name = name,
|
||||||
|
avatarColorHex = avatarColorHex,
|
||||||
|
avatarImageUrl = avatarImageUrl,
|
||||||
|
avatarBackgroundColorHex = avatarBackgroundColorHex,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nativeTabSelect(tabName: String) {
|
fun nativeTabSelect(tabName: String) {
|
||||||
|
|
@ -49,3 +67,12 @@ internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean)
|
||||||
internal expect fun publishNativeTabBarVisible(visible: Boolean)
|
internal expect fun publishNativeTabBarVisible(visible: Boolean)
|
||||||
|
|
||||||
internal expect fun publishNativeSelectedTab(tabName: String)
|
internal expect fun publishNativeSelectedTab(tabName: String)
|
||||||
|
|
||||||
|
internal expect fun publishNativeTabAccentColor(hexColor: String)
|
||||||
|
|
||||||
|
internal expect fun publishNativeProfileTabIcon(
|
||||||
|
name: String?,
|
||||||
|
avatarColorHex: String?,
|
||||||
|
avatarImageUrl: String?,
|
||||||
|
avatarBackgroundColorHex: String?,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ object ThemeSettingsRepository {
|
||||||
_selectedTheme.value = AppTheme.WHITE
|
_selectedTheme.value = AppTheme.WHITE
|
||||||
_amoledEnabled.value = false
|
_amoledEnabled.value = false
|
||||||
_liquidGlassNativeTabBarEnabled.value = false
|
_liquidGlassNativeTabBarEnabled.value = false
|
||||||
|
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||||
NativeTabBridge.publishLiquidGlassEnabled(false)
|
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +53,7 @@ object ThemeSettingsRepository {
|
||||||
AppTheme.WHITE
|
AppTheme.WHITE
|
||||||
}
|
}
|
||||||
_selectedTheme.value = theme
|
_selectedTheme.value = theme
|
||||||
|
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||||
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
||||||
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
||||||
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
||||||
|
|
@ -66,6 +68,7 @@ object ThemeSettingsRepository {
|
||||||
if (_selectedTheme.value == theme) return
|
if (_selectedTheme.value == theme) return
|
||||||
_selectedTheme.value = theme
|
_selectedTheme.value = theme
|
||||||
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
||||||
|
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAmoled(enabled: Boolean) {
|
fun setAmoled(enabled: Boolean) {
|
||||||
|
|
@ -91,3 +94,13 @@ object ThemeSettingsRepository {
|
||||||
_selectedAppLanguage.value = language
|
_selectedAppLanguage.value = language
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AppTheme.nativeTabAccentHex(): String = when (this) {
|
||||||
|
AppTheme.CRIMSON -> "#E53935"
|
||||||
|
AppTheme.OCEAN -> "#1E88E5"
|
||||||
|
AppTheme.VIOLET -> "#8E24AA"
|
||||||
|
AppTheme.EMERALD -> "#43A047"
|
||||||
|
AppTheme.AMBER -> "#FB8C00"
|
||||||
|
AppTheme.ROSE -> "#D81B60"
|
||||||
|
AppTheme.WHITE -> "#F5F5F5"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import platform.UIKit.UIUserInterfaceIdiomPhone
|
||||||
private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
||||||
private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
||||||
private const val nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
private const val nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
||||||
|
private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
|
||||||
|
private const val nativeProfileNameKey = "NuvioNativeProfileName"
|
||||||
|
private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
|
||||||
|
private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL"
|
||||||
|
private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
|
||||||
private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange"
|
private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange"
|
||||||
|
|
||||||
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean {
|
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean {
|
||||||
|
|
@ -28,11 +33,37 @@ internal actual fun publishNativeSelectedTab(tabName: String) {
|
||||||
notifyNativeTabChromeChanged()
|
notifyNativeTabChromeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal actual fun publishNativeTabAccentColor(hexColor: String) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setObject(hexColor, forKey = nativeTabAccentColorKey)
|
||||||
|
notifyNativeTabChromeChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual fun publishNativeProfileTabIcon(
|
||||||
|
name: String?,
|
||||||
|
avatarColorHex: String?,
|
||||||
|
avatarImageUrl: String?,
|
||||||
|
avatarBackgroundColorHex: String?,
|
||||||
|
) {
|
||||||
|
publishString(nativeProfileNameKey, name)
|
||||||
|
publishString(nativeProfileAvatarColorKey, avatarColorHex)
|
||||||
|
publishString(nativeProfileAvatarUrlKey, avatarImageUrl)
|
||||||
|
publishString(nativeProfileAvatarBackgroundColorKey, avatarBackgroundColorHex)
|
||||||
|
notifyNativeTabChromeChanged()
|
||||||
|
}
|
||||||
|
|
||||||
private fun publishBool(key: String, value: Boolean) {
|
private fun publishBool(key: String, value: Boolean) {
|
||||||
NSUserDefaults.standardUserDefaults.setBool(value, forKey = key)
|
NSUserDefaults.standardUserDefaults.setBool(value, forKey = key)
|
||||||
notifyNativeTabChromeChanged()
|
notifyNativeTabChromeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun publishString(key: String, value: String?) {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
NSUserDefaults.standardUserDefaults.removeObjectForKey(key)
|
||||||
|
} else {
|
||||||
|
NSUserDefaults.standardUserDefaults.setObject(value, forKey = key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun notifyNativeTabChromeChanged() {
|
private fun notifyNativeTabChromeChanged() {
|
||||||
NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null)
|
NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,257 @@ import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
|
private enum NuvioNativeTabIcon {
|
||||||
|
static let home = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static let search = drawnIcon { context, rect in
|
||||||
|
drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) {
|
||||||
|
context.setStrokeColor(UIColor.black.cgColor)
|
||||||
|
context.setLineWidth(2)
|
||||||
|
context.setLineCap(.round)
|
||||||
|
context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12))
|
||||||
|
context.move(to: CGPoint(x: 13.6, y: 13.6))
|
||||||
|
context.addLine(to: CGPoint(x: 17, y: 17))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let library = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z",
|
||||||
|
"M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z",
|
||||||
|
"M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static let profileFallback = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static func profileAvatar(
|
||||||
|
name: String?,
|
||||||
|
avatarColor: UIColor?,
|
||||||
|
backgroundColor: UIColor?,
|
||||||
|
avatarImage: UIImage?,
|
||||||
|
selected: Bool,
|
||||||
|
accent: UIColor
|
||||||
|
) -> UIImage {
|
||||||
|
guard name != nil || avatarColor != nil || avatarImage != nil else {
|
||||||
|
return profileFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = CGSize(width: 28, height: 28)
|
||||||
|
let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
|
||||||
|
let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15)
|
||||||
|
let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5)
|
||||||
|
let initial = name?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.prefix(1)
|
||||||
|
.uppercased() ?? ""
|
||||||
|
|
||||||
|
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||||
|
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1)
|
||||||
|
fillColor.setFill()
|
||||||
|
UIBezierPath(ovalIn: rect).fill()
|
||||||
|
|
||||||
|
if let avatarImage {
|
||||||
|
UIBezierPath(ovalIn: rect).addClip()
|
||||||
|
drawAspectFill(image: avatarImage, in: rect)
|
||||||
|
} else if !initial.isEmpty {
|
||||||
|
let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: baseColor,
|
||||||
|
]
|
||||||
|
let textSize = initial.size(withAttributes: attributes)
|
||||||
|
initial.draw(
|
||||||
|
at: CGPoint(
|
||||||
|
x: rect.midX - textSize.width / 2,
|
||||||
|
y: rect.midY - textSize.height / 2
|
||||||
|
),
|
||||||
|
withAttributes: attributes
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
profileFallback
|
||||||
|
.withTintColor(baseColor, renderingMode: .alwaysOriginal)
|
||||||
|
.draw(in: rect.insetBy(dx: 5.5, dy: 5.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
borderColor.setStroke()
|
||||||
|
let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75))
|
||||||
|
borderPath.lineWidth = 1.5
|
||||||
|
borderPath.stroke()
|
||||||
|
}.withRenderingMode(.alwaysOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawInViewport(
|
||||||
|
context: CGContext,
|
||||||
|
rect: CGRect,
|
||||||
|
viewport: CGSize,
|
||||||
|
draw: () -> Void
|
||||||
|
) {
|
||||||
|
let scale = min(rect.width / viewport.width, rect.height / viewport.height)
|
||||||
|
let x = rect.midX - viewport.width * scale / 2
|
||||||
|
let y = rect.midY - viewport.height * scale / 2
|
||||||
|
context.saveGState()
|
||||||
|
context.translateBy(x: x, y: y)
|
||||||
|
context.scaleBy(x: scale, y: scale)
|
||||||
|
draw()
|
||||||
|
context.restoreGState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage {
|
||||||
|
drawnIcon(size: size) { context, rect in
|
||||||
|
drawInViewport(context: context, rect: rect, viewport: viewport) {
|
||||||
|
context.setFillColor(UIColor.black.cgColor)
|
||||||
|
paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in
|
||||||
|
context.addPath(path)
|
||||||
|
context.fillPath(using: .evenOdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawnIcon(
|
||||||
|
size: CGSize = CGSize(width: 25, height: 25),
|
||||||
|
draw: @escaping (CGContext, CGRect) -> Void
|
||||||
|
) -> UIImage {
|
||||||
|
UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||||
|
draw(rendererContext.cgContext, CGRect(origin: .zero, size: size))
|
||||||
|
}.withRenderingMode(.alwaysTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawAspectFill(image: UIImage, in rect: CGRect) {
|
||||||
|
guard image.size.width > 0, image.size.height > 0 else { return }
|
||||||
|
let scale = max(rect.width / image.size.width, rect.height / image.size.height)
|
||||||
|
let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
let drawRect = CGRect(
|
||||||
|
x: rect.midX - drawSize.width / 2,
|
||||||
|
y: rect.midY - drawSize.height / 2,
|
||||||
|
width: drawSize.width,
|
||||||
|
height: drawSize.height
|
||||||
|
)
|
||||||
|
image.draw(in: drawRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SVGPath {
|
||||||
|
private enum Token {
|
||||||
|
case command(Character)
|
||||||
|
case number(CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: String
|
||||||
|
|
||||||
|
var cgPath: CGPath? {
|
||||||
|
let tokens = Self.tokens(from: data)
|
||||||
|
var index = 0
|
||||||
|
var command: Character?
|
||||||
|
var current = CGPoint.zero
|
||||||
|
var subpathStart = CGPoint.zero
|
||||||
|
let path = CGMutablePath()
|
||||||
|
|
||||||
|
func hasNumber() -> Bool {
|
||||||
|
guard index < tokens.count else { return false }
|
||||||
|
if case .number = tokens[index] { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNumber() -> CGFloat? {
|
||||||
|
guard index < tokens.count else { return nil }
|
||||||
|
guard case let .number(value) = tokens[index] else { return nil }
|
||||||
|
index += 1
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoint(relative: Bool) -> CGPoint? {
|
||||||
|
guard let x = readNumber(), let y = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: x, y: y)
|
||||||
|
return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point
|
||||||
|
}
|
||||||
|
|
||||||
|
while index < tokens.count {
|
||||||
|
if case let .command(value) = tokens[index] {
|
||||||
|
command = value
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeCommand = command else { return nil }
|
||||||
|
let relative = activeCommand.isLowercase
|
||||||
|
|
||||||
|
switch activeCommand.uppercased() {
|
||||||
|
case "M":
|
||||||
|
guard let point = readPoint(relative: relative) else { return nil }
|
||||||
|
path.move(to: point)
|
||||||
|
current = point
|
||||||
|
subpathStart = point
|
||||||
|
command = relative ? "l" : "L"
|
||||||
|
case "L":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let point = readPoint(relative: relative) else { return nil }
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "H":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let x = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: relative ? current.x + x : x, y: current.y)
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "V":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let y = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: current.x, y: relative ? current.y + y : y)
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "C":
|
||||||
|
while hasNumber() {
|
||||||
|
guard
|
||||||
|
let c1 = readPoint(relative: relative),
|
||||||
|
let c2 = readPoint(relative: relative),
|
||||||
|
let end = readPoint(relative: relative)
|
||||||
|
else { return nil }
|
||||||
|
path.addCurve(to: end, control1: c1, control2: c2)
|
||||||
|
current = end
|
||||||
|
}
|
||||||
|
case "Z":
|
||||||
|
path.closeSubpath()
|
||||||
|
current = subpathStart
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tokens(from data: String) -> [Token] {
|
||||||
|
let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||||
|
let range = NSRange(data.startIndex..<data.endIndex, in: data)
|
||||||
|
return regex.matches(in: data, range: range).compactMap { match in
|
||||||
|
guard let tokenRange = Range(match.range, in: data) else { return nil }
|
||||||
|
let token = String(data[tokenRange])
|
||||||
|
if token.count == 1, let character = token.first, character.isLetter {
|
||||||
|
return .command(character)
|
||||||
|
}
|
||||||
|
guard let value = Double(token) else { return nil }
|
||||||
|
return .number(CGFloat(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
private enum NativeTab: String, CaseIterable {
|
private enum NativeTab: String, CaseIterable {
|
||||||
case home = "Home"
|
case home = "Home"
|
||||||
|
|
@ -27,12 +278,12 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemImageName: String {
|
var iconImage: UIImage {
|
||||||
switch self {
|
switch self {
|
||||||
case .home: return "house"
|
case .home: return NuvioNativeTabIcon.home
|
||||||
case .search: return "magnifyingglass"
|
case .search: return NuvioNativeTabIcon.search
|
||||||
case .library: return "books.vertical"
|
case .library: return NuvioNativeTabIcon.library
|
||||||
case .settings: return "person.crop.circle"
|
case .settings: return NuvioNativeTabIcon.profileFallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +296,11 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
||||||
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
||||||
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
||||||
|
private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
|
||||||
|
private static let nativeProfileNameKey = "NuvioNativeProfileName"
|
||||||
|
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
|
||||||
|
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
|
||||||
|
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
|
||||||
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
|
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
|
||||||
|
|
||||||
private let contentController: UIViewController
|
private let contentController: UIViewController
|
||||||
|
|
@ -53,6 +309,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
private var tabBarHeightConstraint: NSLayoutConstraint?
|
private var tabBarHeightConstraint: NSLayoutConstraint?
|
||||||
private var userDefaultsObserver: NSObjectProtocol?
|
private var userDefaultsObserver: NSObjectProtocol?
|
||||||
private var tabChromeObserver: NSObjectProtocol?
|
private var tabChromeObserver: NSObjectProtocol?
|
||||||
|
private var profileAvatarImageURL: String?
|
||||||
|
private var profileAvatarImageTask: URLSessionDataTask?
|
||||||
|
private var profileAvatarImage: UIImage?
|
||||||
|
|
||||||
init(contentController: UIViewController) {
|
init(contentController: UIViewController) {
|
||||||
self.contentController = contentController
|
self.contentController = contentController
|
||||||
|
|
@ -96,6 +355,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
if let tabChromeObserver {
|
if let tabChromeObserver {
|
||||||
NotificationCenter.default.removeObserver(tabChromeObserver)
|
NotificationCenter.default.removeObserver(tabChromeObserver)
|
||||||
}
|
}
|
||||||
|
profileAvatarImageTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewSafeAreaInsetsDidChange() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
|
@ -180,13 +440,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
tabBar.delegate = self
|
tabBar.delegate = self
|
||||||
tabBar.translatesAutoresizingMaskIntoConstraints = false
|
tabBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
tabBar.items = NativeTab.allCases.map { tab in
|
tabBar.items = NativeTab.allCases.map { tab in
|
||||||
UITabBarItem(
|
let item = UITabBarItem(
|
||||||
title: tab.title,
|
title: tab.title,
|
||||||
image: UIImage(systemName: tab.systemImageName),
|
image: tab.iconImage,
|
||||||
tag: tab.tag
|
selectedImage: tab.iconImage
|
||||||
)
|
)
|
||||||
|
item.tag = tab.tag
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
tabBar.selectedItem = tabBar.items?.first
|
tabBar.selectedItem = tabBar.items?.first
|
||||||
|
applyNativeTabBarAppearance()
|
||||||
tabBar.alpha = 0
|
tabBar.alpha = 0
|
||||||
tabBar.isHidden = true
|
tabBar.isHidden = true
|
||||||
|
|
||||||
|
|
@ -229,6 +492,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
|
|
||||||
private func syncNativeTabChrome(animated: Bool) {
|
private func syncNativeTabChrome(animated: Bool) {
|
||||||
updateTabBarHeight()
|
updateTabBarHeight()
|
||||||
|
applyNativeTabBarAppearance()
|
||||||
syncSelectedNativeTab()
|
syncSelectedNativeTab()
|
||||||
|
|
||||||
let visible = shouldShowNativeTabBar
|
let visible = shouldShowNativeTabBar
|
||||||
|
|
@ -265,6 +529,105 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
let selectedTab = NativeTab(rawValue: rawValue) ?? .home
|
let selectedTab = NativeTab(rawValue: rawValue) ?? .home
|
||||||
tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
|
tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyNativeTabBarAppearance() {
|
||||||
|
let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ??
|
||||||
|
UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
|
||||||
|
let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1)
|
||||||
|
|
||||||
|
refreshProfileAvatarImageIfNeeded()
|
||||||
|
updateNativeTabImages(accent: accent)
|
||||||
|
|
||||||
|
tabBar.tintColor = accent
|
||||||
|
tabBar.unselectedItemTintColor = unselected
|
||||||
|
|
||||||
|
let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance
|
||||||
|
appearance.stackedLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.stackedLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
appearance.inlineLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.inlineLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
appearance.compactInlineLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.compactInlineLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
tabBar.standardAppearance = appearance
|
||||||
|
tabBar.scrollEdgeAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNativeTabImages(accent: UIColor) {
|
||||||
|
tabBar.items?.forEach { item in
|
||||||
|
guard let tab = NativeTab(tag: item.tag) else { return }
|
||||||
|
item.image = nativeTabImage(for: tab, selected: false, accent: accent)
|
||||||
|
item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage {
|
||||||
|
guard tab == .settings else {
|
||||||
|
return tab.iconImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
return NuvioNativeTabIcon.profileAvatar(
|
||||||
|
name: defaults.string(forKey: Self.nativeProfileNameKey),
|
||||||
|
avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)),
|
||||||
|
backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)),
|
||||||
|
avatarImage: profileAvatarImage,
|
||||||
|
selected: selected,
|
||||||
|
accent: accent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProfileAvatarImageIfNeeded() {
|
||||||
|
let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey)
|
||||||
|
guard urlString != profileAvatarImageURL else { return }
|
||||||
|
|
||||||
|
profileAvatarImageTask?.cancel()
|
||||||
|
profileAvatarImageTask = nil
|
||||||
|
profileAvatarImageURL = urlString
|
||||||
|
profileAvatarImage = nil
|
||||||
|
|
||||||
|
guard let urlString, let url = URL(string: urlString) else { return }
|
||||||
|
|
||||||
|
profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||||
|
guard
|
||||||
|
let self,
|
||||||
|
let data,
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.profileAvatarImageURL == urlString else { return }
|
||||||
|
self.profileAvatarImage = image
|
||||||
|
self.applyNativeTabBarAppearance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profileAvatarImageTask?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
convenience init?(hexString: String?) {
|
||||||
|
guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value.hasPrefix("#") {
|
||||||
|
value.removeFirst()
|
||||||
|
}
|
||||||
|
guard value.count == 6, let rgb = UInt64(value, radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(rgb & 0xFF) / 255,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
struct ComposeView: UIViewControllerRepresentable {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue