44 Commits

Author SHA1 Message Date
Shcherbatykh Oleg
ac433bc492 Start impl base news ui 2026-02-27 10:58:29 +03:00
2fc7f40677 Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-ui 2026-02-26 18:21:02 +03:00
8a5e98dc2e update target sdk to 36 2026-02-26 18:20:25 +03:00
Shcherbatykh Oleg
ada0c5307e Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-ui 2026-02-26 17:24:10 +03:00
Shcherbatykh Oleg
12ce668afb Refactoring db module structure 2026-02-26 17:15:55 +03:00
Shcherbatykh Oleg
e96232c474 fix network timeout 2026-02-26 11:23:40 +03:00
Shcherbatykh Oleg
37c926ac77 Impl base structure of bottom-navigation 2026-02-24 19:00:27 +03:00
Shcherbatykh Oleg
4794b0e185 Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-base
# Conflicts:
#	feature/news/build.gradle.kts
2026-02-24 15:49:48 +03:00
Shcherbatykh Oleg
a6ede37285 Merge branch 'feature/schedule-ui' of https://github.com/finocd2la/TSUDesk into develop
# Conflicts:
#	gradle/libs.versions.toml
2026-02-24 15:38:48 +03:00
Shcherbatykh Oleg
cff73f0f35 impl ui logic 2026-02-24 15:19:20 +03:00
Shcherbatykh Oleg
1b962eb07f Start impl base news func 2026-02-24 11:36:11 +03:00
Shcherbatykh Oleg
c7c2ff62f7 update versions 2026-02-24 11:33:57 +03:00
97c9091038 Start impl UI 2026-02-24 08:54:17 +03:00
b440222575 update app icon 2026-02-20 15:57:07 +03:00
Shcherbatykh Oleg
5ba600cc09 clean project 2026-02-19 16:54:49 +03:00
Shcherbatykh Oleg
6a45abf297 refactoring logger 2026-02-19 14:20:13 +03:00
Shcherbatykh Oleg
6aa4b6d39d Impl base schedule navigation; add internal logger 2026-02-19 13:40:00 +03:00
Shcherbatykh Oleg
592d948cd0 Impl base app navigation 2026-02-19 11:56:10 +03:00
40eaa03a67 move version to toml 2026-02-18 20:09:05 +03:00
7d73db7bdd fix compose options 2026-02-18 20:06:57 +03:00
0efc3ac1ec Start implement splash UI 2026-02-18 19:55:53 +03:00
764930a574 fix db loading, add network log 2026-02-18 18:44:43 +03:00
Shcherbatykh Oleg
705b689c58 Implement core:config module 2026-02-18 14:02:04 +03:00
341d128099 fix 2026-02-17 16:59:21 +03:00
7b40f336cd refactoring network layer 2026-02-17 09:23:25 +03:00
d64f2b5b8f update error handle 2026-02-16 17:40:21 +03:00
b47bba9e22 fix schedule repository
flow
2026-02-16 16:44:19 +03:00
2837a63092 Add change base url 2026-02-16 15:14:10 +03:00
43e7d02a61 update schedule usecase call for test 2026-02-16 15:13:20 +03:00
6a583b446d Remove not used emits 2026-02-16 15:12:25 +03:00
9ca225db94 Magrate schedule domain to flow 2026-02-16 11:58:24 +03:00
a885ba7b1f remove unused dependencies 2026-02-14 14:05:04 +03:00
49b5100987 code-style fix, fix db code structure 2026-02-14 13:43:21 +03:00
Shcherbatykh Oleg
7dd3358dac Add Room database, create tables and DAO for schedule 2026-02-13 12:57:41 +03:00
Shcherbatykh Oleg
debc838893 fix jvmTarget in build.gradle 2026-02-13 11:24:29 +03:00
Shcherbatykh Oleg
6a41e301d9 Add build type params, fix network module structure 2026-02-13 10:34:09 +03:00
314adaff43 fix executor method name 2026-02-12 18:36:37 +03:00
3aef1d5eae Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/schedule
# Conflicts:
#	feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/remote/ScheduleApi.kt
#	feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/model/ScheduleEntity.kt
2026-02-12 18:27:51 +03:00
4b79a2de8f Fix schedule query params, fix hilt bindings 2026-02-12 17:31:39 +03:00
7c865b4e0f Fix schedule models, format code 2026-02-12 16:42:27 +03:00
d157fd921e update 2026-02-12 16:11:16 +03:00
0ea2f64d0a Fix network response model: add error handle 2026-02-12 16:09:34 +03:00
Shcherbatykh Oleg
5dac9438fd fix schedule request. Add Moshi and network logger 2026-02-12 14:55:50 +03:00
Shcherbatykh Oleg
04b8164eba Start Schedule development 2026-02-10 20:20:39 +03:00
193 changed files with 4591 additions and 310 deletions

