Initialize the basic framework

This commit is contained in:
2024-03-14 17:09:28 +08:00
commit 51261e5be9
90 changed files with 3926 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# built application files
*.apk
*.ap_
# files for the dex VM
*.dex
# Java class files
*.class
# generated files
bin/
gen/
out/
build/
# Local configuration file (sdk path, etc)
local.properties
# Eclipse project files
.classpath
.project
# Windows thumbnail db
.DS_Store
# IDEA/Android Studio project files, because
# the project can be imported from settings.gradle.kts
*.iml
.idea/*
!.idea/copyright
# Keep the code styles.
!/.idea/codeStyles
/.idea/codeStyles/*
!/.idea/codeStyles/Project.xml
!/.idea/codeStyles/codeStyleConfig.xml
# Gradle cache
.gradle
# Sandbox stuff
_sandbox
# Android Studio captures folder
captures/

2
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build
/src/main/res/raw/dependencies.json

163
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,163 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.mikepenz.aboutlibraries.plugin.AboutLibrariesTask
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.ksp)
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.hilt)
alias(libs.plugins.protobuf)
}
android {
namespace = "top.fatweb.oxygen.toolbox"
compileSdk = 34
defaultConfig {
applicationId = "top.fatweb.oxygen.toolbox"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0.0-SNAPSHOT"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// Required when setting minSdkVersion to 20 or lower
multiDexEnabled = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
android.applicationVariants.all {
outputs.all {
(this as BaseVariantOutputImpl).outputFileName =
"${project.name}_${defaultConfig.versionName}-${defaultConfig.versionCode}_${buildType.name}.apk"
}
}
compileOptions {
// Flag to enable support for the new language APIs
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
register("java") {
option("lite")
}
register("kotlin") {
option("lite")
}
}
}
}
}
androidComponents.beforeVariants {
android.sourceSets.getByName(it.name) {
val buildDir = layout.buildDirectory.get().asFile
java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java"))
kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin"))
}
}
aboutLibraries {
registerAndroidTasks = false
configPath = "libConfig"
outputFileName = "dependencies.json"
exclusionPatterns = listOf(
Regex("androidx.*"),
Regex("org.jetbrains.*"),
Regex("com.google.guava:listenablefuture")
).map { it.toPattern() }
}
task("exportLibrariesToJson", AboutLibrariesTask::class) {
resultDirectory = project.file("src/main/res/raw/")
variant = "release"
}.dependsOn("collectDependencies")
afterEvaluate {
tasks.findByName("preBuild")?.dependsOn(tasks.findByName("exportLibrariesToJson"))
tasks.findByName("kspDebugKotlin")?.dependsOn(tasks.findByName("generateDebugProto"))
}
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.lifecycle.runtime.testing)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.material.icons.core)
implementation(libs.material.icons.extended)
implementation(libs.material3.window.size)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.androidx.core.splashscreen)
implementation(libs.hilt.android)
ksp(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.coil.kt)
implementation(libs.coil.kt.compose)
implementation(libs.coil.kt.svg)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package top.fatweb.oxygen.toolbox
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("top.fatweb.oxygen.toolbox", appContext.packageName)
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".OxygenApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Oxygen.Splash"
tools:targetApi="tiramisu">
<profileable
android:shell="true"
tools:targetApi="q" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,203 @@
package top.fatweb.oxygen.toolbox
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
import top.fatweb.oxygen.toolbox.ui.OxygenApp
import top.fatweb.oxygen.toolbox.ui.rememberOxygenAppState
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
import top.fatweb.oxygen.toolbox.ui.util.LocalTimeZone
import top.fatweb.oxygen.toolbox.ui.util.LocaleUtils
import javax.inject.Inject
const val TAG = "MainActivity"
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
var uiState: MainActivityUiState by mutableStateOf(MainActivityUiState.Loading)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.onEach { uiState = it }
.collect()
}
}
splashScreen.setKeepOnScreenCondition {
when (uiState) {
MainActivityUiState.Loading -> true
is MainActivityUiState.Success -> false
}
}
enableEdgeToEdge()
setContent {
val locale = whatLocale(uiState)
if (uiState != MainActivityUiState.Loading) {
LaunchedEffect(locale) {
LocaleUtils.switchLocale(this@MainActivity, locale)
}
}
val darkTheme = shouldUseDarkTheme(uiState)
LaunchedEffect(darkTheme) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
) { darkTheme },
navigationBarStyle = SystemBarStyle.auto(
lightScrim, darkScrim
) { darkTheme }
)
}
val appState = rememberOxygenAppState(
windowSizeClass = calculateWindowSizeClass(this),
networkMonitor = networkMonitor,
timeZoneMonitor = timeZoneMonitor,
launchPageConfig = whatLaunchPage(uiState)
)
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalTimeZone provides currentTimeZone
) {
OxygenTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState),
dynamicColor = shouldUseDynamicColor(uiState)
) {
OxygenApp(appState)
}
}
Log.d(TAG, "onCreate: C")
}
Log.d(TAG, "onCreate: D")
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UserDataRepositoryEntryPoint {
val userDataRepository: UserDataRepository
}
override fun attachBaseContext(newBase: Context) {
val userDataRepository =
EntryPointAccessors.fromApplication<UserDataRepositoryEntryPoint>(newBase).userDataRepository
super.attachBaseContext(LocaleUtils.attachBaseContext(newBase, runBlocking {
userDataRepository.userData.first().languageConfig
}))
}
}
@Composable
private fun shouldUseDarkTheme(
uiState: MainActivityUiState
): Boolean = when (uiState) {
MainActivityUiState.Loading -> isSystemInDarkTheme()
is MainActivityUiState.Success -> when (uiState.userData.darkThemeConfig) {
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme()
DarkThemeConfig.LIGHT -> false
DarkThemeConfig.DARK -> true
}
}
@Composable
private fun shouldUseAndroidTheme(
uiState: MainActivityUiState
): Boolean = when (uiState) {
MainActivityUiState.Loading -> false
is MainActivityUiState.Success -> when (uiState.userData.themeBrandConfig) {
ThemeBrandConfig.DEFAULT -> false
ThemeBrandConfig.ANDROID -> true
}
}
@Composable
private fun shouldUseDynamicColor(
uiState: MainActivityUiState
): Boolean = when (uiState) {
MainActivityUiState.Loading -> true
is MainActivityUiState.Success -> uiState.userData.useDynamicColor
}
@Composable
private fun whatLocale(
uiState: MainActivityUiState
): LanguageConfig = when (uiState) {
MainActivityUiState.Loading -> LanguageConfig.FOLLOW_SYSTEM
is MainActivityUiState.Success -> uiState.userData.languageConfig
}
@Composable
private fun whatLaunchPage(
uiState: MainActivityUiState
): LaunchPageConfig = when (uiState) {
MainActivityUiState.Loading -> LaunchPageConfig.TOOLS
is MainActivityUiState.Success -> uiState.userData.launchPageConfig
}
/**
* The default light scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598
*/
private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
/**
* The default dark scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598
*/
private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)

View File

@@ -0,0 +1,31 @@
package top.fatweb.oxygen.toolbox
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.UserData
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
MainActivityUiState.Success(it)
}.stateIn(
scope = viewModelScope,
initialValue = MainActivityUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
}
sealed interface MainActivityUiState {
data object Loading : MainActivityUiState
data class Success(val userData: UserData) : MainActivityUiState
}

View File

@@ -0,0 +1,12 @@
package top.fatweb.oxygen.toolbox
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
import javax.inject.Inject
@HiltAndroidApp
class OxygenApplication : Application() {
@Inject
lateinit var userDataRepository: UserDataRepository
}

View File

@@ -0,0 +1,12 @@
package top.fatweb.oxygen.toolbox.datastore
import androidx.datastore.core.DataMigration
internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences =
currentData.copy { }
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean = false
}

View File

@@ -0,0 +1,123 @@
package top.fatweb.oxygen.toolbox.datastore
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.map
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.UserData
import javax.inject.Inject
class OxygenPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>
) {
val userData = userPreferences.data
.map {
UserData(
languageConfig = when (it.languageConfig) {
null,
LanguageConfigProto.UNRECOGNIZED,
LanguageConfigProto.LANGUAGE_CONFIG_UNSPECIFIED,
LanguageConfigProto.LANGUAGE_CONFIG_FOLLOW_SYSTEM
-> LanguageConfig.FOLLOW_SYSTEM
LanguageConfigProto.LANGUAGE_CONFIG_CHINESE
-> LanguageConfig.CHINESE
LanguageConfigProto.LANGUAGE_CONFIG_ENGLISH
-> LanguageConfig.ENGLISH
},
launchPageConfig = when (it.launchPageConfig) {
null,
LaunchPageConfigProto.UNRECOGNIZED,
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_UNSPECIFIED,
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_TOOLS
-> LaunchPageConfig.TOOLS
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_STAR
-> LaunchPageConfig.STAR
},
themeBrandConfig = when (it.themeBrandConfig) {
null,
ThemeBrandConfigProto.UNRECOGNIZED,
ThemeBrandConfigProto.THEME_BRAND_CONFIG_UNSPECIFIED,
ThemeBrandConfigProto.THEME_BRAND_CONFIG_DEFAULT
->
ThemeBrandConfig.DEFAULT
ThemeBrandConfigProto.THEME_BRAND_CONFIG_ANDROID
-> ThemeBrandConfig.ANDROID
},
darkThemeConfig = when (it.darkThemeConfig) {
null,
DarkThemeConfigProto.UNRECOGNIZED,
DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
->
DarkThemeConfig.FOLLOW_SYSTEM
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
-> DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
-> DarkThemeConfig.DARK
},
useDynamicColor = it.useDynamicColor
)
}
suspend fun setLanguageConfig(languageConfig: LanguageConfig) {
userPreferences.updateData {
it.copy {
this.languageConfig = when (languageConfig) {
LanguageConfig.FOLLOW_SYSTEM -> LanguageConfigProto.LANGUAGE_CONFIG_FOLLOW_SYSTEM
LanguageConfig.CHINESE -> LanguageConfigProto.LANGUAGE_CONFIG_CHINESE
LanguageConfig.ENGLISH -> LanguageConfigProto.LANGUAGE_CONFIG_ENGLISH
}
}
}
}
suspend fun setLaunchPageConfig(launchPageConfig: LaunchPageConfig) {
userPreferences.updateData {
it.copy {
this.launchPageConfig = when (launchPageConfig) {
LaunchPageConfig.TOOLS -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_TOOLS
LaunchPageConfig.STAR -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_STAR
}
}
}
}
suspend fun setThemeBrandConfig(themeBrandConfig: ThemeBrandConfig) {
userPreferences.updateData {
it.copy {
this.themeBrandConfig = when (themeBrandConfig) {
ThemeBrandConfig.DEFAULT -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_DEFAULT
ThemeBrandConfig.ANDROID -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_ANDROID
}
}
}
}
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
userPreferences.updateData {
it.copy {
this.darkThemeConfig = when (darkThemeConfig) {
DarkThemeConfig.FOLLOW_SYSTEM -> DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
}
}
}
}
suspend fun setUseDynamicColor(useDynamicColor: Boolean) {
userPreferences.updateData {
it.copy {
this.useDynamicColor = useDynamicColor
}
}
}
}

View File

@@ -0,0 +1,23 @@
package top.fatweb.oxygen.toolbox.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences =
try {
UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
t.writeTo(output)
}
}

View File

@@ -0,0 +1,28 @@
package top.fatweb.oxygen.toolbox.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import top.fatweb.oxygen.toolbox.network.Dispatcher
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
internal object CoroutineScopesModule {
@Provides
@Singleton
@ApplicationScope
fun providesCoroutineScope(
@Dispatcher(OxygenDispatchers.Default) dispatcher: CoroutineDispatcher
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}

View File

@@ -0,0 +1,25 @@
package top.fatweb.oxygen.toolbox.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import top.fatweb.oxygen.toolbox.monitor.ConnectivityManagerNetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneBroadcastMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.repository.OfflineFirstUserDataRepository
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
internal abstract fun bindsUserDataRepository(userDataRepository: OfflineFirstUserDataRepository): UserDataRepository
@Binds
internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor
@Binds
internal abstract fun bindsTimeZoneMonitor(timeZoneMonitor: TimeZoneBroadcastMonitor): TimeZoneMonitor
}

View File

@@ -0,0 +1,41 @@
package top.fatweb.oxygen.toolbox.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import top.fatweb.oxygen.toolbox.datastore.IntToStringIdsMigration
import top.fatweb.oxygen.toolbox.datastore.UserPreferences
import top.fatweb.oxygen.toolbox.datastore.UserPreferencesSerializer
import top.fatweb.oxygen.toolbox.network.Dispatcher
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
internal fun providesUserPreferencesDataStore(
@ApplicationContext context: Context,
@Dispatcher(OxygenDispatchers.IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer
): DataStore<UserPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
scope = CoroutineScope(scope.coroutineContext + ioDispatcher),
migrations = listOf(
IntToStringIdsMigration
)
) {
context.dataStoreFile("user_preferences.pb")
}
}

View File

@@ -0,0 +1,22 @@
package top.fatweb.oxygen.toolbox.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import top.fatweb.oxygen.toolbox.network.Dispatcher
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides
@Dispatcher(OxygenDispatchers.IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(OxygenDispatchers.Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

View File

@@ -0,0 +1,20 @@
package top.fatweb.oxygen.toolbox.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
object OxygenIcons {
val Home = Icons.Rounded.Home
val HomeBorder = Icons.Outlined.Home
val Star = Icons.Rounded.Star
val StarBorder = Icons.Outlined.StarBorder
val Search = Icons.Rounded.Search
val MoreVert = Icons.Default.MoreVert
val Back = Icons.Rounded.ArrowBackIosNew
}

View File

@@ -0,0 +1,7 @@
package top.fatweb.oxygen.toolbox.model
enum class DarkThemeConfig {
FOLLOW_SYSTEM,
LIGHT,
DARK,
}

View File

@@ -0,0 +1,7 @@
package top.fatweb.oxygen.toolbox.model
enum class LanguageConfig(val code: String? = null) {
FOLLOW_SYSTEM,
CHINESE("cn"),
ENGLISH("en")
}

View File

@@ -0,0 +1,6 @@
package top.fatweb.oxygen.toolbox.model
enum class LaunchPageConfig {
TOOLS,
STAR
}

View File

@@ -0,0 +1,6 @@
package top.fatweb.oxygen.toolbox.model
enum class ThemeBrandConfig {
DEFAULT,
ANDROID
}

View File

@@ -0,0 +1,9 @@
package top.fatweb.oxygen.toolbox.model
data class UserData(
val languageConfig: LanguageConfig,
val launchPageConfig: LaunchPageConfig,
val themeBrandConfig: ThemeBrandConfig,
val darkThemeConfig: DarkThemeConfig,
val useDynamicColor: Boolean
)

View File

@@ -0,0 +1,69 @@
package top.fatweb.oxygen.toolbox.monitor
import android.content.Context
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
if (connectivityManager == null) {
channel.trySend(false)
channel.close()
return@callbackFlow
}
val callback = object : NetworkCallback() {
private val networks = mutableSetOf<Network>()
override fun onAvailable(network: Network) {
networks += network
channel.trySend(true)
}
override fun onLost(network: Network) {
networks -= network
channel.trySend(networks.isNotEmpty())
}
}
val request =
NetworkRequest
.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
.conflate()
@Suppress("DEPRECATION")
private fun ConnectivityManager.isCurrentlyConnected() = when {
VERSION.SDK_INT >= VERSION_CODES.M ->
activeNetwork
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
else -> activeNetworkInfo?.isConnected
} ?: false
}

View File

@@ -0,0 +1,7 @@
package top.fatweb.oxygen.toolbox.monitor
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}

View File