View File

@@ -12,10 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "ru.fincode.tsudesk" applicationId = "ru.fincode.tsudesk"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt() versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get() versionName = libs.versions.versionName.get()
} }
@@ -29,33 +27,57 @@ android {
) )
} }
} }
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = jvm
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = jvm
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = jvm.toString()
} }
buildFeatures {
buildConfig = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
}
}
kapt {
correctErrorTypes = true
} }
dependencies { dependencies {
implementation(libs.core.ktx) // Compose
implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose)
implementation(libs.material) implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity) implementation(libs.compose.runtime)
implementation(libs.androidx.constraintlayout) implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
kapt(libs.hiltcompiler) // Navigation Compose
implementation(libs.hiltandroid) implementation(libs.androidx.navigation.compose)
implementation(libs.okhttp) // DI: Hilt
implementation(libs.retrofit) implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(project(":core:network")) // Modules
implementation(project(":core:database")) implementation(projects.core.common)
implementation(projects.core.config)
implementation(projects.core.navigation)
implementation(projects.core.database.impl)
implementation(projects.core.ui)
implementation(project(":feature:schedule")) implementation(projects.feature.splash)
implementation(project(":feature:progress")) implementation(projects.feature.schedule)
implementation(project(":feature:news")) implementation(projects.feature.progress)
implementation(projects.feature.news)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
} }

View File

@@ -5,16 +5,15 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TSUDesk"> android:theme="@style/Theme.TSUDesk">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@@ -0,0 +1,11 @@
package ru.fincode.tsudesk
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application() {
override fun onCreate() {
super.onCreate()
}
}

View File

@@ -1,28 +1,19 @@
package ru.fincode.tsudesk package ru.fincode.tsudesk
import android.os.Bundle import android.os.Bundle
import androidx.activity.enableEdgeToEdge import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent
import androidx.core.view.ViewCompat import dagger.hilt.android.AndroidEntryPoint
import androidx.core.view.WindowInsetsCompat import ru.fincode.tsudesk.presentation.TSUDeskApp
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
val okHttpClient = HttpClientProvider.provide()
val retrofit = RetrofitProvider.provide( setContent {
baseUrl = NetworkConstants.BASE_URL, TSUDeskApp()
client = okHttpClient }
)
val api = retrofit.create(ScheduleApi::class.java)
} }
} }

View File

@@ -0,0 +1,14 @@
package ru.fincode.tsudesk
import androidx.compose.runtime.Composable
import androidx.navigation.compose.rememberNavController
import ru.fincode.tsudesk.presentation.navigation.AppNavHost
@Composable
fun TSUDeskRoot() {
val navController = rememberNavController()
AppNavHost(
navController = navController
)
}

View File

@@ -0,0 +1,30 @@
package ru.fincode.tsudesk.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.fincode.tsudesk.BuildConfig
import ru.fincode.tsudesk.core.common.app.AppConfig
import javax.inject.Singleton
private const val BASE_TIMEOUT = 30L
@Module
@InstallIn(SingletonComponent::class)
object AppConfigModule {
private const val BASE_URL_PROD = "https://tulsu.ru/schedule/queries/"
private const val BASE_URL_DEVELOP = "https://scherbatykh.ru/app/tsudesk/"
@Provides
@Singleton
fun provideAppConfig(): AppConfig {
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_PROD else BASE_URL_PROD
return AppConfig(
isDebug = BuildConfig.DEBUG,
baseUrl = baseUrl,
networkTimeoutSec = BASE_TIMEOUT
)
}
}

View File

@@ -0,0 +1,22 @@
package ru.fincode.tsudesk.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import ru.fincode.tsudesk.presentation.navigation.AppNavHost
@Composable
fun TSUDeskApp() {
val navController = rememberNavController()
MaterialTheme {
Surface(
modifier = Modifier,
color = MaterialTheme.colorScheme.background
) {
AppNavHost(navController = navController)
}
}
}

View File

@@ -0,0 +1,37 @@
package ru.fincode.tsudesk.app.presentation.main
import androidx.navigation.NavBackStackEntry
import ru.fincode.tsudesk.core.navigation.AppRoute
import ru.fincode.tsudesk.core.navigation.TopLevelDestination
import ru.fincode.tsudesk.core.navigation.route
fun selectedTopLevel(entry: NavBackStackEntry?): TopLevelDestination {
if (entry == null) return TopLevelDestination.SCHEDULE
return runCatching {
when (entry.route<AppRoute>()) {
AppRoute.Schedule,
is AppRoute.ScheduleDetails -> TopLevelDestination.SCHEDULE
AppRoute.News,
is AppRoute.NewsDetails -> TopLevelDestination.NEWS
AppRoute.Progress -> TopLevelDestination.PROGRESS
AppRoute.Settings -> TopLevelDestination.SETTINGS
else -> TopLevelDestination.SCHEDULE
}
}.getOrDefault(TopLevelDestination.SCHEDULE)
}
fun shouldShowBottomBar(entry: NavBackStackEntry?): Boolean {
if (entry == null) return false
return runCatching {
when (entry.route<AppRoute>()) {
AppRoute.Splash -> false
else -> true
}
}.getOrDefault(true)
}