@@ -0,0 +1,68 @@
package top.fatweb.oxygen.toolbox.monitor
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Build.VERSION_CODES
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinTimeZone
import top.fatweb.oxygen.toolbox.di.ApplicationScope
import top.fatweb.oxygen.toolbox.network.Dispatcher
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@Singleton
class TimeZoneBroadcastMonitor @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope appScope: CoroutineScope,
@Dispatcher(OxygenDispatchers.IO) private val ioDispatcher: CoroutineDispatcher
) : TimeZoneMonitor {
override val currentTimeZone: SharedFlow<TimeZone> = callbackFlow {
trySend(TimeZone.currentSystemDefault())
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return
val zonIdFromIntent = if (Build.VERSION.SDK_INT < VERSION_CODES.R) {
null
} else {
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->
val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)
zoneId.toKotlinTimeZone()
}
}
trySend(zonIdFromIntent ?: TimeZone.currentSystemDefault())
}
}
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
trySend(TimeZone.currentSystemDefault())
awaitClose {
context.unregisterReceiver(receiver)
}
}
.distinctUntilChanged()
.conflate()
.flowOn(ioDispatcher)
.shareIn(appScope, SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), 1)
}

View File

@@ -0,0 +1,8 @@
package top.fatweb.oxygen.toolbox.monitor
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.TimeZone
interface TimeZoneMonitor {
val currentTimeZone: Flow<TimeZone>
}

View File

@@ -0,0 +1,31 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import top.fatweb.oxygen.toolbox.ui.OxygenAppState
@Composable
fun OxygenNavHost(
modifier: Modifier = Modifier,
appState: OxygenAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
startDestination: String
) {
val navController = appState.navController
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
searchScreen(
onBackClick = navController::popBackStack
)
toolsScreen(
)
starScreen(
)
}
}

View File

@@ -0,0 +1,22 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import top.fatweb.oxygen.toolbox.ui.search.SearchRoute
const val SEARCH_ROUTE = "search_route"
fun NavController.navigateToSearch(navOptions: NavOptions? = null) =
navigate(SEARCH_ROUTE, navOptions)
fun NavGraphBuilder.searchScreen(
onBackClick: () -> Unit
) {
composable(
route = SEARCH_ROUTE
) {
SearchRoute(onBackClick = onBackClick)
}
}

View File

@@ -0,0 +1,18 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val STAR_ROUTE = "star_route"
fun NavController.navigateToStar(navOptions: NavOptions) = navigate(STAR_ROUTE, navOptions)
fun NavGraphBuilder.starScreen() {
composable(
route = STAR_ROUTE
) {
}
}

View File

@@ -0,0 +1,16 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val TOOLS_ROUTE = "tools_route"
fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE, navOptions)
fun NavGraphBuilder.toolsScreen() {
composable(
route = TOOLS_ROUTE
) { }
}

View File

@@ -0,0 +1,26 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val iconTextId: Int,
val titleTextId: Int
) {
TOOLS(
selectedIcon = OxygenIcons.Home,
unselectedIcon = OxygenIcons.HomeBorder,
iconTextId = R.string.feature_tools_title,
titleTextId = R.string.feature_tools_title
),
STAR(
selectedIcon = OxygenIcons.Star,
unselectedIcon = OxygenIcons.StarBorder,
iconTextId = R.string.feature_star_title,
titleTextId = R.string.feature_star_title
)
}

View File

@@ -0,0 +1,12 @@
package top.fatweb.oxygen.toolbox.network
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dispatcher(val oxygenDispatcher: OxygenDispatchers)
enum class OxygenDispatchers {
Default,
IO
}

View File

@@ -0,0 +1,37 @@
package top.fatweb.oxygen.toolbox.repository
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.datastore.OxygenPreferencesDataSource
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.UserData
import javax.inject.Inject
internal class OfflineFirstUserDataRepository @Inject constructor(
private val oxygenPreferencesDataSource: OxygenPreferencesDataSource
) : UserDataRepository {
override val userData: Flow<UserData> =
oxygenPreferencesDataSource.userData
override suspend fun setLanguageConfig(languageConfig: LanguageConfig) {
oxygenPreferencesDataSource.setLanguageConfig(languageConfig)
}
override suspend fun setLaunchPageConfig(launchPageConfig: LaunchPageConfig) {
oxygenPreferencesDataSource.setLaunchPageConfig(launchPageConfig)
}
override suspend fun setThemeBrandConfig(themeBrandConfig: ThemeBrandConfig) {
oxygenPreferencesDataSource.setThemeBrandConfig(themeBrandConfig)
}
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
oxygenPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
}
override suspend fun setUseDynamicColor(useDynamicColor: Boolean) {
oxygenPreferencesDataSource.setUseDynamicColor(useDynamicColor)
}
}

View File

@@ -0,0 +1,22 @@
package top.fatweb.oxygen.toolbox.repository
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.UserData
interface UserDataRepository {
val userData: Flow<UserData>
suspend fun setLanguageConfig(languageConfig: LanguageConfig)
suspend fun setLaunchPageConfig(launchPageConfig: LaunchPageConfig)
suspend fun setThemeBrandConfig(themeBrandConfig: ThemeBrandConfig)
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
suspend fun setUseDynamicColor(useDynamicColor: Boolean)
}

View File

@@ -0,0 +1,236 @@
package top.fatweb.oxygen.toolbox.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.navigation.OxygenNavHost
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
import top.fatweb.oxygen.toolbox.ui.component.OxygenBackground
import top.fatweb.oxygen.toolbox.ui.component.OxygenGradientBackground
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationBar
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationBarItem
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationRail
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationRailItem
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
import top.fatweb.oxygen.toolbox.ui.settings.SettingsDialog
import top.fatweb.oxygen.toolbox.ui.theme.GradientColors
import top.fatweb.oxygen.toolbox.ui.theme.LocalGradientColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OxygenApp(appState: OxygenAppState) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.TOOLS
var showSettingsDialog by rememberSaveable {
mutableStateOf(false)
}
OxygenBackground {
OxygenGradientBackground(
gradientColors = if (shouldShowGradientBackground) LocalGradientColors.current else GradientColors()
) {
val destination = appState.currentTopLevelDestination
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
val noConnectMessage = stringResource(R.string.no_connect)
LaunchedEffect(isOffline) {
if (isOffline) {
snackbarHostState.showSnackbar(
message = noConnectMessage,
duration = SnackbarDuration.Indefinite
)
}
}
if (showSettingsDialog) {
SettingsDialog(
onDismiss = { showSettingsDialog = false }
)
}
Scaffold(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar && destination != null) {
OxygenBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination
)
}
}
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
) {
if (appState.shouldShowNavRail && destination != null) {
OxygenNavRail(
modifier = Modifier.safeDrawingPadding(),
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination
)
}
Column(
Modifier.fillMaxSize()
) {
if (destination != null) {
OxygenTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = OxygenIcons.Search,
navigationIconContentDescription = stringResource(R.string.feature_settings_top_app_bar_navigation_icon_description),
actionIcon = OxygenIcons.MoreVert,
actionIconContentDescription = stringResource(R.string.feature_settings_top_app_bar_action_icon_description),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
onNavigationClick = { appState.navigateToSearch() },
onActionClick = { showSettingsDialog = true }
)
}
OxygenNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = SnackbarDuration.Short
) == SnackbarResult.ActionPerformed
},
startDestination = when (appState.launchPageConfig) {
LaunchPageConfig.TOOLS -> TOOLS_ROUTE
LaunchPageConfig.STAR -> STAR_ROUTE
}
)
}
}
}
}
}
}
@Composable
private fun OxygenBottomBar(
modifier: Modifier = Modifier,
destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?
) {
OxygenNavigationBar(
modifier = modifier
) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
OxygenNavigationBarItem(
modifier = modifier,
selected = selected,
label = { Text(stringResource(destination.titleTextId)) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null
)
},
onClick = { onNavigateToDestination(destination) }
)
}
}
}
@Composable
private fun OxygenNavRail(
modifier: Modifier = Modifier,
destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?
) {
OxygenNavigationRail(
modifier = modifier
) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
OxygenNavigationRailItem(
modifier = modifier,
selected = selected,
label = { Text(stringResource(destination.titleTextId)) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null
)
},
onClick = { onNavigateToDestination(destination) }
)
}
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
} ?: false

View File

@@ -0,0 +1,117 @@
package top.fatweb.oxygen.toolbox.ui
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
import top.fatweb.oxygen.toolbox.navigation.navigateToSearch
import top.fatweb.oxygen.toolbox.navigation.navigateToStar
import top.fatweb.oxygen.toolbox.navigation.navigateToTools
import kotlin.time.Duration.Companion.seconds
@Composable
fun rememberOxygenAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
launchPageConfig: LaunchPageConfig
): OxygenAppState = remember(
windowSizeClass,
networkMonitor,
timeZoneMonitor,
coroutineScope,
navController,
launchPageConfig
) {
OxygenAppState(
windowSizeClass,
networkMonitor,
timeZoneMonitor,
coroutineScope,
navController,
launchPageConfig
)
}
@Stable
class OxygenAppState(
val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope,
val navController: NavHostController,
val launchPageConfig: LaunchPageConfig
) {
val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
TOOLS_ROUTE -> TopLevelDestination.TOOLS
STAR_ROUTE -> TopLevelDestination.STAR
else -> null
}
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = coroutineScope,
initialValue = false,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
val currentTimeZone = timeZoneMonitor.currentTimeZone
.stateIn(
scope = coroutineScope,
initialValue = TimeZone.currentSystemDefault(),
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
val topLevelNavOptions = navOptions {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (topLevelDestination) {
TopLevelDestination.TOOLS -> navController.navigateToTools(topLevelNavOptions)
TopLevelDestination.STAR -> navController.navigateToStar(topLevelNavOptions)
}
}
fun navigateToSearch() = navController.navigateToSearch()
}

View File