View File

@@ -0,0 +1,82 @@
package ru.fincode.tsudesk.presentation.main
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import ru.fincode.core.ui.components.BottomBarItem
import ru.fincode.core.ui.components.TsudeskBottomBar
import ru.fincode.tsudesk.R
import ru.fincode.tsudesk.app.presentation.main.selectedTopLevel
import ru.fincode.tsudesk.app.presentation.main.shouldShowBottomBar
import ru.fincode.tsudesk.core.navigation.TopLevelDestination
import ru.fincode.tsudesk.core.navigation.navigateToTopLevel
@Composable
fun MainScaffold(
navController: NavHostController,
content: @Composable (Modifier) -> Unit,
) {
val scheduleIcon = painterResource(R.drawable.ic_progress)
val newsIcon = painterResource(R.drawable.ic_news)
val progressIcon = painterResource(R.drawable.ic_progress)
val settingsIcon = painterResource(R.drawable.ic_progress)
val items = listOf(
TopLevelItem(
destination = TopLevelDestination.SCHEDULE,
labelRes = R.string.tab_schedule,
icon = scheduleIcon
),
TopLevelItem(
destination = TopLevelDestination.NEWS,
labelRes = R.string.tab_news,
icon = newsIcon
),
TopLevelItem(
destination = TopLevelDestination.PROGRESS,
labelRes = R.string.tab_progress,
icon = progressIcon
),
TopLevelItem(
destination = TopLevelDestination.SETTINGS,
labelRes = R.string.tab_settings,
icon = settingsIcon
),
)
val backStackEntry by navController.currentBackStackEntryAsState()
val selected = selectedTopLevel(backStackEntry)
Scaffold(
bottomBar = {
if (shouldShowBottomBar(backStackEntry)) {
val uiItems = items.map { item ->
BottomBarItem(
key = item.destination.name,
label = stringResource(item.labelRes),
icon = item.icon
)
}
TsudeskBottomBar(
items = uiItems,
selectedKey = selected.name,
onItemClick = { clicked ->
if (clicked.key == selected.name) return@TsudeskBottomBar
navController.navigateToTopLevel(
TopLevelDestination.valueOf(clicked.key)
)
}
)
}
}
) { innerPadding ->
content(Modifier.padding(innerPadding))
}
}

View File

@@ -0,0 +1,12 @@
package ru.fincode.tsudesk.presentation.main
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import ru.fincode.tsudesk.core.navigation.TopLevelDestination
data class TopLevelItem(
val destination: TopLevelDestination,
@StringRes val labelRes: Int,
val icon: Painter,
)

View File

@@ -0,0 +1,83 @@
package ru.fincode.tsudesk.presentation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import ru.fincode.tsudesk.presentation.main.MainScaffold
import ru.fincode.tsudesk.core.navigation.AppRoute
import ru.fincode.tsudesk.core.navigation.navigateRoute
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleRoute
import ru.fincode.tsudesk.feature.splash.presentation.screen.SplashRoute
@Composable
fun AppNavHost(
navController: NavHostController,
) {
NavHost(
navController = navController,
startDestination = AppRoute.Splash
) {
composable<AppRoute.Splash> {
SplashRoute(
onFinish = {
navController.navigateRoute(AppRoute.Main) {
popUpTo(AppRoute.Splash) { inclusive = true }
launchSingleTop = true
}
}
)
}
navigation<AppRoute.Main>(startDestination = AppRoute.Schedule) {
composable<AppRoute.Schedule> {
MainScaffold(navController) { innerModifier ->
ScheduleRoute(
modifier = innerModifier,
// onOpenDetails = { lessonId ->
// navController.navigateRoute(AppRoute.ScheduleDetails(lessonId))
// }
)
}
}
composable<AppRoute.News> {
MainScaffold(navController) { innerModifier: Modifier ->
// NewsScreen(
// modifier = innerModifier,
// onOpenDetails = { id ->
// navController.navigateRoute(AppRoute.NewsDetails(id))
// }
// )
}
}
composable<AppRoute.Progress> {
MainScaffold(navController) { innerModifier: Modifier ->
// ProgressScreen(modifier = innerModifier)
}
}
composable<AppRoute.Settings> {
MainScaffold(navController) { innerModifier ->
// SettingsRoute(modifier = innerModifier)
}
}
//
// composable<AppRoute.ScheduleDetails> { entry ->
// val args = entry.route<AppRoute.ScheduleDetails>()
// ScheduleDetailsScreen(lessonId = args.lessonId)
// }
//
// composable<AppRoute.NewsDetails> { entry ->
// val args = entry.route<AppRoute.NewsDetails>()
// NewsDetailsScreen(id = args.id)
// }
}
}
}

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,19L5,19L5,5h14v14z"
android:fillColor="?attr/colorControlNormal"/>
<path
android:pathData="M7,7h10v2H7zM7,11h10v2H7zM7,15h7v2H7z"
android:fillColor="?attr/colorControlNormal"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,3L1,9l11,6 9,-4.91V17h2V9L12,3z"
android:fillColor="?attr/colorControlNormal"/>
<path
android:pathData="M5,12.18V17c0,2.21 3.58,4 7,4s7,-1.79 7,-4v-4.82l-7,3.82 -7,-3.82z"
android:fillColor="?attr/colorControlNormal"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,4h-1L18,2h-2v2L8,4L8,2L6,2v2L5,4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.9,-2 -2,-2zM19,20L5,20L5,10h14v10zM19,8L5,8L5,6h14v2z"
android:fillColor="?attr/colorControlNormal"/>
</vector>