@@ -0,0 +1,145 @@
package top.fatweb.oxygen.toolbox.ui.component
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.ui.theme.GradientColors
import top.fatweb.oxygen.toolbox.ui.theme.LocalBackgroundTheme
import top.fatweb.oxygen.toolbox.ui.theme.LocalGradientColors
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
import kotlin.math.tan
@Composable
fun OxygenBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val color = LocalBackgroundTheme.current.color
val tonalElevation = LocalBackgroundTheme.current.tonalElevation
Surface(
color = if (color == Color.Unspecified) Color.Transparent else color,
tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation,
modifier = modifier.fillMaxSize()
) {
CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) {
content()
}
}
}
@Composable
fun OxygenGradientBackground(
modifier: Modifier = Modifier,
gradientColors: GradientColors = LocalGradientColors.current,
content: @Composable () -> Unit
) {
val currentTopColor by rememberUpdatedState(gradientColors.top)
val currentBottomColor by rememberUpdatedState(gradientColors.bottom)
Surface(
color = if (gradientColors.container == Color.Unspecified) Color.Transparent else gradientColors.container,
modifier = modifier.fillMaxSize()
) {
Box(
Modifier
.fillMaxSize()
.drawWithCache {
val offset = size.height * tan(
Math
.toRadians(11.06)
.toFloat()
)
val start = Offset(size.width / 2 + offset / 2, 0f)
val end = Offset(size.width / 2 - offset / 2, size.height)
val topGradient = Brush.linearGradient(
0f to if (currentTopColor == Color.Unspecified) Color.Transparent else currentTopColor,
0.724f to Color.Transparent,
start = start,
end = end
)
val bottomGradient = Brush.linearGradient(
0.2552f to Color.Transparent,
1f to if (currentBottomColor == Color.Unspecified) Color.Transparent else currentBottomColor,
start = start,
end = end
)
onDrawBehind {
drawRect(topGradient)
drawRect(bottomGradient)
}
}
) {
content()
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme")
annotation class ThemePreviews
@ThemePreviews
@Composable
fun BackgroundDefault() {
OxygenTheme(dynamicColor = false) {
OxygenBackground(Modifier.size(100.dp), content = {})
}
}
@ThemePreviews
@Composable
fun BackgroundDynamic() {
OxygenTheme(dynamicColor = true) {
OxygenBackground(Modifier.size(100.dp), content = {})
}
}
@ThemePreviews
@Composable
fun BackgroundAndroid() {
OxygenTheme(androidTheme = true) {
OxygenBackground(Modifier.size(100.dp), content = {})
}
}
@ThemePreviews
@Composable
fun GradientBackgroundDefault() {
OxygenTheme(dynamicColor = false) {
OxygenGradientBackground(Modifier.size(100.dp), content = {})
}
}
@ThemePreviews
@Composable
fun GradientBackgroundDynamic() {
OxygenTheme(dynamicColor = true) {
OxygenGradientBackground(Modifier.size(100.dp), content = {})
}
}
@ThemePreviews
@Composable
fun GradientBackgroundAndroid() {
OxygenTheme(androidTheme = true) {
OxygenGradientBackground(Modifier.size(100.dp), content = {})
}
}

View File

@@ -0,0 +1,179 @@
package top.fatweb.oxygen.toolbox.ui.component
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
@Composable
fun RowScope.OxygenNavigationBarItem(
modifier: Modifier = Modifier,
selected: Boolean,
label: @Composable (() -> Unit)? = null,
icon: @Composable () -> Unit,
selectedIcon: @Composable () -> Unit,
onClick: () -> Unit,
enabled: Boolean = true,
alwaysShowLabel: Boolean = false
) {
NavigationBarItem(
modifier = modifier,
selected = selected,
label = label,
icon = if (selected) selectedIcon else icon,
onClick = onClick,
enabled = enabled,
alwaysShowLabel = alwaysShowLabel,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
unselectedIconColor = OxygenNavigationDefaults.navigationContentColor(),
selectedTextColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = OxygenNavigationDefaults.navigationContentColor(),
indicatorColor = OxygenNavigationDefaults.navigationIndicatorColor()
)
)
}
@Composable
fun OxygenNavigationBar(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
NavigationBar(
modifier = modifier,
contentColor = OxygenNavigationDefaults.navigationContentColor(),
content = content,
tonalElevation = 0.dp
)
}
@Composable
fun OxygenNavigationRailItem(
modifier: Modifier = Modifier,
selected: Boolean,
label: @Composable (() -> Unit)? = null,
icon: @Composable () -> Unit,
selectedIcon: @Composable () -> Unit,
onClick: () -> Unit,
enabled: Boolean = true,
alwaysShowLabel: Boolean = true
) {
NavigationRailItem(
modifier = modifier,
selected = selected,
label = label,
icon = if (selected) selectedIcon else icon,
onClick = onClick,
enabled = enabled,
alwaysShowLabel = alwaysShowLabel,
colors = NavigationRailItemDefaults.colors(
selectedIconColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
unselectedIconColor = OxygenNavigationDefaults.navigationContentColor(),
selectedTextColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = OxygenNavigationDefaults.navigationContentColor(),
indicatorColor = OxygenNavigationDefaults.navigationIndicatorColor()
)
)
}
@Composable
fun OxygenNavigationRail(
modifier: Modifier = Modifier,
header: @Composable (ColumnScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
NavigationRail(
modifier = modifier,
header = header,
contentColor = OxygenNavigationDefaults.navigationContentColor(),
content = content,
containerColor = Color.Transparent
)
}
object OxygenNavigationDefaults {
@Composable
fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer
@Composable
fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant
@Composable
fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer
}
@ThemePreviews
@Composable
fun OxygenNavigationBarPreview() {
val items = TopLevelDestination.entries
OxygenTheme {
OxygenNavigationBar {
items.forEachIndexed { index, item ->
OxygenNavigationBarItem(
selected = index == 0,
label = { Text(stringResource(item.titleTextId)) },
icon = {
Icon(
imageVector = item.unselectedIcon,
contentDescription = stringResource(item.titleTextId)
)
},
selectedIcon = {
Icon(
imageVector = item.selectedIcon, contentDescription = stringResource(
item.titleTextId
)
)
},
onClick = {}
)
}
}
}
}
@ThemePreviews
@Composable
fun OxygenNavigationRailPreview() {
val items = TopLevelDestination.entries
OxygenTheme {
OxygenNavigationRail {
items.forEachIndexed { index, item ->
OxygenNavigationRailItem(
selected = index == 0,
label = { Text(stringResource(item.titleTextId)) },
icon = {
Icon(
imageVector = item.unselectedIcon,
contentDescription = stringResource(item.titleTextId)
)
},
selectedIcon = {
Icon(
imageVector = item.selectedIcon, contentDescription = stringResource(
item.titleTextId
)
)
},
onClick = {}
)
}
}
}
}

View File

@@ -0,0 +1,72 @@
package top.fatweb.oxygen.toolbox.ui.component
import androidx.annotation.StringRes
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
import android.R as androidR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OxygenTopAppBar(
modifier: Modifier = Modifier,
@StringRes titleRes: Int,
navigationIcon: ImageVector,
navigationIconContentDescription: String,
actionIcon: ImageVector,
actionIconContentDescription: String,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {},
onActionClick: () -> Unit = {}
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = { Text(stringResource(titleRes)) },
navigationIcon = {
IconButton(onClick = onNavigationClick) {
Icon(
imageVector = navigationIcon,
contentDescription = navigationIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
actions = {
IconButton(onClick = onActionClick) {
Icon(
imageVector = actionIcon,
contentDescription = actionIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
colors = colors
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
private fun OxygenTopAppBarPreview() {
OxygenTheme {
OxygenTopAppBar(
titleRes = androidR.string.untitled,
navigationIcon = OxygenIcons.Search,
navigationIconContentDescription = "Navigation icon",
actionIcon = OxygenIcons.MoreVert,
actionIconContentDescription = "Action icon"
)
}
}

View File

@@ -0,0 +1,29 @@
package top.fatweb.oxygen.toolbox.ui.search
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
@Composable
internal fun SearchRoute(
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
// searchViewmodel: SearchViewModel = hiltViewModel()
) {
Row(
modifier = modifier
.fillMaxSize()
.safeDrawingPadding()
) {
IconButton(onClick = onBackClick) {
Icon(imageVector = OxygenIcons.Back, contentDescription = null)
}
Text("Search")
}
}

View File

@@ -0,0 +1,9 @@
package top.fatweb.oxygen.toolbox.ui.search
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
) : ViewModel()

View File

@@ -0,0 +1,301 @@
package top.fatweb.oxygen.toolbox.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.ui.component.ThemePreviews
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
import top.fatweb.oxygen.toolbox.ui.theme.supportsDynamicTheming
@Composable
fun SettingsDialog(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog(
modifier = modifier,
settingsUiState = settingsUiState,
onDismiss = onDismiss,
onChangeLanguageConfig = viewModel::updateLanguageConfig,
onChangeLaunchPageConfig = viewModel::updateLaunchPageConfig,
onchangeThemeBrandConfig = viewModel::updateThemeBrandConfig,
onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,
onchangeUseDynamicColor = viewModel::updateUseDynamicColor
)
}
@Composable
fun SettingsDialog(
modifier: Modifier = Modifier,
settingsUiState: SettingsUiState,
onDismiss: () -> Unit,
supportDynamicColor: Boolean = supportsDynamicTheming(),
onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit,
onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit,
onchangeThemeBrandConfig: (themeBrandConfig: ThemeBrandConfig) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
onchangeUseDynamicColor: (useDynamicColor: Boolean) -> Unit
) {
val configuration = LocalConfiguration.current
AlertDialog(
modifier = modifier
.widthIn(max = configuration.screenWidthDp.dp - 80.dp)
.heightIn(max = configuration.screenHeightDp.dp - 40.dp),
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.feature_settings_title),
style = MaterialTheme.typography.titleLarge
)
},
text = {
HorizontalDivider()
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
when (settingsUiState) {
SettingsUiState.Loading -> {
Text(
modifier = Modifier.padding(vertical = 16.dp),
text = stringResource(R.string.feature_settings_loading)
)
}
is SettingsUiState.Success -> {
SettingsPanel(
settings = settingsUiState.settings,
supportDynamicColor = supportDynamicColor,
onChangeLanguageConfig = onChangeLanguageConfig,
onChangeLaunchPageConfig = onChangeLaunchPageConfig,
onchangeThemeBrandConfig = onchangeThemeBrandConfig,
onChangeDarkThemeConfig = onChangeDarkThemeConfig,
onchangeUseDynamicColor = onchangeUseDynamicColor
)
}
}
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
},
confirmButton = {
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.clickable { onDismiss() },
text = stringResource(R.string.feature_settings_dismiss_dialog_button_text),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
)
}
@Composable
private fun ColumnScope.SettingsPanel(
settings: UserEditableSettings,
supportDynamicColor: Boolean,
onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit,
onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit,
onchangeThemeBrandConfig: (themeBrandConfig: ThemeBrandConfig) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
onchangeUseDynamicColor: (useDynamicColor: Boolean) -> Unit
) {
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_language))
Column(
modifier = Modifier.selectableGroup()
) {
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_language_system_default),
selected = settings.languageConfig == LanguageConfig.FOLLOW_SYSTEM,
onClick = { onChangeLanguageConfig(LanguageConfig.FOLLOW_SYSTEM) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_language_chinese),
selected = settings.languageConfig == LanguageConfig.CHINESE,
onClick = { onChangeLanguageConfig(LanguageConfig.CHINESE) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_language_english),
selected = settings.languageConfig == LanguageConfig.ENGLISH,
onClick = { onChangeLanguageConfig(LanguageConfig.ENGLISH) }
)
}
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_launch_page))
Column(
modifier = Modifier.selectableGroup()
) {
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_launch_page_tools),
selected = settings.launchPageConfig == LaunchPageConfig.TOOLS,
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.TOOLS) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_launch_page_star),
selected = settings.launchPageConfig == LaunchPageConfig.STAR,
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.STAR) }
)
}
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_theme_brand))
Column(
modifier = Modifier.selectableGroup()
) {
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_theme_brand_default),
selected = settings.themeBrandConfig == ThemeBrandConfig.DEFAULT,
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.DEFAULT) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_theme_brand_android),
selected = settings.themeBrandConfig == ThemeBrandConfig.ANDROID,
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.ANDROID) }
)
}
AnimatedVisibility(visible = settings.themeBrandConfig == ThemeBrandConfig.DEFAULT && supportDynamicColor) {
Column(
modifier = Modifier.selectableGroup()
) {
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_dynamic_color))
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_dynamic_color_enable),
selected = settings.useDynamicColor,
onClick = { onchangeUseDynamicColor(true) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_dynamic_color_disable),
selected = !settings.useDynamicColor,
onClick = { onchangeUseDynamicColor(false) }
)
}
}
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_dark_mode))
Column(
modifier = Modifier.selectableGroup()
) {
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_dark_mode_system_default),
selected = settings.darkThemeConfig == DarkThemeConfig.FOLLOW_SYSTEM,
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.FOLLOW_SYSTEM) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_dark_mode_light),
selected = settings.darkThemeConfig == DarkThemeConfig.LIGHT,
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.LIGHT) }
)
SettingsDialogThemeChooserRow(
text = stringResource(R.string.feature_settings_dark_mode_dark),
selected = settings.darkThemeConfig == DarkThemeConfig.DARK,
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.DARK) }
)
}
}
@Composable
private fun SettingsDialogSectionTitle(text: String) {
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
text = text,
style = MaterialTheme.typography.titleMedium
)
}
@Composable
private fun SettingsDialogThemeChooserRow(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxSize()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onClick
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(text)
}
}
@ThemePreviews
@Composable
private fun SettingsDialogLoadingPreview() {
OxygenTheme {
SettingsDialog(
onDismiss = { },
settingsUiState = SettingsUiState.Loading,
onChangeLanguageConfig = {},
onChangeLaunchPageConfig = {},
onchangeThemeBrandConfig = {},
onChangeDarkThemeConfig = {},
onchangeUseDynamicColor = {}
)
}
}
@ThemePreviews
@Composable
private fun SettingDialogPreview() {
OxygenTheme {
SettingsDialog(
onDismiss = {},
settingsUiState = SettingsUiState.Success(
UserEditableSettings(
languageConfig = LanguageConfig.FOLLOW_SYSTEM,
launchPageConfig = LaunchPageConfig.TOOLS,
themeBrandConfig = ThemeBrandConfig.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = true
)
),
onChangeLanguageConfig = {},
onChangeLaunchPageConfig = {},
onchangeThemeBrandConfig = {},
onChangeDarkThemeConfig = {},
onchangeUseDynamicColor = {}
)
}
}

View File

@@ -0,0 +1,84 @@
package top.fatweb.oxygen.toolbox.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository
) : ViewModel() {
val settingsUiState: StateFlow<SettingsUiState> =
userDataRepository.userData
.map { userData ->
SettingsUiState.Success(
settings = UserEditableSettings(
languageConfig = userData.languageConfig,
launchPageConfig = userData.launchPageConfig,
themeBrandConfig = userData.themeBrandConfig,
darkThemeConfig = userData.darkThemeConfig,
useDynamicColor = userData.useDynamicColor
)
)
}
.stateIn(
scope = viewModelScope,
initialValue = SettingsUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
fun updateLanguageConfig(languageConfig: LanguageConfig) {
viewModelScope.launch {
userDataRepository.setLanguageConfig(languageConfig)
}
}
fun updateLaunchPageConfig(launchPageConfig: LaunchPageConfig) {
viewModelScope.launch {
userDataRepository.setLaunchPageConfig(launchPageConfig)
}
}
fun updateThemeBrandConfig(themeBrandConfig: ThemeBrandConfig) {
viewModelScope.launch {
userDataRepository.setThemeBrandConfig(themeBrandConfig)
}
}
fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
viewModelScope.launch {
userDataRepository.setDarkThemeConfig(darkThemeConfig)
}
}
fun updateUseDynamicColor(useDynamicColor: Boolean) {
viewModelScope.launch {
userDataRepository.setUseDynamicColor(useDynamicColor)
}
}
}
data class UserEditableSettings(
val languageConfig: LanguageConfig,
val launchPageConfig: LaunchPageConfig,
val themeBrandConfig: ThemeBrandConfig,
val darkThemeConfig: DarkThemeConfig,
val useDynamicColor: Boolean
)
sealed interface SettingsUiState {
data object Loading : SettingsUiState
data class Success(val settings: UserEditableSettings) : SettingsUiState
}

View File

@@ -0,0 +1,3 @@
package top.fatweb.oxygen.toolbox.ui.star
class StarScreen

View File

@@ -0,0 +1,14 @@
package top.fatweb.oxygen.toolbox.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
@Immutable
data class BackgroundTheme(
val color: Color = Color.Unspecified,
val tonalElevation: Dp = Dp.Unspecified
)
val LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() }

View File

@@ -0,0 +1,66 @@
package top.fatweb.oxygen.toolbox.ui.theme
import androidx.compose.ui.graphics.Color
internal val Blue10 = Color(0xFF001F28)
internal val Blue20 = Color(0xFF003544)
internal val Blue30 = Color(0xFF004D61)
internal val Blue40 = Color(0xFF006780)
internal val Blue80 = Color(0xFF5DD5FC)
internal val Blue90 = Color(0xFFB8EAFF)
internal val DarkGreen10 = Color(0xFF0D1F12)
internal val DarkGreen20 = Color(0xFF223526)
internal val DarkGreen30 = Color(0xFF394B3C)
internal val DarkGreen40 = Color(0xFF4F6352)
internal val DarkGreen80 = Color(0xFFB7CCB8)
internal val DarkGreen90 = Color(0xFFD3E8D3)
internal val DarkGreenGray10 = Color(0xFF1A1C1A)
internal val DarkGreenGray20 = Color(0xFF2F312E)
internal val DarkGreenGray90 = Color(0xFFE2E3DE)
internal val DarkGreenGray95 = Color(0xFFF0F1EC)
internal val DarkGreenGray99 = Color(0xFFFBFDF7)
internal val DarkPurpleGray10 = Color(0xFF201A1B)
internal val DarkPurpleGray20 = Color(0xFF362F30)
internal val DarkPurpleGray90 = Color(0xFFECDFE0)
internal val DarkPurpleGray95 = Color(0xFFFAEEEF)
internal val DarkPurpleGray99 = Color(0xFFFCFCFC)
internal val Green10 = Color(0xFF00210B)
internal val Green20 = Color(0xFF003919)
internal val Green30 = Color(0xFF005227)
internal val Green40 = Color(0xFF006D36)
internal val Green80 = Color(0xFF0EE37C)
internal val Green90 = Color(0xFF5AFF9D)
internal val GreenGray30 = Color(0xFF414941)
internal val GreenGray50 = Color(0xFF727971)
internal val GreenGray60 = Color(0xFF8B938A)
internal val GreenGray80 = Color(0xFFC1C9BF)
internal val GreenGray90 = Color(0xFFDDE5DB)
internal val Orange10 = Color(0xFF380D00)
internal val Orange20 = Color(0xFF5B1A00)
internal val Orange30 = Color(0xFF812800)
internal val Orange40 = Color(0xFFA23F16)
internal val Orange80 = Color(0xFFFFB59B)
internal val Orange90 = Color(0xFFFFDBCF)
internal val Purple10 = Color(0xFF36003C)
internal val Purple20 = Color(0xFF560A5D)
internal val Purple30 = Color(0xFF702776)
internal val Purple40 = Color(0xFF8B418F)
internal val Purple80 = Color(0xFFFFA9FE)
internal val Purple90 = Color(0xFFFFD6FA)
internal val PurpleGray30 = Color(0xFF4D444C)
internal val PurpleGray50 = Color(0xFF7F747C)
internal val PurpleGray60 = Color(0xFF998D96)
internal val PurpleGray80 = Color(0xFFD0C3CC)
internal val PurpleGray90 = Color(0xFFEDDEE8)
internal val Red10 = Color(0xFF410002)
internal val Red20 = Color(0xFF690005)
internal val Red30 = Color(0xFF93000A)
internal val Red40 = Color(0xFFBA1A1A)
internal val Red80 = Color(0xFFFFB4AB)
internal val Red90 = Color(0xFFFFDAD6)
internal val Teal10 = Color(0xFF001F26)
internal val Teal20 = Color(0xFF02363F)
internal val Teal30 = Color(0xFF214D56)
internal val Teal40 = Color(0xFF3A656F)
internal val Teal80 = Color(0xFFA2CED9)
internal val Teal90 = Color(0xFFBEEAF6)

View File

@@ -0,0 +1,14 @@
package top.fatweb.oxygen.toolbox.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class GradientColors(
val top: Color = Color.Unspecified,
val bottom: Color = Color.Unspecified,
val container: Color = Color.Unspecified
)
val LocalGradientColors = staticCompositionLocalOf { GradientColors() }

View File