View File

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

View File

@@ -1,6 +0,0 @@
<?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: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <style name="Theme.TSUDesk" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources> </resources>

View File

@@ -1,3 +1,7 @@
<resources> <resources>
<string name="app_name">TSUDesk</string> <string name="app_name">TSUDesk</string>
<string name="tab_schedule">Расписание</string>
<string name="tab_news">Новости</string>
<string name="tab_progress">Успеваемость</string>
<string name="tab_settings">Настройки</string>
</resources> </resources>

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <style name="Theme.TSUDesk" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources> </resources>

View File

@@ -1,3 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
plugins { plugins {
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.kapt) apply false
@@ -6,3 +9,21 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
} }
subprojects {
plugins.withId("com.android.application") {
extensions.configure<ApplicationExtension> {
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
}
}
}
plugins.withId("com.android.library") {
extensions.configure<LibraryExtension> {
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
}
}
}
}

View File

@@ -21,15 +21,18 @@ android {
) )
} }
} }
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = jvm
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = jvm
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = jvm.toString()
}
buildFeatures {
buildConfig = true
} }
} }
dependencies { dependencies {
implementation(libs.core.ktx)
} }

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.common.app
data class AppConfig(
val isDebug: Boolean,
val baseUrl: String,
val networkTimeoutSec: Long
)

View File

@@ -0,0 +1,5 @@
package ru.fincode.tsudesk.core.common.log
object Constants {
const val TAG = "DEBUG"
}

View File

@@ -0,0 +1,23 @@
package ru.fincode.tsudesk.core.common.log
import android.util.Log
private fun Any.moduleTag(): String {
val pkg = this::class.java.`package`?.name.orEmpty()
val parts = pkg.split('.')
val module =
if (parts.size >= 5 && parts[0] == "ru" && parts[1] == "fincode" && parts[2] == "tsudesk") {
when (parts[3]) {
"core", "feature" -> parts[4]
else -> parts[3] // fallback
}
} else {
parts.lastOrNull() ?: "unknown"
}
return "${Constants.TAG}:${module.uppercase()}"
}
fun Any.logD(message: String) = Log.d(moduleTag(), message)
fun Any.logD(message: String, throwable: Throwable) = Log.d(moduleTag(), message, throwable)
fun Any.logE(message: String) = Log.e(moduleTag(), message)
fun Any.logE(message: String, throwable: Throwable) = Log.e(moduleTag(), message, throwable)

View File

@@ -0,0 +1,12 @@
package ru.fincode.tsudesk.core.common.model
sealed interface AppError {
data object NoInternet : AppError
data object Timeout : AppError
/** Временный сбой соединения (EOF / unexpected end of stream / reset) */
data object Temporary : AppError
data class Http(val code: Int) : AppError
data class Unknown(val message: String? = null) : AppError
}

View File

@@ -0,0 +1,17 @@
package ru.fincode.tsudesk.core.common.model
sealed interface DataResult<out T> {
data class Data<T>(
val data: T,
val refreshedFromNetwork: Boolean
) : DataResult<T>
data class Error<T>(
val error: AppError,
val data: T? = null,
val cause: Throwable? = null
) : DataResult<T>
data object Loading : DataResult<Nothing>
}

View File

@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "ru.fincode.tsudesk.core.config"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
buildFeatures {
buildConfig = true
}
}
kapt {
correctErrorTypes = true
}
dependencies {
kapt(libs.hilt.compiler)
implementation(libs.hilt.android)
implementation(libs.kotlinx.serialization.json)
implementation(projects.core.network)
implementation(projects.core.common)
}