@@ -0,0 +1,194 @@
package top.fatweb.oxygen.toolbox.ui.theme
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
val LightDefaultColorScheme = lightColorScheme(
primary = Purple40,
onPrimary = Color.White,
primaryContainer = Purple90,
onPrimaryContainer = Purple10,
secondary = Orange40,
onSecondary = Color.White,
secondaryContainer = Orange90,
onSecondaryContainer = Orange10,
tertiary = Blue40,
onTertiary = Color.White,
tertiaryContainer = Blue90,
onTertiaryContainer = Blue10,
error = Red40,
onError = Color.White,
errorContainer = Red90,
onErrorContainer = Red10,
background = DarkPurpleGray99,
onBackground = DarkPurpleGray10,
surface = DarkPurpleGray99,
onSurface = DarkPurpleGray10,
surfaceVariant = PurpleGray90,
onSurfaceVariant = PurpleGray30,
inverseSurface = DarkPurpleGray20,
inverseOnSurface = DarkPurpleGray95,
outline = PurpleGray50,
)
val DarkDefaultColorScheme = darkColorScheme(
primary = Purple80,
onPrimary = Purple20,
primaryContainer = Purple30,
onPrimaryContainer = Purple90,
secondary = Orange80,
onSecondary = Orange20,
secondaryContainer = Orange30,
onSecondaryContainer = Orange90,
tertiary = Blue80,
onTertiary = Blue20,
tertiaryContainer = Blue30,
onTertiaryContainer = Blue90,
error = Red80,
onError = Red20,
errorContainer = Red30,
onErrorContainer = Red90,
background = DarkPurpleGray10,
onBackground = DarkPurpleGray90,
surface = DarkPurpleGray10,
onSurface = DarkPurpleGray90,
surfaceVariant = PurpleGray30,
onSurfaceVariant = PurpleGray80,
inverseSurface = DarkPurpleGray90,
inverseOnSurface = DarkPurpleGray10,
outline = PurpleGray60,
)
val LightAndroidColorScheme = lightColorScheme(
primary = Green40,
onPrimary = Color.White,
primaryContainer = Green90,
onPrimaryContainer = Green10,
secondary = DarkGreen40,
onSecondary = Color.White,
secondaryContainer = DarkGreen90,
onSecondaryContainer = DarkGreen10,
tertiary = Teal40,
onTertiary = Color.White,
tertiaryContainer = Teal90,
onTertiaryContainer = Teal10,
error = Red40,
onError = Color.White,
errorContainer = Red90,
onErrorContainer = Red10,
background = DarkGreenGray99,
onBackground = DarkGreenGray10,
surface = DarkGreenGray99,
onSurface = DarkGreenGray10,
surfaceVariant = GreenGray90,
onSurfaceVariant = GreenGray30,
inverseSurface = DarkGreenGray20,
inverseOnSurface = DarkGreenGray95,
outline = GreenGray50,
)
val DarkAndroidColorScheme = darkColorScheme(
primary = Green80,
onPrimary = Green20,
primaryContainer = Green30,
onPrimaryContainer = Green90,
secondary = DarkGreen80,
onSecondary = DarkGreen20,
secondaryContainer = DarkGreen30,
onSecondaryContainer = DarkGreen90,
tertiary = Teal80,
onTertiary = Teal20,
tertiaryContainer = Teal30,
onTertiaryContainer = Teal90,
error = Red80,
onError = Red20,
errorContainer = Red30,
onErrorContainer = Red90,
background = DarkGreenGray10,
onBackground = DarkGreenGray90,
surface = DarkGreenGray10,
onSurface = DarkGreenGray90,
surfaceVariant = GreenGray30,
onSurfaceVariant = GreenGray80,
inverseSurface = DarkGreenGray90,
inverseOnSurface = DarkGreenGray10,
outline = GreenGray60,
)
val LightAndroidGradientColors = GradientColors(container = DarkGreenGray95)
val DarkAndroidGradientColors = GradientColors(container = Color.Black)
val LightAndroidBackgroundTheme = BackgroundTheme(color = DarkGreenGray95)
val DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black)
@Composable
fun OxygenTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
dynamicColor && supportsDynamicTheming() -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
val emptyGradientColors = GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))
val defaultGradientColors = GradientColors(
top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer,
container = colorScheme.surface
)
val gradientColors = when {
androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors
dynamicColor && supportsDynamicTheming() -> emptyGradientColors
else -> defaultGradientColors
}
val defaultBackgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = when {
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme
}
val tintTheme = when {
androidTheme -> TintTheme()
dynamicColor && supportsDynamicTheming() -> TintTheme(colorScheme.primary)
else -> TintTheme()
}
CompositionLocalProvider(
LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme,
LocalTintTheme provides tintTheme
) {
MaterialTheme(
colorScheme = colorScheme,
typography = OxygenTypography,
content = content
)
}
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

View File

@@ -0,0 +1,12 @@
package top.fatweb.oxygen.toolbox.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class TintTheme(
val iconTint: Color = Color.Unspecified
)
val LocalTintTheme = staticCompositionLocalOf { TintTheme() }

View File

@@ -0,0 +1,129 @@
package top.fatweb.oxygen.toolbox.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
internal val OxygenTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Bottom,
trim = LineHeightStyle.Trim.None
)
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Bottom,
trim = LineHeightStyle.Trim.LastLineBottom
)
),
titleMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
// Default text style
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.None
)
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
// Used for Button
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.LastLineBottom
)
),
// Used for Navigation items
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.LastLineBottom
)
),
// Used for Tag
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 10.sp,
lineHeight = 14.sp,
letterSpacing = 0.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.LastLineBottom
)
)
)

View File

@@ -0,0 +1,8 @@
package top.fatweb.oxygen.toolbox.ui.tools
import androidx.compose.runtime.Composable
@Composable
internal fun ToolsScreen() {
}

View File

@@ -0,0 +1,6 @@
package top.fatweb.oxygen.toolbox.ui.util
import androidx.compose.runtime.compositionLocalOf
import kotlinx.datetime.TimeZone
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }

View File

@@ -0,0 +1,58 @@
package top.fatweb.oxygen.toolbox.ui.util
import android.app.Activity
import android.content.Context
import android.os.Build
import android.os.LocaleList
import androidx.annotation.RequiresApi
import top.fatweb.oxygen.toolbox.model.LanguageConfig
import java.util.Locale
object LocaleUtils {
fun switchLocale(activity: Activity, languageConfig: LanguageConfig) {
val newLanguage = when (languageConfig) {
LanguageConfig.FOLLOW_SYSTEM -> ResourcesUtils.getSystemLocale().get(0)!!.language
LanguageConfig.CHINESE -> "zh"
LanguageConfig.ENGLISH -> "en"
}
val currentLanguage = ResourcesUtils.getAppLocale(activity).language
if (newLanguage != currentLanguage) {
activity.recreate()
}
}
fun attachBaseContext(context: Context, languageConfig: LanguageConfig): Context {
val locale: Locale = getLocaleFromLanguageConfig(languageConfig)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
createConfigurationContext(context, locale)
} else {
updateConfiguration(context, locale)
}
}
private fun getLocaleFromLanguageConfig(languageConfig: LanguageConfig): Locale =
when (languageConfig) {
LanguageConfig.FOLLOW_SYSTEM -> ResourcesUtils.getSystemLocale().get(0)!!
LanguageConfig.CHINESE -> Locale("zh")
LanguageConfig.ENGLISH -> Locale("en")
}
@RequiresApi(Build.VERSION_CODES.N)
private fun createConfigurationContext(context: Context, locale: Locale): Context {
val configuration = context.resources.configuration
configuration.setLocales(LocaleList(locale))
return context.createConfigurationContext(configuration)
}
@Suppress("DEPRECATION")
private fun updateConfiguration(context: Context, locale: Locale): Context {
val resources = context.resources
val configuration = resources.configuration
configuration.locale = locale
resources.updateConfiguration(configuration, resources.displayMetrics)
return context
}
}

View File