View File

@@ -0,0 +1,63 @@
package ru.fincode.tsudesk.core.config.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSource
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
import ru.fincode.tsudesk.core.network.model.NetworkResult
import javax.inject.Inject
class ConfigRepositoryImpl @Inject constructor(
private val remoteDataSource: ConfigRemoteDataSource
) : ConfigRepository {
private val defaultConfig = AppConfig()
override suspend fun fetchConfig(): DataResult<AppConfig> {
return when (val res = remoteDataSource.load()) {
is NetworkResult.Success -> {
val dto = res.data
DataResult.Data(
data = AppConfig(
newsEnabled = dto.newsEnabled ?: defaultConfig.newsEnabled,
scheduleEnabled = dto.scheduleEnabled ?: defaultConfig.scheduleEnabled,
gradesEnabled = dto.gradesEnabled ?: defaultConfig.gradesEnabled
),
refreshedFromNetwork = true
)
}
is NetworkResult.Error -> {
DataResult.Error(
error = res.error.toAppError(), data = defaultConfig
)
}
}
}
override fun getConfig(): Flow<DataResult<AppConfig>> = flow {
when (val result = remoteDataSource.load()) {
is NetworkResult.Success -> {
val dto = result.data
val appConfig = AppConfig(
newsEnabled = dto.newsEnabled ?: defaultConfig.newsEnabled,
scheduleEnabled = dto.scheduleEnabled ?: defaultConfig.scheduleEnabled,
gradesEnabled = dto.gradesEnabled ?: defaultConfig.gradesEnabled
)
emit(DataResult.Data(data = appConfig, refreshedFromNetwork = true))
}
is NetworkResult.Error -> {
emit(
DataResult.Error(
error = result.error.toAppError(),
data = defaultConfig
)
)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.fincode.tsudesk.core.config.data.datasource
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
import ru.fincode.tsudesk.core.network.model.NetworkResult
interface ConfigRemoteDataSource {
suspend fun load(): NetworkResult<RemoteConfigDto>
}

View File

@@ -0,0 +1,15 @@
package ru.fincode.tsudesk.core.config.data.datasource
import ru.fincode.tsudesk.core.config.data.remote.api.ConfigApi
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
import ru.fincode.tsudesk.core.network.apiCall
import ru.fincode.tsudesk.core.network.model.NetworkResult
import javax.inject.Inject
class ConfigRemoteDataSourceImpl @Inject constructor(
private val api: ConfigApi
) : ConfigRemoteDataSource {
override suspend fun load(): NetworkResult<RemoteConfigDto> =
apiCall { api.getConfig() }
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.data.model
data class RemoteConfig(
val newsEnabled: Boolean,
val scheduleEnabled: Boolean,
val gradesEnabled: Boolean
)

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.data.remote
object ConfigApiContract {
const val CONFIG_BASE_URL = "https://scherbatykh.ru/app/tsudesk/"
object Path {
const val GET_CONFIG_METHOD = "config.json"
}
}

View File

@@ -0,0 +1,12 @@
package ru.fincode.tsudesk.core.config.data.remote.api
import retrofit2.Response
import retrofit2.http.GET
import ru.fincode.tsudesk.core.config.data.remote.ConfigApiContract.Path.GET_CONFIG_METHOD
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
interface ConfigApi {
@GET(GET_CONFIG_METHOD)
suspend fun getConfig(): Response<RemoteConfigDto>
}

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.data.remote.dto
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RemoteConfigDto(
val newsEnabled: Boolean? = null,
val scheduleEnabled: Boolean? = null,
val gradesEnabled: Boolean? = null
)

View File

@@ -0,0 +1,35 @@
package ru.fincode.tsudesk.core.config.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import ru.fincode.tsudesk.core.config.data.remote.ConfigApiContract.CONFIG_BASE_URL
import ru.fincode.tsudesk.core.network.RetrofitFactory
import ru.fincode.tsudesk.core.config.data.remote.api.ConfigApi
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ConfigNetworkModule {
@Provides
@Singleton
@ConfigRetrofit
fun provideConfigRetrofit(
factory: RetrofitFactory,
client: OkHttpClient
): Retrofit =
factory.create(
baseUrl = CONFIG_BASE_URL,
client = client
)
@Provides
@Singleton
fun provideConfigApi(
@ConfigRetrofit retrofit: Retrofit
): ConfigApi =
retrofit.create(ConfigApi::class.java)
}

View File

@@ -0,0 +1,28 @@
package ru.fincode.tsudesk.core.config.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.fincode.tsudesk.core.config.data.ConfigRepositoryImpl
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSource
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSourceImpl
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ConfigRepositoryModule {
@Binds
@Singleton
abstract fun bindRemoteDataSource(
impl: ConfigRemoteDataSourceImpl
): ConfigRemoteDataSource
@Binds
@Singleton
abstract fun bindRepository(
impl: ConfigRepositoryImpl
): ConfigRepository
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ConfigRetrofit

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.domain
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
interface ConfigRepository {
suspend fun fetchConfig(): DataResult<AppConfig>
fun getConfig(): Flow<DataResult<AppConfig>>
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.domain.model
data class AppConfig(
val newsEnabled: Boolean = false,
val scheduleEnabled: Boolean = false,
val gradesEnabled: Boolean = false
)

View File

@@ -0,0 +1,13 @@
package ru.fincode.tsudesk.core.config.domain.usecase
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
import javax.inject.Inject
class GetConfigUseCase @Inject constructor(
private val repository: ConfigRepository
) {
suspend operator fun invoke(): DataResult<AppConfig> = repository.fetchConfig()
}

View File

@@ -0,0 +1,28 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "ru.fincode.tsudesk.core.database.api"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
}
dependencies {
api(libs.room.runtime)
api(libs.kotlinx.coroutines.core)
}

View File

@@ -0,0 +1,13 @@
package ru.fincode.tsudesk.core.database.api.schedule
object Constants {
const val SCHEDULE_TABLE = "schedule_cache"
const val LESSON_TABLE = "lesson_cache"
const val COL_SCHEDULE_KEY = "scheduleKey"
const val COL_KEY = "key"
const val GROUP_PRE_KEY = "group:"
const val TEACHER_PRE_KEY = "teacher:"
}

View File

@@ -0,0 +1,25 @@
package ru.fincode.tsudesk.core.database.api.schedule
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import ru.fincode.tsudesk.core.database.api.schedule.Constants.COL_SCHEDULE_KEY
import ru.fincode.tsudesk.core.database.api.schedule.Constants.LESSON_TABLE
@Entity(
tableName = LESSON_TABLE,
indices = [Index(value = [COL_SCHEDULE_KEY])]
)
data class LessonCacheEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleKey: String,
val date: String,
val time: String,
val name: String,
val typeName: String,
val room: String,
val teacher: String,
val groupIds: List<String>,
val type: String
)

View File

@@ -0,0 +1,21 @@
package ru.fincode.tsudesk.core.database.api.schedule
import ru.fincode.tsudesk.core.database.api.schedule.Constants.COL_KEY
import ru.fincode.tsudesk.core.database.api.schedule.Constants.COL_SCHEDULE_KEY
import ru.fincode.tsudesk.core.database.api.schedule.Constants.LESSON_TABLE
import ru.fincode.tsudesk.core.database.api.schedule.Constants.SCHEDULE_TABLE
object Query {
const val SELECT_LESSON_BY_KEY_QUERY =
"SELECT * FROM ${LESSON_TABLE} WHERE ${COL_SCHEDULE_KEY} = :key"
const val SELECT_SCHEDULE_BY_KEY_QUERY =
"SELECT * FROM ${SCHEDULE_TABLE} WHERE `${COL_KEY}` = :key LIMIT 1"
const val DELETE_LESSON_BY_KEY_QUERY =
"DELETE FROM ${LESSON_TABLE} WHERE ${COL_SCHEDULE_KEY} = :key"
const val DELETE_SCHEDULE_BY_KEY_QUERY =
"DELETE FROM ${SCHEDULE_TABLE} WHERE `${COL_KEY}` = :key"
}

View File

@@ -0,0 +1,12 @@
package ru.fincode.tsudesk.core.database.api.schedule
import androidx.room.Entity
import androidx.room.PrimaryKey
import ru.fincode.tsudesk.core.database.api.schedule.Constants.SCHEDULE_TABLE
@Entity(tableName = SCHEDULE_TABLE)
data class ScheduleCacheEntity(
@PrimaryKey
val key: String, // "group:220631" | "teacher:ФИО"
val timestamp: Long
)

View File

@@ -0,0 +1,57 @@
package ru.fincode.tsudesk.core.database.api.schedule
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.database.api.schedule.Query.DELETE_LESSON_BY_KEY_QUERY
import ru.fincode.tsudesk.core.database.api.schedule.Query.DELETE_SCHEDULE_BY_KEY_QUERY
import ru.fincode.tsudesk.core.database.api.schedule.Query.SELECT_LESSON_BY_KEY_QUERY
import ru.fincode.tsudesk.core.database.api.schedule.Query.SELECT_SCHEDULE_BY_KEY_QUERY
@Dao
interface ScheduleDao {
@Query(SELECT_SCHEDULE_BY_KEY_QUERY)
fun observeSchedule(key: String): Flow<ScheduleCacheEntity?>
@Query(SELECT_LESSON_BY_KEY_QUERY)
fun observeLessons(key: String): Flow<List<LessonCacheEntity>>
@Query(SELECT_SCHEDULE_BY_KEY_QUERY)
suspend fun getSchedule(key: String): ScheduleCacheEntity?
@Query(SELECT_LESSON_BY_KEY_QUERY)
suspend fun getLessons(key: String): List<LessonCacheEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSchedule(schedule: ScheduleCacheEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonCacheEntity>)
@Query(DELETE_LESSON_BY_KEY_QUERY)
suspend fun deleteLessonsByKey(key: String)
@Query(DELETE_SCHEDULE_BY_KEY_QUERY)
suspend fun deleteScheduleByKey(key: String)
@Transaction
suspend fun replaceSchedule(
key: String,
schedule: ScheduleCacheEntity,
lessons: List<LessonCacheEntity>
) {
deleteLessonsByKey(key)
upsertSchedule(schedule)
insertLessons(lessons)
}
@Transaction
suspend fun clearSchedule(key: String) {
deleteLessonsByKey(key)
deleteScheduleByKey(key)
}
}

View File

@@ -1,35 +0,0 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "ru.fincode.tsudesk.core.database"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = libs.versions.jvmTarget.get()
}
}
dependencies {
implementation(libs.core.ktx)
}

View File

@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
}
android {
namespace = "ru.fincode.tsudesk.core.database.impl"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
buildFeatures {
buildConfig = true
}
}
kapt {
correctErrorTypes = true
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(projects.core.database.api)
}

View File

21
core/database/impl/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,124 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "3d269ca42c7bee3550ec1498f99f51f1",
"entities": [
{
"tableName": "schedule_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "lesson_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleKey` TEXT NOT NULL, `date` TEXT NOT NULL, `time` TEXT NOT NULL, `name` TEXT NOT NULL, `typeName` TEXT NOT NULL, `room` TEXT NOT NULL, `teacher` TEXT NOT NULL, `groupIds` TEXT NOT NULL, `type` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleKey",
"columnName": "scheduleKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "typeName",
"columnName": "typeName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "groupIds",
"columnName": "groupIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lesson_cache_scheduleKey",
"unique": false,
"columnNames": [
"scheduleKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lesson_cache_scheduleKey` ON `${TABLE_NAME}` (`scheduleKey`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d269ca42c7bee3550ec1498f99f51f1')"
]
}
}

View File

@@ -0,0 +1,22 @@
package ru.fincode.core.database.impl
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import ru.fincode.tsudesk.core.database.api.schedule.LessonCacheEntity
import ru.fincode.tsudesk.core.database.api.schedule.ScheduleCacheEntity
import ru.fincode.tsudesk.core.database.api.schedule.ScheduleDao
@Database(
entities = [
ScheduleCacheEntity::class,
LessonCacheEntity::class
],
version = 2,
exportSchema = true
)
@TypeConverters(StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun scheduleCacheDao(): ScheduleDao
}

View File

@@ -0,0 +1,21 @@
package ru.fincode.core.database.impl
import androidx.room.TypeConverter
class StringListConverter {
private companion object {
const val SEPARATOR = "||"
}
@TypeConverter
fun fromList(list: List<String>?): String =
list?.joinToString(SEPARATOR).orEmpty()
@TypeConverter
fun toList(value: String?): List<String> =
value
?.takeIf { it.isNotBlank() }
?.split(SEPARATOR)
?: emptyList()
}

View File

@@ -0,0 +1,15 @@
package ru.fincode.core.database.impl.builder
import android.content.Context
import androidx.room.Room
import ru.fincode.core.database.impl.AppDatabase
object AppDatabaseBuilder {
private const val DB_NAME = "tsudesk.db"
fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DB_NAME)
.fallbackToDestructiveMigration()
.build()
}

View File

@@ -0,0 +1,26 @@
package ru.fincode.core.database.impl.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.fincode.core.database.impl.AppDatabase
import ru.fincode.core.database.impl.builder.AppDatabaseBuilder
import ru.fincode.tsudesk.core.database.api.schedule.ScheduleDao
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase =
AppDatabaseBuilder.build(context)
@Provides
fun provideScheduleDao(db: AppDatabase): ScheduleDao =
db.scheduleCacheDao()
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "ru.fincode.tsudesk.core.navigation"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
buildFeatures {
buildConfig = true
compose = true
}
}
dependencies {
implementation(libs.kotlinx.serialization.json)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.runtime)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.common)
implementation(projects.core.ui)
}