@@ -0,0 +1,38 @@
package top.fatweb.oxygen.toolbox.ui.util
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Resources
import android.os.Build
import androidx.core.os.ConfigurationCompat
import androidx.core.os.LocaleListCompat
import java.util.Locale
object ResourcesUtils {
fun getConfiguration(context: Context) = context.resources.configuration
fun getDisplayMetrics(context: Context) = context.resources.displayMetrics
fun getAppLocale(context: Context): Locale =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) getConfiguration(context).locales.get(0)
else getConfiguration(context).locale
fun getSystemLocale(): LocaleListCompat =
ConfigurationCompat.getLocales(Resources.getSystem().configuration)
fun getAppVersionName(context: Context): String =
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: PackageManager.NameNotFoundException) {
"Unknown"
}
fun getAppVersionCode(context: Context): Long =
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
else context.packageManager.getPackageInfo(context.packageName, 0).versionCode.toLong()
} catch (e: PackageManager.NameNotFoundException) {
-1
}
}

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
option java_package = "top.fatweb.oxygen.toolbox.datastore";
option java_multiple_files = true;
enum DarkThemeConfigProto {
DARK_THEME_CONFIG_UNSPECIFIED = 0;
DARK_THEME_CONFIG_FOLLOW_SYSTEM = 1;
DARK_THEME_CONFIG_LIGHT = 2;
DARK_THEME_CONFIG_DARK = 3;
}

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
option java_package = "top.fatweb.oxygen.toolbox.datastore";
option java_multiple_files = true;
enum LanguageConfigProto {
LANGUAGE_CONFIG_UNSPECIFIED = 0;
LANGUAGE_CONFIG_FOLLOW_SYSTEM = 1;
LANGUAGE_CONFIG_CHINESE = 2;
LANGUAGE_CONFIG_ENGLISH = 3;
}

View File

@@ -0,0 +1,10 @@
syntax = "proto3";
option java_package = "top.fatweb.oxygen.toolbox.datastore";
option java_multiple_files = true;
enum LaunchPageConfigProto {
LAUNCH_PAGE_CONFIG_UNSPECIFIED = 0;
LAUNCH_PAGE_CONFIG_TOOLS = 1;
LAUNCH_PAGE_CONFIG_STAR = 2;
}

View File

@@ -0,0 +1,10 @@
syntax = "proto3";
option java_package = "top.fatweb.oxygen.toolbox.datastore";
option java_multiple_files = true;
enum ThemeBrandConfigProto {
THEME_BRAND_CONFIG_UNSPECIFIED = 0;
THEME_BRAND_CONFIG_DEFAULT = 1;
THEME_BRAND_CONFIG_ANDROID = 2;
}

View File

@@ -0,0 +1,17 @@
syntax = "proto3";
import "com/fatweb/oxygen/toolbox/data/language_config.proto";
import "com/fatweb/oxygen/toolbox/data/launch_page_config.proto";
import "com/fatweb/oxygen/toolbox/data/theme_brand_config.proto";
import "com/fatweb/oxygen/toolbox/data/dark_theme_config.proto";
option java_package = "top.fatweb.oxygen.toolbox.datastore";
option java_multiple_files = true;
message UserPreferences {
LanguageConfigProto language_config = 1;
LaunchPageConfigProto launch_page_config = 2;
ThemeBrandConfigProto theme_brand_config = 3;
DarkThemeConfigProto dark_theme_config = 4;
bool use_dynamic_color = 5;
}

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#FFFFFF"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="NightAdjusted.Theme.Oxygen" parent="android:Theme.Material.NoActionBar" />
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
</style>
</resources>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OxygenToolbox</string>
<string name="no_connect">⚠️ 无法连接至互联网</string>
<string name="feature_tools_title">工具</string>
<string name="feature_star_title">收藏</string>
<string name="feature_settings_title">设置</string>
<string name="feature_settings_loading">加载中…</string>
<string name="feature_settings_language">语言</string>
<string name="feature_settings_language_system_default">系统默认</string>
<string name="feature_settings_launch_page">启动页</string>
<string name="feature_settings_launch_page_tools">工具</string>
<string name="feature_settings_launch_page_star">收藏</string>
<string name="feature_settings_theme_brand">主题类型</string>
<string name="feature_settings_theme_brand_default">默认</string>
<string name="feature_settings_theme_brand_android">Android</string>
<string name="feature_settings_dark_mode">深色模式</string>
<string name="feature_settings_dark_mode_system_default">系统默认</string>
<string name="feature_settings_dark_mode_light">明亮</string>
<string name="feature_settings_dark_mode_dark">深色</string>
<string name="feature_settings_dynamic_color">动态颜色</string>
<string name="feature_settings_dynamic_color_enable">启用</string>
<string name="feature_settings_dynamic_color_disable">禁用</string>
<string name="feature_settings_top_app_bar_action_icon_description">更多</string>
<string name="feature_settings_top_app_bar_navigation_icon_description">搜索</string>
<string name="feature_settings_dismiss_dialog_button_text">完成</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,28 @@
<resources>
<string name="app_name">OxygenToolbox</string>
<string name="no_connect">⚠️ Unable to connect to the internet</string>
<string name="feature_tools_title">Tools</string>
<string name="feature_star_title">Star</string>
<string name="feature_settings_title">Settings</string>
<string name="feature_settings_loading">Loading…</string>
<string name="feature_settings_language">Language</string>
<string name="feature_settings_language_system_default">System Default</string>
<string name="feature_settings_language_chinese" translatable="false">中文</string>
<string name="feature_settings_language_english" translatable="false">English</string>
<string name="feature_settings_launch_page">Launch Page</string>
<string name="feature_settings_launch_page_tools">Tools</string>
<string name="feature_settings_launch_page_star">Star</string>
<string name="feature_settings_theme_brand">Theme Brand</string>
<string name="feature_settings_theme_brand_default">Default</string>
<string name="feature_settings_theme_brand_android">Android</string>
<string name="feature_settings_dark_mode">Dark Mode</string>
<string name="feature_settings_dark_mode_system_default">System Default</string>
<string name="feature_settings_dark_mode_light">Light</string>
<string name="feature_settings_dark_mode_dark">Dark</string>
<string name="feature_settings_dynamic_color">Dynamic Color</string>
<string name="feature_settings_dynamic_color_enable">Enable</string>
<string name="feature_settings_dynamic_color_disable">Disable</string>
<string name="feature_settings_top_app_bar_action_icon_description">More</string>
<string name="feature_settings_top_app_bar_navigation_icon_description">Search</string>
<string name="feature_settings_dismiss_dialog_button_text">OK</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="NightAdjusted.Theme.Oxygen" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Oxygen" parent="NightAdjusted.Theme.Oxygen" />
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item>
</style>
<style name="Theme.Oxygen.Splash" parent="NightAdjusted.Theme.Splash">
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_oxygen</item>
<item name="postSplashScreenTheme">@style/Theme.Oxygen</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package top.fatweb.oxygen.toolbox
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.protobuf) apply false
}

36
gradle.properties Normal file
View File

@@ -0,0 +1,36 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
org.gradle.parallel=true
# Enable caching between builds.
org.gradle.caching=true
# Enable configuration caching between builds.
org.gradle.configuration-cache=true
# This option is set because of https://github.com/google/play-services-plugins/issues/246
# to generate the Configuration Cache regardless of incompatible tasks.
# See https://github.com/android/nowinandroid/issues/1022 before using it.
org.gradle.configuration-cache.problems=warn
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

75
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,75 @@
[versions]
agp = "8.3.0"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.18"
aboutlibraries = "11.1.0"
protobufPlugin = "0.9.4"
desugarJdkLibs = "2.0.4"
composeBom = "2024.02.02"
junit = "4.13.2"
coreKtx = "1.12.0"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
activityCompose = "1.8.2"
appcompat = "1.6.1"
androidxLifecycle = "2.7.0"
androidxCoreSplashscreen = "1.0.1"
hilt = "2.51"
coil = "2.5.0"
kotlinxDatetime = "0.5.0"
androidxDataStore = "1.0.0"
protobuf = "3.25.2"
androidxNavigation = "2.7.7"
androidxHiltNavigationCompose = "1.2.0"
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
aboutlibraries = {id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries"}
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
[libraries]
desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs"}
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
material-icons-core = { group = "androidx.compose.material", name = "material-icons-core"}
material-icons-extended = {group = "androidx.compose.material", name = "material-icons-extended"}
material3-window-size = {group = "androidx.compose.material3", name = "material3-window-size-class"}
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
lifecycle-runtime-testing = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidxLifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle"}
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" }
dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", version.ref = "hilt" }
hilt-android = {group = "com.google.dagger", name = "hilt-android", version.ref = "hilt"}
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }
androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
#Sat Mar 09 10:57:35 CST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OxygenToolbox"
include(":app")