View File

21
core/navigation/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,40 @@
package ru.fincode.tsudesk.core.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed interface AppRoute {
@Serializable
data object Splash : AppRoute
/**
* Root-граф приложения после сплеша.
* Внутри него лежат табы: Schedule/News/Progress.
*/
@Serializable
data object Main : AppRoute
// Tabs
@Serializable
data object Schedule : AppRoute
@Serializable
data object News : AppRoute
@Serializable
data object Progress : AppRoute
@kotlinx.serialization.Serializable
data object Settings : AppRoute
@Serializable
data class ScheduleDetails(
val lessonId: Long
) : AppRoute
@Serializable
data class NewsDetails(
val id: String
) : AppRoute
}

View File

@@ -0,0 +1,6 @@
package ru.fincode.tsudesk.core.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.toRoute
inline fun <reified T : AppRoute> NavBackStackEntry.route(): T = toRoute()

View File

@@ -0,0 +1,28 @@
package ru.fincode.tsudesk.core.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavOptionsBuilder
fun NavController.navigateToTopLevel(destination: TopLevelDestination) {
val route = destination.toRoute()
navigate(route) {
popUpTo(graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
fun NavController.navigateRoute(
route: AppRoute,
builder: (NavOptionsBuilder.() -> Unit)? = null
) {
if (builder == null) {
navigate(route)
} else {
navigate(route, builder)
}
}

View File

@@ -0,0 +1,22 @@
package ru.fincode.tsudesk.core.navigation
import kotlinx.serialization.Serializable
@Serializable
enum class TopLevelDestination {
SCHEDULE, NEWS, PROGRESS, SETTINGS
}
fun TopLevelDestination.toRoute(): AppRoute = when (this) {
TopLevelDestination.SCHEDULE -> AppRoute.Schedule
TopLevelDestination.NEWS -> AppRoute.News
TopLevelDestination.PROGRESS -> AppRoute.Progress
TopLevelDestination.SETTINGS -> AppRoute.Settings
}
val TOP_LEVEL_DESTINATIONS: List<TopLevelDestination> = listOf(
TopLevelDestination.SCHEDULE,
TopLevelDestination.NEWS,
TopLevelDestination.PROGRESS,
TopLevelDestination.SETTINGS
)

View File

@@ -13,7 +13,6 @@ android {
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
@@ -31,16 +30,26 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = jvm.toString() jvmTarget = jvm.toString()
} }
buildFeatures {
buildConfig = true
}
}
kapt {
correctErrorTypes = true
} }
dependencies { dependencies {
implementation(libs.core.ktx) kapt(libs.hilt.compiler)
implementation(libs.hilt.android)
kapt(libs.hiltcompiler)
implementation(libs.hiltandroid)
api(libs.retrofit) api(libs.retrofit)
api(libs.okhttp) api(libs.okhttp)
implementation(libs.retrofit.simplexml) implementation(libs.okhttp.logging)
implementation(libs.retrofit.gson)
api(libs.moshi)
api(libs.moshi.kotlin)
api(libs.retrofit.moshi)
implementation(projects.core.common)
} }

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -1,10 +0,0 @@
package ru.fincode.tsudesk.core.network
object HttpClientProvider {
fun provide(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}

View File

@@ -0,0 +1,78 @@
package ru.fincode.tsudesk.core.network
import retrofit2.HttpException
import retrofit2.Response
import ru.fincode.tsudesk.core.network.model.NetworkError
import ru.fincode.tsudesk.core.network.model.NetworkMeta
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
import java.io.EOFException
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
suspend inline fun <T> apiCall(
crossinline block: suspend () -> Response<T>
): NetworkResult<T> {
val startedAt = System.currentTimeMillis()
return try {
val response = block()
val finishedAt = System.currentTimeMillis()
val raw = response.raw()
val meta = NetworkMeta(
startedAtMillis = startedAt,
finishedAtMillis = finishedAt,
sentAtMillis = raw.sentRequestAtMillis,
receivedAtMillis = raw.receivedResponseAtMillis
)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Success(data = body, meta = meta)
} else {
NetworkResult.Error(
NetworkError.Unknown(
throwable = NullPointerException("Response body is null"),
meta = meta
)
)
}
} else {
NetworkResult.Error(NetworkError.Http(code = response.code(), meta = meta))
}
} catch (t: Throwable) {
val meta = NetworkMeta(
startedAtMillis = startedAt,
finishedAtMillis = System.currentTimeMillis()
)
NetworkResult.Error(t.toNetworkError(meta))
}
}
fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) {
is UnknownHostException,
is ConnectException,
is NoRouteToHostException -> NetworkError.NoInternet(meta)
is SocketTimeoutException,
is InterruptedIOException -> NetworkError.Timeout(meta)
is EOFException -> NetworkError.Temporary(meta)
is HttpException -> NetworkError.Http(code(), meta)
is IOException -> {
val msg = message?.lowercase().orEmpty()
if ("unexpected end of stream" in msg || "eof" in msg) NetworkError.Temporary(meta)
else NetworkError.Temporary(meta) // любые IO — временные, не "NoInternet"
}
else -> NetworkError.Unknown(this, meta)
}

Some files were not shown because too many files have changed in this diff Show More