├── .gitattributes ├── .idea ├── icon.png └── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── static ├── html.png ├── json.png ├── promo_pic.png ├── disable_js.png ├── choose_project_view.png └── integration.md ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── appmodules ├── app │ └── chronusparsers │ │ ├── ios │ │ ├── app-ios-compose │ │ │ ├── Assets.xcassets │ │ │ │ ├── Contents.json │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ └── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ ├── Preview Content │ │ │ │ └── Preview Assets.xcassets │ │ │ │ │ └── Contents.json │ │ │ ├── RootView.swift │ │ │ ├── Info.plist │ │ │ └── iOSApp.swift │ │ ├── chronus parsers.xcodeproj │ │ │ ├── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcshareddata │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ ├── xcshareddata │ │ │ │ └── xcschemes │ │ │ │ │ └── app-ios-compose.xcscheme │ │ │ └── project.pbxproj │ │ └── .gitignore │ │ ├── android │ │ ├── src │ │ │ └── main │ │ │ │ ├── res │ │ │ │ └── xml │ │ │ │ │ └── permitted_http_connections.xml │ │ │ │ ├── kotlin │ │ │ │ └── app │ │ │ │ │ └── chronusparsers │ │ │ │ │ └── android │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ └── build.gradle.kts │ │ └── desktop │ │ ├── src │ │ └── jvmMain │ │ │ └── kotlin │ │ │ └── app │ │ │ └── chronusparsers │ │ │ └── desktop │ │ │ ├── Utils.kt │ │ │ └── Main.kt │ │ └── build.gradle.kts ├── model │ ├── chronus │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── model │ │ │ │ └── chronus │ │ │ │ ├── ProgressStatus.kt │ │ │ │ ├── ScheduleType.kt │ │ │ │ ├── SearchType.kt │ │ │ │ ├── City.kt │ │ │ │ ├── EntryInfo.kt │ │ │ │ ├── Schedule.kt │ │ │ │ ├── ContributorAbout.kt │ │ │ │ ├── Place.kt │ │ │ │ ├── Lesson.kt │ │ │ │ ├── LessonType.kt │ │ │ │ └── Contributor.kt │ │ └── build.gradle.kts │ └── common │ │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── model │ │ │ └── common │ │ │ ├── Int.kt │ │ │ ├── List.kt │ │ │ ├── LocalDate.kt │ │ │ └── String.kt │ │ └── build.gradle.kts ├── library │ ├── logger │ │ ├── src │ │ │ ├── jvmMain │ │ │ │ └── kotlin │ │ │ │ │ └── library │ │ │ │ │ └── logger │ │ │ │ │ └── Logger.desktop.kt │ │ │ ├── androidMain │ │ │ │ └── kotlin │ │ │ │ │ └── library │ │ │ │ │ └── logger │ │ │ │ │ └── Logger.android.kt │ │ │ ├── iosMain │ │ │ │ └── kotlin │ │ │ │ │ └── library │ │ │ │ │ └── logger │ │ │ │ │ └── Logger.native.kt │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── library │ │ │ │ └── logger │ │ │ │ └── Logger.kt │ │ └── build.gradle.kts │ ├── ui │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── library │ │ │ │ └── ui │ │ │ │ ├── InnerCardPadding.kt │ │ │ │ ├── HorizontalSpacer.kt │ │ │ │ ├── ScreenElementPadding.kt │ │ │ │ └── Screen.kt │ │ └── build.gradle.kts │ └── navigation │ │ ├── build.gradle.kts │ │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── library │ │ └── navigation │ │ └── Retainable.kt ├── datasource │ └── network │ │ └── chronus │ │ ├── main │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── datasource │ │ │ │ └── network │ │ │ │ └── chronus │ │ │ │ └── main │ │ │ │ ├── DefaultJson.kt │ │ │ │ ├── Ext.kt │ │ │ │ ├── DefaultHttpClient.kt │ │ │ │ ├── NetworkCache.kt │ │ │ │ └── ChronusNetwork.kt │ │ └── build.gradle.kts │ │ ├── yourcity-youruniversity │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── datasource │ │ │ │ └── network │ │ │ │ └── chronus │ │ │ │ └── yourcity │ │ │ │ └── youruniversity │ │ │ │ ├── YouruniversityGetSearchResults.kt │ │ │ │ └── YouruniversityGetLessons.kt │ │ └── build.gradle.kts │ │ ├── irkutsk-irnitu │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── datasource │ │ │ └── network │ │ │ └── chronus │ │ │ └── irkutsk │ │ │ └── irnitu │ │ │ ├── IrnituGetSearchResults.kt │ │ │ └── IrnituGetLessons.kt │ │ └── irkutsk-igu-imit │ │ ├── build.gradle.kts │ │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── datasource │ │ └── network │ │ └── chronus │ │ └── irkutsk │ │ └── iguimit │ │ ├── LessonDto.kt │ │ ├── IguimitGetSearchResults.kt │ │ └── IguimitGetLessons.kt ├── feature │ └── chronus │ │ ├── places │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── feature │ │ │ │ └── chronus │ │ │ │ └── places │ │ │ │ ├── PlacesComponent.kt │ │ │ │ └── PlacesContent.kt │ │ └── build.gradle.kts │ │ ├── contributor │ │ ├── src │ │ │ └── commonMain │ │ │ │ └── kotlin │ │ │ │ └── feature │ │ │ │ └── chronus │ │ │ │ └── contributor │ │ │ │ ├── ContributorComponent.kt │ │ │ │ └── ContributorContent.kt │ │ └── build.gradle.kts │ │ ├── search │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── feature │ │ │ └── chronus │ │ │ └── search │ │ │ ├── SearchComponent.kt │ │ │ └── SearchContent.kt │ │ └── schedule │ │ ├── build.gradle.kts │ │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── feature │ │ └── chronus │ │ └── schedule │ │ ├── ScheduleComponent.kt │ │ └── ScheduleContent.kt └── shared │ └── chronusparsers │ ├── src │ ├── iosMain │ │ └── kotlin │ │ │ └── shared │ │ │ └── chronusparsers │ │ │ └── RootViewController.kt │ └── commonMain │ │ └── kotlin │ │ └── shared │ │ └── chronusparsers │ │ ├── Route.kt │ │ ├── Child.kt │ │ ├── RootContent.kt │ │ └── RootComponent.kt │ └── build.gradle.kts ├── gradle.properties ├── .gitignore ├── .run ├── Run on iOS.run.xml └── app-desktop.run.xml ├── .github └── workflows │ └── build.yml ├── LICENSE ├── .editorconfig ├── settings.gradle.kts ├── gradlew.bat ├── README.md └── gradlew /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/.idea/icon.png -------------------------------------------------------------------------------- /static/html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/static/html.png -------------------------------------------------------------------------------- /static/json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/static/json.png -------------------------------------------------------------------------------- /static/promo_pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/static/promo_pic.png -------------------------------------------------------------------------------- /static/disable_js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/static/disable_js.png -------------------------------------------------------------------------------- /static/choose_project_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/static/choose_project_view.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxkmn/ChronusParserTemplate/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/ProgressStatus.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | enum class ProgressStatus { NOT_USED, LOADING, ERROR, SUCCESS } 4 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/ScheduleType.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | enum class ScheduleType { 4 | GROUP, 5 | PERSON, 6 | CLASSROOM, 7 | OTHER, 8 | } 9 | -------------------------------------------------------------------------------- /appmodules/model/common/src/commonMain/kotlin/model/common/Int.kt: -------------------------------------------------------------------------------- 1 | package model.common 2 | 3 | fun Int.isEven(): Boolean = 4 | this % 2 == 0 5 | 6 | fun Int.isOdd(): Boolean = 7 | this % 2 == 1 8 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/SearchType.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | enum class SearchType { 4 | BY_GROUP_PERSON_PLACE, 5 | BY_GROUP_PERSON, 6 | BY_GROUP_PLACE, 7 | BY_GROUP, 8 | } 9 | -------------------------------------------------------------------------------- /appmodules/library/logger/src/jvmMain/kotlin/library/logger/Logger.desktop.kt: -------------------------------------------------------------------------------- 1 | package library.logger 2 | 3 | import com.juul.khronicle.ConsoleLogger 4 | import com.juul.khronicle.Log 5 | 6 | actual fun installLogger() = Log.dispatcher.install(ConsoleLogger) 7 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/chronus parsers.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /appmodules/library/logger/src/androidMain/kotlin/library/logger/Logger.android.kt: -------------------------------------------------------------------------------- 1 | package library.logger 2 | 3 | import com.juul.khronicle.ConsoleLogger 4 | import com.juul.khronicle.Log 5 | 6 | actual fun installLogger() = Log.dispatcher.install(ConsoleLogger) 7 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/City.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | enum class City(val cyrillicName: String, val timeZoneId: String) { 4 | IRKUTSK("Иркутск", "Asia/Irkutsk"), 5 | YOUR_CITY("Ваш город", "Europe/Moscow"), 6 | } 7 | -------------------------------------------------------------------------------- /appmodules/library/logger/src/iosMain/kotlin/library/logger/Logger.native.kt: -------------------------------------------------------------------------------- 1 | package library.logger 2 | 3 | import com.juul.khronicle.AppleSystemLogger 4 | import com.juul.khronicle.Log 5 | 6 | actual fun installLogger() = Log.dispatcher.install(AppleSystemLogger) 7 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/src/commonMain/kotlin/datasource/network/chronus/main/DefaultJson.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.main 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | internal fun defaultJson() = Json { ignoreUnknownKeys = true } 6 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/EntryInfo.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | data class EntryInfo( 4 | val name: String, 5 | val url: String?, // если ссылка на группу/преподавателя/аудиторию в виде url не предоставляется, нужно оставить null 6 | ) 7 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/Schedule.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Schedule( 7 | val name: String, 8 | val type: ScheduleType, 9 | val place: Place, 10 | val url: String, 11 | ) 12 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/android/src/main/res/xml/permitted_http_connections.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | exam.ple.com 5 | 6 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/.gitignore: -------------------------------------------------------------------------------- 1 | DerivedData/ 2 | *.pbxuser 3 | !default.pbxuser 4 | *.mode1v3 5 | !default.mode1v3 6 | *.mode2v3 7 | !default.mode2v3 8 | *.perspectivev3 9 | !default.perspectivev3 10 | xcuserdata/ 11 | *.moved-aside 12 | *.xccheckout 13 | *.xcscmblueprint 14 | *.hmap 15 | *.ipa 16 | *.dSYM.zip 17 | *.dSYM 18 | Pods 19 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/chronus parsers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /appmodules/library/ui/src/commonMain/kotlin/library/ui/InnerCardPadding.kt: -------------------------------------------------------------------------------- 1 | package library.ui 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.unit.Dp 6 | import androidx.compose.ui.unit.dp 7 | 8 | fun Modifier.innerCardPadding( 9 | horizontal: Dp = 16.dp, 10 | vertical: Dp = 8.dp, 11 | ) = this.padding(horizontal = horizontal, vertical = vertical) 12 | -------------------------------------------------------------------------------- /appmodules/model/common/src/commonMain/kotlin/model/common/List.kt: -------------------------------------------------------------------------------- 1 | package model.common 2 | 3 | import kotlinx.coroutines.async 4 | import kotlinx.coroutines.awaitAll 5 | import kotlinx.coroutines.coroutineScope 6 | 7 | suspend fun Iterable.asyncMap( 8 | transform: suspend (T) -> R, 9 | ): List = coroutineScope { 10 | this@asyncMap.map { item -> 11 | async { 12 | transform(item) 13 | } 14 | }.awaitAll() 15 | } 16 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/places/src/commonMain/kotlin/feature/chronus/places/PlacesComponent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.places 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import model.chronus.Contributor 5 | import model.chronus.Place 6 | 7 | class PlacesComponent( 8 | componentContext: ComponentContext, 9 | val toSearch: (Place) -> Unit, 10 | val toContributor: (Contributor) -> Unit, 11 | ) : ComponentContext by componentContext 12 | -------------------------------------------------------------------------------- /appmodules/library/ui/src/commonMain/kotlin/library/ui/HorizontalSpacer.kt: -------------------------------------------------------------------------------- 1 | package library.ui 2 | 3 | import androidx.compose.foundation.layout.Spacer 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.Dp 8 | 9 | @Composable 10 | fun HorizontalSpacer(height: Dp, modifier: Modifier = Modifier) { 11 | Spacer(modifier.height(height)) 12 | } 13 | -------------------------------------------------------------------------------- /appmodules/library/ui/src/commonMain/kotlin/library/ui/ScreenElementPadding.kt: -------------------------------------------------------------------------------- 1 | package library.ui 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.foundation.layout.width 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.Dp 7 | import androidx.compose.ui.unit.dp 8 | 9 | fun Modifier.screenElementPadding( 10 | horizontal: Dp = 16.dp, 11 | vertical: Dp = 8.dp, 12 | ) = this.width(640.dp).padding(horizontal = horizontal, vertical = vertical) 13 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | 4 | #Kotlin 5 | kotlin.code.style=official 6 | 7 | #Android 8 | android.useAndroidX=true 9 | android.nonTransitiveRClass=true 10 | 11 | #MPP 12 | kotlin.mpp.enableCInteropCommonization=true 13 | kotlin.mpp.androidSourceSetLayoutVersion=2 14 | # hide `w: The following Kotlin/Native targets cannot be built on this machine and are disabled` 15 | kotlin.native.ignoreDisabledTargets=true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | **/build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | .DS_Store 10 | /node_modules 11 | /npm-debug.log* 12 | __pycache__ 13 | venv 14 | /MANIFEST 15 | /manifest.json 16 | /site 17 | /dist 18 | /mkdocs_material.egg-info 19 | .vscode 20 | .kotlin 21 | 22 | ktlint 23 | .idea/* 24 | !.idea/copyright 25 | !.idea/icon.png 26 | !.idea/codeInsightSettings.xml 27 | !.idea/codeStyles/ 28 | !.idea/dictionaries/ 29 | !.idea/runConfigurations/ 30 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/src/iosMain/kotlin/shared/chronusparsers/RootViewController.kt: -------------------------------------------------------------------------------- 1 | package shared.chronusparsers 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.window.ComposeUIViewController 6 | import platform.UIKit.UIViewController 7 | 8 | fun rootViewController(root: RootComponent): UIViewController = 9 | ComposeUIViewController { 10 | RootContent(component = root, modifier = Modifier.fillMaxSize()) 11 | } 12 | -------------------------------------------------------------------------------- /.run/Run on iOS.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import shared 3 | 4 | struct RootView: UIViewControllerRepresentable { 5 | let root: RootComponent 6 | 7 | func makeUIViewController(context: Context) -> UIViewController { 8 | let controller = RootViewControllerKt.rootViewController(root: root) 9 | controller.overrideUserInterfaceStyle = .light 10 | return controller 11 | } 12 | 13 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) { } 14 | } 15 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | NSAppTransportSecurity 8 | 9 | NSAllowsArbitraryLoads 10 | 11 | NSAllowsArbitraryLoadsForMedia 12 | 13 | NSAllowsArbitraryLoadsInWebContent 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/desktop/src/jvmMain/kotlin/app/chronusparsers/desktop/Utils.kt: -------------------------------------------------------------------------------- 1 | package app.chronusparsers.desktop 2 | 3 | import javax.swing.SwingUtilities 4 | 5 | internal fun runOnUiThread(block: () -> T): T { 6 | if (SwingUtilities.isEventDispatchThread()) { 7 | return block() 8 | } 9 | 10 | var error: Throwable? = null 11 | var result: T? = null 12 | 13 | SwingUtilities.invokeAndWait { 14 | try { 15 | result = block() 16 | } catch (e: Throwable) { 17 | error = e 18 | } 19 | } 20 | 21 | error?.also { throw it } 22 | 23 | @Suppress("UNCHECKED_CAST") 24 | return result as T 25 | } 26 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/ContributorAbout.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | data class ContributorAbout( 4 | val photoUrl: String, 5 | val en: ContributorAboutNameAndCards?, 6 | val ru: ContributorAboutNameAndCards?, 7 | ) 8 | 9 | data class ContributorAboutNameAndCards( 10 | val name: String?, 11 | val cards: List, 12 | ) 13 | 14 | data class ContributorAboutCard( 15 | val title: String, 16 | val text: String, 17 | val buttons: List, 18 | ) 19 | 20 | data class ContributorAboutButton( 21 | val text: String, 22 | val url: String, 23 | ) 24 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/src/commonMain/kotlin/shared/chronusparsers/Route.kt: -------------------------------------------------------------------------------- 1 | package shared.chronusparsers 2 | 3 | import kotlinx.serialization.Serializable 4 | import model.chronus.Contributor 5 | import model.chronus.Place 6 | import model.chronus.Schedule 7 | 8 | @Serializable 9 | internal sealed interface Route { 10 | @Serializable 11 | data object Places : Route // home screen 12 | 13 | @Serializable 14 | data class Search(val place: Place) : Route 15 | 16 | @Serializable 17 | data class Lessons(val schedule: Schedule) : Route 18 | 19 | @Serializable 20 | data class AboutContributor(val contributor: Contributor) : Route 21 | } 22 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/src/commonMain/kotlin/shared/chronusparsers/Child.kt: -------------------------------------------------------------------------------- 1 | package shared.chronusparsers 2 | 3 | import feature.chronus.contributor.ContributorComponent 4 | import feature.chronus.places.PlacesComponent 5 | import feature.chronus.schedule.ScheduleComponent 6 | import feature.chronus.search.SearchComponent 7 | 8 | sealed class Child { // all screens in app 9 | class Places(val component: PlacesComponent) : Child() 10 | 11 | class Search(val component: SearchComponent) : Child() 12 | 13 | class Schedule(val component: ScheduleComponent) : Child() 14 | 15 | class AboutContributor(val component: ContributorComponent) : Child() 16 | } 17 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/src/commonMain/kotlin/datasource/network/chronus/main/Ext.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.main 2 | 3 | import com.fleeksoft.charset.Charsets 4 | import com.fleeksoft.charset.decodeToString 5 | import io.ktor.client.statement.HttpResponse 6 | import io.ktor.client.statement.bodyAsBytes 7 | 8 | /** 9 | * Use it if you get broken Russian characters with standard `.body()` (it happens when server 10 | * doesn't send `` in HTML code, but uses this encoding). 11 | */ 12 | suspend fun HttpResponse.bodyWithBadRussianEncoding(): String = 13 | this.bodyAsBytes().decodeToString(Charsets.forName("Windows-1251")) 14 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import shared 3 | 4 | @main 5 | struct iOSApp: App { 6 | @UIApplicationDelegateAdaptor(AppDelegate.self) 7 | var appDelegate: AppDelegate 8 | 9 | init() { 10 | Logger_nativeKt.installLogger() 11 | } 12 | 13 | var body: some Scene { 14 | WindowGroup { 15 | RootView(root: appDelegate.root) 16 | .ignoresSafeArea(.all) 17 | } 18 | } 19 | } 20 | 21 | class AppDelegate: NSObject, UIApplicationDelegate { 22 | let root: RootComponent = RootComponent( 23 | componentContext: DefaultComponentContext(lifecycle: ApplicationLifecycle()) 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | macos-build: 7 | name: all versions 8 | runs-on: macos-15 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Install Java 13 | uses: actions/setup-java@v3 14 | with: 15 | distribution: 'zulu' 16 | java-version: 17 17 | - name: Build -> Gradle (full project) 18 | uses: gradle/gradle-build-action@v2 19 | with: 20 | arguments: build 21 | # сборка iOS иногда стопорится без причины, поэтому была отключена 22 | # - name: Build -> XCode (iOS) 23 | # run: xcodebuild -project "appmodules/app/chronusparsers/ios/chronus parsers.xcodeproj" -scheme app-ios-compose -sdk iphonesimulator -arch x86_64 build 24 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/android/src/main/kotlin/app/chronusparsers/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package app.chronusparsers.android 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import com.arkivanov.decompose.defaultComponentContext 8 | import library.logger.installLogger 9 | import shared.chronusparsers.RootComponent 10 | import shared.chronusparsers.RootContent 11 | 12 | class MainActivity : ComponentActivity() { 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | enableEdgeToEdge() 16 | 17 | installLogger() 18 | val root = RootComponent(componentContext = defaultComponentContext()) 19 | 20 | setContent { 21 | RootContent(root) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/contributor/src/commonMain/kotlin/feature/chronus/contributor/ContributorComponent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.contributor 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import library.navigation.Retainable 5 | import model.chronus.Contributor 6 | 7 | class ContributorComponent( 8 | componentContext: ComponentContext, 9 | contributor: Contributor, 10 | val onFinish: () -> Unit, 11 | ) : ComponentContext by componentContext { 12 | data class State( 13 | val isRuLangUsed: Boolean, // eng otherwise 14 | val contributor: Contributor, 15 | ) 16 | 17 | val retainable = Retainable( 18 | instanceKeeper = instanceKeeper, 19 | defaultState = State(isRuLangUsed = true, contributor = contributor), 20 | ) 21 | 22 | fun onLangChange() { 23 | retainable.updateState { it.copy(isRuLangUsed = !it.isRuLangUsed) } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/src/commonMain/kotlin/datasource/network/chronus/main/DefaultHttpClient.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.main 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.plugins.HttpRequestRetry 5 | import io.ktor.client.plugins.HttpTimeout 6 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 7 | import io.ktor.http.isSuccess 8 | import io.ktor.serialization.kotlinx.json.json 9 | import kotlinx.serialization.json.Json 10 | 11 | internal fun defaultHttpClient(json: Json): HttpClient = HttpClient { 12 | expectSuccess = true 13 | 14 | install(ContentNegotiation) { json(json) } 15 | 16 | install(HttpTimeout) { 17 | socketTimeoutMillis = 60_000 18 | } 19 | 20 | install(HttpRequestRetry) { 21 | maxRetries = 3 22 | retryIf { _, response -> !response.status.isSuccess() } 23 | // retryOnExceptionIf { _, cause -> cause is NetworkErrorException } 24 | retryOnServerErrors() 25 | exponentialDelay() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/yourcity-youruniversity/src/commonMain/kotlin/datasource/network/chronus/yourcity/youruniversity/YouruniversityGetSearchResults.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.yourcity.youruniversity 2 | 3 | import io.ktor.client.HttpClient 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import kotlinx.serialization.json.Json 7 | import library.logger.LogType 8 | import library.logger.log 9 | import model.chronus.Schedule 10 | 11 | suspend fun getSearchResults(client: HttpClient, json: Json, query: String): List? { 12 | val response: String = try { 13 | error("Получение данных не реализовано") 14 | } catch (e: Exception) { 15 | log(LogType.NetworkClientError, e) 16 | return null 17 | } 18 | 19 | return try { 20 | withContext(Dispatchers.Default) { // Dispatchers.Default нужен для ускорение парсинга 21 | error("Парсинг не реализован") 22 | } 23 | } catch (e: Exception) { 24 | log(LogType.ParseError, e) 25 | return null 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/src/commonMain/kotlin/datasource/network/chronus/main/NetworkCache.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.main 2 | 3 | import kotlinx.datetime.Clock 4 | import model.chronus.Place 5 | 6 | class NetworkCache> { 7 | private val cache = mutableMapOf>() 8 | 9 | operator fun get(place: Place, url: String): T? = 10 | cache[PlaceAndQuery(place, url)]?.takeIf { it.cachedAt + 15 * 60 > Clock.System.now().epochSeconds }?.collection 11 | 12 | operator fun set(place: Place, url: String, collection: T) { 13 | cache[PlaceAndQuery(place, url)] = CollectionAndSyncTime(collection, Clock.System.now().epochSeconds) 14 | } 15 | 16 | fun getWithTimeIgnorance(place: Place, url: String): T? = 17 | cache[PlaceAndQuery(place, url)]?.collection 18 | } 19 | 20 | private data class PlaceAndQuery( 21 | val place: Place, 22 | val query: String, 23 | ) 24 | 25 | private data class CollectionAndSyncTime( 26 | val collection: T, 27 | val cachedAt: Long, 28 | ) 29 | -------------------------------------------------------------------------------- /appmodules/library/logger/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | } 7 | 8 | kotlin { 9 | applyDefaultHierarchyTemplate() 10 | jvm() 11 | iosArm64() // iOS 12 | iosSimulatorArm64() // macOS Apple Silicon 13 | iosX64() // macOS Intel 14 | 15 | androidTarget { 16 | compilerOptions { 17 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain.dependencies { 23 | implementation(libs.khronicle) 24 | } 25 | } 26 | } 27 | 28 | android { 29 | namespace = "library.logger" 30 | compileSdk = libs.versions.androidsdk.target.get().toInt() 31 | 32 | defaultConfig { 33 | minSdk = libs.versions.androidsdk.min.get().toInt() 34 | } 35 | 36 | compileOptions { 37 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 38 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/yourcity-youruniversity/src/commonMain/kotlin/datasource/network/chronus/yourcity/youruniversity/YouruniversityGetLessons.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.yourcity.youruniversity 2 | 3 | import io.ktor.client.HttpClient 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import kotlinx.serialization.json.Json 7 | import library.logger.LogType 8 | import library.logger.log 9 | import model.chronus.Lesson 10 | import model.chronus.Schedule 11 | 12 | suspend fun getLessons(client: HttpClient, json: Json, schedule: Schedule): List? { 13 | val response: String = try { 14 | error("Получение данных не реализовано") 15 | } catch (e: Exception) { 16 | log(LogType.NetworkClientError, e) 17 | return null 18 | } 19 | 20 | return try { 21 | withContext(Dispatchers.Default) { // Dispatchers.Default нужен для ускорение парсинга 22 | error("Парсинг не реализован") 23 | } 24 | } catch (e: Exception) { 25 | log(LogType.ParseError, e) 26 | return null 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.run/app-desktop.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | 22 | 23 | -------------------------------------------------------------------------------- /appmodules/model/common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | } 7 | 8 | kotlin { 9 | applyDefaultHierarchyTemplate() 10 | jvm() 11 | iosArm64() // iOS 12 | iosSimulatorArm64() // macOS Apple Silicon 13 | iosX64() // macOS Intel 14 | 15 | androidTarget { 16 | compilerOptions { 17 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain.dependencies { 23 | api(libs.kotlinx.datetime) 24 | api(libs.kotlinx.coroutines.core) 25 | } 26 | } 27 | } 28 | 29 | android { 30 | namespace = "model.common" 31 | compileSdk = libs.versions.androidsdk.target.get().toInt() 32 | 33 | defaultConfig { 34 | minSdk = libs.versions.androidsdk.min.get().toInt() 35 | } 36 | 37 | compileOptions { 38 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 39 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Maksim Yarkov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/desktop/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("DSL_SCOPE_VIOLATION") 2 | 3 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 4 | 5 | plugins { 6 | alias(libs.plugins.kotlin.multiplatform) 7 | alias(libs.plugins.kotlin.compose) 8 | alias(libs.plugins.jetbrains.compose) 9 | } 10 | 11 | kotlin { 12 | jvm { 13 | withJava() 14 | } 15 | 16 | sourceSets { 17 | jvmMain.dependencies { 18 | implementation(compose.desktop.currentOs) 19 | implementation(libs.decompose.core) 20 | implementation(libs.decompose.composeext) // needed by LifecycleController 21 | 22 | implementation(projects.appmodules.library.logger) 23 | implementation(projects.appmodules.model.common) 24 | implementation(projects.appmodules.model.chronus) 25 | implementation(projects.appmodules.shared.chronusparsers) 26 | } 27 | } 28 | } 29 | 30 | compose.desktop { 31 | application { 32 | mainClass = "app.chronusparsers.desktop.MainKt" 33 | 34 | nativeDistributions { 35 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 36 | packageName = "chronusparsers parsers" 37 | packageVersion = "1.0.0" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /appmodules/model/chronus/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlin.serialization) 7 | } 8 | 9 | kotlin { 10 | applyDefaultHierarchyTemplate() 11 | jvm() 12 | iosArm64() // iOS 13 | iosSimulatorArm64() // macOS Apple Silicon 14 | iosX64() // macOS Intel 15 | 16 | androidTarget { 17 | compilerOptions { 18 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 19 | } 20 | } 21 | 22 | sourceSets { 23 | commonMain.dependencies { 24 | api(libs.kotlinx.datetime) 25 | api(libs.kotlinx.coroutines.core) 26 | implementation(libs.kotlinx.serialization) 27 | } 28 | } 29 | } 30 | 31 | android { 32 | namespace = "model.chronus" 33 | compileSdk = libs.versions.androidsdk.target.get().toInt() 34 | 35 | defaultConfig { 36 | minSdk = libs.versions.androidsdk.min.get().toInt() 37 | } 38 | 39 | compileOptions { 40 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 41 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /appmodules/library/navigation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | } 7 | 8 | kotlin { 9 | applyDefaultHierarchyTemplate() 10 | jvm() 11 | iosArm64() // iOS 12 | iosSimulatorArm64() // macOS Apple Silicon 13 | iosX64() // macOS Intel 14 | 15 | androidTarget { 16 | compilerOptions { 17 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain.dependencies { 23 | api(libs.decompose.core) 24 | api(libs.decompose.composeext) 25 | api(libs.kotlinx.coroutines.core) 26 | } 27 | jvmMain.dependencies { 28 | api(libs.kotlinx.coroutines.swing) 29 | } 30 | } 31 | } 32 | 33 | android { 34 | namespace = "library.navigation" 35 | compileSdk = libs.versions.androidsdk.target.get().toInt() 36 | 37 | defaultConfig { 38 | minSdk = libs.versions.androidsdk.min.get().toInt() 39 | } 40 | 41 | compileOptions { 42 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 43 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/desktop/src/jvmMain/kotlin/app/chronusparsers/desktop/Main.kt: -------------------------------------------------------------------------------- 1 | package app.chronusparsers.desktop 2 | 3 | import androidx.compose.ui.unit.dp 4 | import androidx.compose.ui.window.Window 5 | import androidx.compose.ui.window.application 6 | import androidx.compose.ui.window.rememberWindowState 7 | import com.arkivanov.decompose.DefaultComponentContext 8 | import com.arkivanov.decompose.extensions.compose.lifecycle.LifecycleController 9 | import com.arkivanov.essenty.lifecycle.LifecycleRegistry 10 | import library.logger.installLogger 11 | import shared.chronusparsers.RootComponent 12 | import shared.chronusparsers.RootContent 13 | 14 | fun main() { 15 | installLogger() 16 | val lifecycle = LifecycleRegistry() 17 | 18 | val root = runOnUiThread { 19 | RootComponent( 20 | componentContext = DefaultComponentContext(lifecycle = lifecycle), 21 | ) 22 | } 23 | 24 | application { 25 | val windowState = rememberWindowState(width = 450.dp, height = 800.dp) 26 | 27 | LifecycleController(lifecycle, windowState) 28 | 29 | Window( 30 | onCloseRequest = ::exitApplication, 31 | state = windowState, 32 | title = "Chronus parsers", 33 | ) { 34 | RootContent(root) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /appmodules/library/ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.jetbrains.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | api(compose.ui) 26 | api(compose.foundation) 27 | api(compose.material3) 28 | 29 | api(libs.coil.compose) 30 | implementation(libs.coil.network) 31 | } 32 | androidMain.dependencies { 33 | runtimeOnly(libs.coil.gif) 34 | } 35 | } 36 | } 37 | 38 | android { 39 | namespace = "library.ui" 40 | compileSdk = libs.versions.androidsdk.target.get().toInt() 41 | 42 | defaultConfig { 43 | minSdk = libs.versions.androidsdk.min.get().toInt() 44 | } 45 | 46 | compileOptions { 47 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 48 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/app-ios-compose/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-irnitu/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // kotlin serialization is disabled in this module 2 | 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | alias(libs.plugins.kotlin.multiplatform) 7 | alias(libs.plugins.android.library) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | implementation(libs.ktor.client.core) 26 | implementation(libs.ksoup) 27 | 28 | implementation(projects.appmodules.library.logger) 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | } 32 | } 33 | } 34 | 35 | android { 36 | namespace = "datasource.network.chronusparsers.irkutsk.irnitu" 37 | compileSdk = libs.versions.androidsdk.target.get().toInt() 38 | 39 | defaultConfig { 40 | minSdk = libs.versions.androidsdk.min.get().toInt() 41 | } 42 | 43 | compileOptions { 44 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 45 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/places/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.jetbrains.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | implementation(projects.appmodules.library.logger) 26 | implementation(projects.appmodules.library.navigation) 27 | implementation(projects.appmodules.library.ui) 28 | 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | } 32 | } 33 | } 34 | 35 | android { 36 | namespace = "feature.chronus.places" 37 | compileSdk = libs.versions.androidsdk.target.get().toInt() 38 | 39 | defaultConfig { 40 | minSdk = libs.versions.androidsdk.min.get().toInt() 41 | } 42 | 43 | compileOptions { 44 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 45 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-igu-imit/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlin.serialization) 7 | } 8 | 9 | kotlin { 10 | applyDefaultHierarchyTemplate() 11 | jvm() 12 | iosArm64() // iOS 13 | iosSimulatorArm64() // macOS Apple Silicon 14 | iosX64() // macOS Intel 15 | 16 | androidTarget { 17 | compilerOptions { 18 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 19 | } 20 | } 21 | 22 | sourceSets { 23 | commonMain.dependencies { 24 | implementation(libs.ktor.client.core) 25 | implementation(libs.ktor.serialization) 26 | implementation(libs.ksoup) 27 | 28 | implementation(projects.appmodules.library.logger) 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | } 32 | } 33 | } 34 | 35 | android { 36 | namespace = "datasource.network.chronusparsers.irkutsk.iguimit" 37 | compileSdk = libs.versions.androidsdk.target.get().toInt() 38 | 39 | defaultConfig { 40 | minSdk = libs.versions.androidsdk.min.get().toInt() 41 | } 42 | 43 | compileOptions { 44 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 45 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/Place.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | enum class Place( 4 | val cyrillicName: String, 5 | val city: City, 6 | val contributor: Contributor, 7 | val defaultUrl: String, 8 | val minSearchChars: Int?, // if null, query is not needed, otherwise at least 1 char is needed 9 | val searchType: SearchType, 10 | val lessonDurationInMinutes: Int = 90, 11 | val apiCredits: Pair? = null, // name and url to your schedule api with OSS license (if needed) 12 | ) { 13 | IRKUTSK_IGU_IMIT( 14 | cyrillicName = "ИМИТ ИГУ", 15 | city = City.IRKUTSK, 16 | contributor = Contributor.MXKMN, 17 | defaultUrl = "https://raspmath.isu.ru/", 18 | minSearchChars = null, 19 | searchType = SearchType.BY_GROUP_PERSON_PLACE, 20 | ), 21 | IRKUTSK_IRNITU( 22 | cyrillicName = "ИРНИТУ", 23 | city = City.IRKUTSK, 24 | contributor = Contributor.MXKMN, 25 | defaultUrl = "https://istu.edu/schedule/", 26 | minSearchChars = 2, 27 | searchType = SearchType.BY_GROUP_PERSON_PLACE, 28 | ), 29 | YOUR_PLACE( 30 | cyrillicName = "Уч. заведение", 31 | city = City.YOUR_CITY, 32 | contributor = Contributor.YOU, 33 | defaultUrl = "https://your_place.edu/your_schedule_api/", 34 | minSearchChars = null, 35 | searchType = SearchType.BY_GROUP, 36 | lessonDurationInMinutes = 90, 37 | apiCredits = "Your Schedule API" to "https://github.com/you/schedule_api", 38 | ), 39 | } 40 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/yourcity-youruniversity/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlin.serialization) 7 | } 8 | 9 | kotlin { 10 | applyDefaultHierarchyTemplate() 11 | jvm() 12 | iosArm64() // iOS 13 | iosSimulatorArm64() // macOS Apple Silicon 14 | iosX64() // macOS Intel 15 | 16 | androidTarget { 17 | compilerOptions { 18 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 19 | } 20 | } 21 | 22 | sourceSets { 23 | commonMain.dependencies { 24 | implementation(libs.ktor.client.core) 25 | implementation(libs.ktor.serialization) 26 | implementation(libs.ksoup) 27 | implementation(libs.charsets) 28 | 29 | implementation(projects.appmodules.library.logger) 30 | implementation(projects.appmodules.model.common) 31 | implementation(projects.appmodules.model.chronus) 32 | } 33 | } 34 | } 35 | 36 | android { 37 | namespace = "datasource.network.chronusparsers.yourcity.youruniversity" 38 | compileSdk = libs.versions.androidsdk.target.get().toInt() 39 | 40 | defaultConfig { 41 | minSdk = libs.versions.androidsdk.min.get().toInt() 42 | } 43 | 44 | compileOptions { 45 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 46 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/search/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.jetbrains.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | implementation(projects.appmodules.library.logger) 26 | implementation(projects.appmodules.library.navigation) 27 | implementation(projects.appmodules.library.ui) 28 | 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | 32 | implementation(projects.appmodules.datasource.network.chronus.main) 33 | } 34 | } 35 | } 36 | 37 | android { 38 | namespace = "feature.chronus.search" 39 | compileSdk = libs.versions.androidsdk.target.get().toInt() 40 | 41 | defaultConfig { 42 | minSdk = libs.versions.androidsdk.min.get().toInt() 43 | } 44 | 45 | compileOptions { 46 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 47 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/schedule/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.jetbrains.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | implementation(projects.appmodules.library.logger) 26 | implementation(projects.appmodules.library.navigation) 27 | implementation(projects.appmodules.library.ui) 28 | 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | 32 | implementation(projects.appmodules.datasource.network.chronus.main) 33 | } 34 | } 35 | } 36 | 37 | android { 38 | namespace = "feature.chronus.schedule" 39 | compileSdk = libs.versions.androidsdk.target.get().toInt() 40 | 41 | defaultConfig { 42 | minSdk = libs.versions.androidsdk.min.get().toInt() 43 | } 44 | 45 | compileOptions { 46 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 47 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/contributor/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.jetbrains.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | } 9 | 10 | kotlin { 11 | applyDefaultHierarchyTemplate() 12 | jvm() 13 | iosArm64() // iOS 14 | iosSimulatorArm64() // macOS Apple Silicon 15 | iosX64() // macOS Intel 16 | 17 | androidTarget { 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 20 | } 21 | } 22 | 23 | sourceSets { 24 | commonMain.dependencies { 25 | implementation(projects.appmodules.library.logger) 26 | implementation(projects.appmodules.library.navigation) 27 | implementation(projects.appmodules.library.ui) 28 | 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | 32 | implementation(projects.appmodules.datasource.network.chronus.main) 33 | } 34 | } 35 | } 36 | 37 | android { 38 | namespace = "feature.chronus.contributor" 39 | compileSdk = libs.versions.androidsdk.target.get().toInt() 40 | 41 | defaultConfig { 42 | minSdk = libs.versions.androidsdk.min.get().toInt() 43 | } 44 | 45 | compileOptions { 46 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 47 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /appmodules/library/logger/src/commonMain/kotlin/library/logger/Logger.kt: -------------------------------------------------------------------------------- 1 | package library.logger 2 | 3 | import com.juul.khronicle.Log 4 | import com.juul.khronicle.LogLevel 5 | import com.juul.khronicle.WriteMetadata 6 | 7 | // inline is needed to provide the true file name in the tag (if it's null) 8 | 9 | enum class LogType(val logLevel: LogLevel) { 10 | IntentLaunchError(LogLevel.Error), 11 | NetworkClientError(LogLevel.Warn), 12 | ParseError(LogLevel.Warn), 13 | IOError(LogLevel.Warn), 14 | IORetry(LogLevel.Warn), 15 | ScheduledEvent(LogLevel.Info), 16 | SyncEvent(LogLevel.Info), 17 | ConcurrentLock(LogLevel.Debug), 18 | CornerCase(LogLevel.Debug), 19 | NonProductionCodeDebug(LogLevel.Debug), 20 | SensorInfo(LogLevel.Verbose), 21 | ParseResult(LogLevel.Verbose), 22 | NetworkRequestUrl(LogLevel.Verbose), 23 | } 24 | 25 | expect fun installLogger() 26 | 27 | inline fun log( 28 | type: LogType, 29 | noinline message: (WriteMetadata?) -> String, 30 | throwable: Throwable? = null, 31 | tag: String? = null, 32 | ) { 33 | Log.dynamic(level = type.logLevel, throwable = throwable, tag = tag, message = message) 34 | } 35 | 36 | inline fun log(type: LogType, message: String, throwable: Throwable? = null, tag: String? = null) = 37 | log(type = type, message = { message }, throwable = throwable, tag = tag) 38 | 39 | inline fun log(type: LogType, throwable: Throwable, tag: String? = null) = 40 | log(type = type, message = { throwable.toString() } /*, throwable = throwable*/, tag = tag) 41 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | max_line_length = 120 # default for .kt is 100, for ktlint is 140 6 | 7 | [{*.xml,.editorconfig,libs.versions.toml}] 8 | max_line_length = off 9 | 10 | [*.{kt,kts}] 11 | indent_style = tab 12 | 13 | ktlint_standard_annotation=disabled # не переносить аннотации, например @Inject 14 | 15 | ktlint_standard_if-else-wrapping=disabled # фикс для discouraged-comment-location 16 | ktlint_standard_function-naming=disabled # именование Composable в PascalCase 17 | ktlint_standard_property-naming=disabled # именование MutableSharedFlow с _ 18 | ktlint_standard_comment-wrapping=disabled # не беситься на отключённый через /* */ код 19 | 20 | # разрешить написание комментов после строки кода 21 | ktlint_standard_discouraged-comment-location=disabled 22 | ktlint_standard_value-parameter-comment=disabled # в датаклассе 23 | ktlint_standard_value-argument-comment=disabled # в скобках при создании объекта 24 | ktlint_standard_no-consecutive-comments=disabled # в kdoc 25 | 26 | # перенос строк при *fun() = withContext* и фиксы 27 | ktlint_standard_multiline-expression-wrapping=disabled 28 | ktlint_standard_string-template-indent=disabled 29 | ktlint_standard_function-signature=disabled 30 | 31 | # всякий кринж из версии 1.3 32 | ktlint_standard_binary-expression-wrapping=disabled 33 | ktlint_standard_class-signature=disabled 34 | ktlint_standard_condition-wrapping=disabled 35 | ktlint_standard_function-literal=disabled 36 | ktlint_standard_chain-method-continuation=disabled -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("DSL_SCOPE_VIOLATION") 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.android) 5 | alias(libs.plugins.kotlin.compose) 6 | alias(libs.plugins.android.application) 7 | alias(libs.plugins.jetbrains.compose) 8 | } 9 | 10 | android { 11 | namespace = "app.chronusparsers.android" 12 | compileSdk = libs.versions.androidsdk.target.get().toInt() 13 | 14 | defaultConfig { 15 | applicationId = "mxkmn.chronus.parsers" 16 | minSdk = libs.versions.androidsdk.min.get().toInt() 17 | targetSdk = libs.versions.androidsdk.target.get().toInt() 18 | versionCode = 1 19 | versionName = "1.0" 20 | } 21 | 22 | packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" 23 | 24 | buildTypes { 25 | getByName("release") { 26 | isMinifyEnabled = false 27 | } 28 | } 29 | 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 32 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 33 | } 34 | 35 | kotlinOptions { 36 | jvmTarget = libs.versions.jvm.target.get() 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation(libs.androidx.activity) 42 | implementation(compose.foundation) 43 | implementation(libs.decompose.core) 44 | 45 | implementation(projects.appmodules.library.logger) 46 | implementation(projects.appmodules.model.chronus) 47 | implementation(projects.appmodules.model.common) 48 | implementation(projects.appmodules.shared.chronusparsers) 49 | } 50 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/schedule/src/commonMain/kotlin/feature/chronus/schedule/ScheduleComponent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.schedule 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import datasource.network.chronus.main.ChronusNetwork 5 | import kotlinx.coroutines.launch 6 | import library.navigation.Retainable 7 | import model.chronus.Lesson 8 | import model.chronus.ProgressStatus 9 | import model.chronus.Schedule 10 | 11 | class ScheduleComponent( 12 | componentContext: ComponentContext, 13 | schedule: Schedule, 14 | val onFinish: () -> Unit, 15 | ) : ComponentContext by componentContext { 16 | data class State( 17 | val schedule: Schedule, 18 | val searchStatus: ProgressStatus = ProgressStatus.NOT_USED, 19 | val foundLessons: List = emptyList(), 20 | ) 21 | 22 | val retainable = Retainable( 23 | instanceKeeper = instanceKeeper, 24 | defaultState = State(schedule = schedule), 25 | ) 26 | 27 | init { 28 | getLessons() 29 | } 30 | 31 | fun getLessons() { 32 | retainable.scope.launch { 33 | retainable.updateState { it.copy(searchStatus = ProgressStatus.LOADING) } 34 | 35 | val searchResults = ChronusNetwork.getLessons(retainable.state.schedule) 36 | 37 | retainable.updateState { capturedState -> 38 | if (searchResults != null) { 39 | capturedState.copy( 40 | searchStatus = ProgressStatus.SUCCESS, 41 | foundLessons = searchResults.sortedBy { it.startTime }, 42 | ) 43 | } else { 44 | capturedState.copy(searchStatus = ProgressStatus.ERROR) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/Lesson.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | 5 | data class Lesson( 6 | val name: String, // название предмета 7 | val type: LessonType = LessonType.Other(), // тип пары 8 | val startTime: LocalDateTime, // время и дата начала 9 | val durationInMinutes: Int, 10 | val groups: Set = emptySet(), 11 | val subgroups: Set = emptySet(), // подгруппа, отсутствует == общая 12 | val persons: Set = emptySet(), 13 | val classrooms: Set = emptySet(), 14 | val additionalInfo: String? = null, // дополнительная информация, которую выдаёт только ваш сервис расписания 15 | ) 16 | 17 | /** У некоторых ВУЗов общие пары для нескольких групп, преподавателей, аудиторий или подгрупп прилетают в виде 18 | * отдельных записей - эта функция объединяет их для правильного представления. 19 | */ 20 | fun MutableList.addOrExtend(lesson: Lesson) { 21 | find { 22 | it.name == lesson.name && it.type == lesson.type && it.startTime == lesson.startTime && 23 | it.durationInMinutes == lesson.durationInMinutes && it.additionalInfo == lesson.additionalInfo && 24 | (it.persons == lesson.persons || it.classrooms == lesson.classrooms) 25 | }?.let { 26 | remove(it) 27 | add( 28 | it.copy( 29 | groups = it.groups + lesson.groups, 30 | subgroups = it.subgroups + lesson.subgroups, 31 | persons = it.persons + lesson.persons, 32 | classrooms = it.classrooms + lesson.classrooms, 33 | ), 34 | ) 35 | } ?: run { 36 | add(lesson) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | rootProject.name = "ChronusParserTemplate" 4 | 5 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 6 | 7 | pluginManagement { 8 | repositories { 9 | google { 10 | mavenContent { 11 | includeGroupAndSubgroups("androidx") 12 | includeGroupAndSubgroups("com.android") 13 | includeGroupAndSubgroups("com.google") 14 | } 15 | } 16 | mavenCentral() 17 | gradlePluginPortal() 18 | } 19 | } 20 | 21 | dependencyResolutionManagement { 22 | repositories { 23 | google { 24 | mavenContent { 25 | includeGroupAndSubgroups("androidx") 26 | includeGroupAndSubgroups("com.android") 27 | includeGroupAndSubgroups("com.google") 28 | } 29 | } 30 | mavenCentral() 31 | } 32 | } 33 | 34 | include(":appmodules:library:logger") 35 | include(":appmodules:library:navigation") 36 | include(":appmodules:library:ui") 37 | 38 | include(":appmodules:model:common") 39 | include(":appmodules:model:chronus") 40 | 41 | include(":appmodules:datasource:network:chronus:irkutsk-irnitu") 42 | include(":appmodules:datasource:network:chronus:irkutsk-igu-imit") 43 | include(":appmodules:datasource:network:chronus:yourcity-youruniversity") 44 | include(":appmodules:datasource:network:chronus:main") // collecting parsers in one class 45 | 46 | // include(":appmodules:datarepository:chronus") // FIXME: use repo instead of source 47 | 48 | include(":appmodules:feature:chronus:contributor") 49 | include(":appmodules:feature:chronus:places") 50 | include(":appmodules:feature:chronus:schedule") 51 | include(":appmodules:feature:chronus:search") 52 | 53 | include(":appmodules:shared:chronusparsers") 54 | 55 | include(":appmodules:app:chronusparsers:android") 56 | include(":appmodules:app:chronusparsers:desktop") 57 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-igu-imit/src/commonMain/kotlin/datasource/network/chronus/irkutsk/iguimit/LessonDto.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.irkutsk.iguimit 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /* 6 | У нас два эндпоинта, и json'ы на них немного отличаются... 7 | 8 | Это есть только на fillSchedule: { "groupName": "02223-ДБ", "typeSubjectId": 3 } 9 | Это есть только на searchPairSTC: { "group": "02271-ДБ", "weekday": "Понедельник", "time": "10.10-11.40" } 10 | 11 | Классический подход - создание отдельных Dto для каждого эндпоинта (это предпочтительный вариант если отличается всё, 12 | но в нашем случае это бесполезный оверхед, поскольку почти все названия идентичны). Поэтому работаем с общими 13 | переменными, не используем отличающиеся (timeId а не time, weekdayId а не weekday) по возможности, а если возможности 14 | нет (group/groupName) - делаем дубли с параметром null по умолчанию - по null сможем распознать, что данные находятся 15 | в соседней переменной. 16 | */ 17 | 18 | @Serializable 19 | internal data class LessonDto( 20 | // weekday (не путать с weekdayId) нельзя использовать, так как он есть у searchPairSTC, но нет у fillSchedule 21 | val weekdayId: Int, 22 | // time (не путать с timeId) нельзя использовать, так как он есть у searchPairSTC, но нет у fillSchedule 23 | val timeId: Int, 24 | val week: String, // "верхняя" == нечетная, "нижняя" == четная 25 | val typeSubjectName: String, 26 | val subjectId: Int, 27 | val subjectName: String, 28 | val group: String? = null, // для searchPairSTC 29 | val groupName: String? = null, // для fillSchedule 30 | val groupId: Int, 31 | val className: String?, 32 | val classroomId: Int, 33 | val teacherName: String, 34 | val teacherId: Int, 35 | val beginDatePairs: String, 36 | val endDatePairs: String, 37 | ) 38 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-irnitu/src/commonMain/kotlin/datasource/network/chronus/irkutsk/irnitu/IrnituGetSearchResults.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.irkutsk.irnitu 2 | 3 | import com.fleeksoft.ksoup.Ksoup 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.get 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import library.logger.LogType 10 | import library.logger.log 11 | import model.chronus.Place 12 | import model.chronus.Schedule 13 | import model.chronus.ScheduleType 14 | 15 | suspend fun getSearchResults(client: HttpClient, query: String): List? { 16 | val page: String = try { 17 | client.get("${Place.IRKUTSK_IRNITU.defaultUrl}?search=$query").body() 18 | } catch (e: Exception) { 19 | log(LogType.NetworkClientError, e) 20 | return null 21 | } 22 | 23 | return try { 24 | withContext(Dispatchers.Default) { 25 | val document = Ksoup.parse(page) 26 | val classWithResults = document.getElementsByClass("content ").firstOrNull() 27 | ?: return@withContext emptyList() 28 | 29 | classWithResults.getElementsByTag("li") 30 | .mapNotNull { if (it.childrenSize() > 0) it.child(0) else null } 31 | .map { a -> 32 | val url = Place.IRKUTSK_IRNITU.defaultUrl + a.attr("href") 33 | val name = a.text() 34 | .replace("", "").replace("", "") // website bug fix 35 | val type = when { 36 | url.contains("group") -> ScheduleType.GROUP 37 | url.contains("prep") -> ScheduleType.PERSON 38 | url.contains("aud") -> ScheduleType.CLASSROOM 39 | else -> ScheduleType.OTHER // never happens 40 | } 41 | Schedule(name, type, Place.IRKUTSK_IRNITU, url) 42 | } 43 | } 44 | } catch (e: Exception) { 45 | log(LogType.ParseError, e) 46 | null 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/places/src/commonMain/kotlin/feature/chronus/places/PlacesContent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.places 2 | 3 | import androidx.compose.foundation.lazy.items 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.OutlinedButton 6 | import androidx.compose.material3.OutlinedCard 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.LocalUriHandler 11 | import library.ui.Screen 12 | import library.ui.innerCardPadding 13 | import library.ui.screenElementPadding 14 | import model.chronus.Place 15 | 16 | @Composable 17 | fun PlacesContent( 18 | component: PlacesComponent, 19 | modifier: Modifier = Modifier, 20 | ) { 21 | val uriHandler = LocalUriHandler.current 22 | 23 | Screen( 24 | title = "Учебные заведения", 25 | onBackClick = null, 26 | modifier = modifier, 27 | ) { 28 | items(items = Place.entries) { 29 | OutlinedCard(Modifier.screenElementPadding()) { 30 | Text( 31 | modifier = Modifier.innerCardPadding(), 32 | style = MaterialTheme.typography.headlineMedium, 33 | text = "${it.cyrillicName}, ${it.city.cyrillicName}", 34 | ) 35 | OutlinedButton( 36 | onClick = { component.toSearch(it) }, 37 | modifier = Modifier.innerCardPadding(), 38 | ) { 39 | Text("Поиск") 40 | } 41 | OutlinedButton( 42 | onClick = { component.toContributor(it.contributor) }, 43 | modifier = Modifier.innerCardPadding(), 44 | ) { 45 | Text("Внедрил ${it.contributor.nickName}") 46 | } 47 | it.apiCredits?.let { (apiName, url) -> 48 | OutlinedButton( 49 | onClick = { uriHandler.openUri(url) }, 50 | modifier = Modifier.innerCardPadding(), 51 | ) { 52 | Text("На основе $apiName") 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /appmodules/library/navigation/src/commonMain/kotlin/library/navigation/Retainable.kt: -------------------------------------------------------------------------------- 1 | package library.navigation 2 | 3 | import com.arkivanov.decompose.value.MutableValue 4 | import com.arkivanov.decompose.value.Value 5 | import com.arkivanov.decompose.value.update 6 | import com.arkivanov.essenty.instancekeeper.InstanceKeeper 7 | import com.arkivanov.essenty.instancekeeper.getOrCreate 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.Job 11 | import kotlinx.coroutines.cancel 12 | 13 | /** 14 | * Предоставление доступа к State и CoroutineScope, которые должны сохраняться 15 | * при изменениях кофигурации и удаляться при уничтожении экрана. Предоставляет 16 | * основные возможности Jetpack ViewModel: 17 | * 18 | * @param onDestroy aka onCleared - вызывается при уничтожении экрана 19 | * @property scope aka viewModelScope - уничтожает работу зависимых Job при уничтожении экрана 20 | */ 21 | class Retainable( 22 | instanceKeeper: InstanceKeeper, 23 | defaultState: State, 24 | onDestroy: (Value, CoroutineScope) -> Unit = { _, _ -> }, // aka onCleared from Jetpack ViewModel 25 | ) { 26 | private val stateAndScope = instanceKeeper.getOrCreate { StateAndScope(defaultState, onDestroy) } 27 | 28 | val scope = stateAndScope.scope 29 | val observableState: Value = stateAndScope.state 30 | val state: State get() = stateAndScope.state.value 31 | 32 | fun updateState(function: (State) -> State) = stateAndScope.state.update(function) 33 | } 34 | 35 | private class StateAndScope( 36 | defaultState: T, 37 | private val onCleared: (Value, CoroutineScope) -> Unit, 38 | ) : InstanceKeeper.Instance { 39 | val state = MutableValue(defaultState) 40 | val scope = CoroutineScope(Job() + Dispatchers.Main.immediate) 41 | 42 | override fun onDestroy() { 43 | onCleared(state, scope) 44 | 45 | scope.cancel() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.android.library) 6 | } 7 | 8 | kotlin { 9 | applyDefaultHierarchyTemplate() 10 | jvm() 11 | iosArm64() // iOS 12 | iosSimulatorArm64() // macOS Apple Silicon 13 | iosX64() // macOS Intel 14 | 15 | androidTarget { 16 | compilerOptions { 17 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain.dependencies { 23 | implementation(libs.ktor.client.core) 24 | implementation(libs.ktor.client.content.negotiation) 25 | implementation(libs.ktor.serialization) 26 | implementation(libs.charsets) 27 | 28 | implementation(projects.appmodules.library.logger) 29 | implementation(projects.appmodules.model.common) 30 | implementation(projects.appmodules.model.chronus) 31 | 32 | implementation(projects.appmodules.datasource.network.chronus.irkutskIrnitu) 33 | implementation(projects.appmodules.datasource.network.chronus.irkutskIguImit) 34 | implementation(projects.appmodules.datasource.network.chronus.yourcityYouruniversity) 35 | } 36 | androidMain.dependencies { 37 | implementation(libs.ktor.client.okhttp) 38 | } 39 | jvmMain.dependencies { 40 | implementation(libs.ktor.client.okhttp) 41 | } 42 | iosMain.dependencies { 43 | implementation(libs.ktor.client.darwin) 44 | } 45 | } 46 | } 47 | 48 | android { 49 | namespace = "datasource.network.chronusparsers.main" 50 | compileSdk = libs.versions.androidsdk.target.get().toInt() 51 | 52 | defaultConfig { 53 | minSdk = libs.versions.androidsdk.min.get().toInt() 54 | } 55 | 56 | compileOptions { 57 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 58 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/src/commonMain/kotlin/shared/chronusparsers/RootContent.kt: -------------------------------------------------------------------------------- 1 | package shared.chronusparsers 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.lightColorScheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import com.arkivanov.decompose.ExperimentalDecomposeApi 11 | import com.arkivanov.decompose.extensions.compose.stack.Children 12 | import com.arkivanov.decompose.extensions.compose.stack.animation.fade 13 | import com.arkivanov.decompose.extensions.compose.stack.animation.plus 14 | import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation 15 | import com.arkivanov.decompose.extensions.compose.stack.animation.scale 16 | import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation 17 | import feature.chronus.contributor.ContributorContent 18 | import feature.chronus.places.PlacesContent 19 | import feature.chronus.schedule.ScheduleContent 20 | import feature.chronus.search.SearchContent 21 | 22 | @OptIn(ExperimentalDecomposeApi::class) 23 | @Composable 24 | fun RootContent( 25 | component: RootComponent, 26 | modifier: Modifier = Modifier, 27 | ) { 28 | MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { 29 | Children( 30 | stack = component.stack, 31 | modifier = modifier.fillMaxSize(), 32 | animation = predictiveBackAnimation( 33 | fallbackAnimation = stackAnimation(fade() + scale()), 34 | onBack = component::onBack, 35 | backHandler = component.backHandler, 36 | ), 37 | ) { (_, instance) -> 38 | when (instance) { 39 | is Child.Places -> PlacesContent(component = instance.component) 40 | is Child.Search -> SearchContent(component = instance.component) 41 | is Child.Schedule -> ScheduleContent(component = instance.component) 42 | is Child.AboutContributor -> ContributorContent(component = instance.component) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/LessonType.kt: -------------------------------------------------------------------------------- 1 | package model.chronus 2 | 3 | sealed interface LessonType { 4 | data object Lecture : LessonType // лекция 5 | 6 | data object Practice : LessonType // практика 7 | 8 | data object LabWork : LessonType // лабораторная работа 9 | 10 | data object Project : LessonType // проектная деятельность 11 | 12 | data object Exam : LessonType // экзамен 13 | 14 | data object CourseCredit : LessonType // зачёт 15 | 16 | data object Consultation : LessonType // консультация 17 | 18 | data class Other(val name: String = "") : LessonType // любой другой тип 19 | } 20 | 21 | fun LessonType.Other.correctNameOrNull(): String? = 22 | this.name.replaceFirstChar { it.uppercase() }.takeIf { it.isNotBlank() } 23 | 24 | fun LessonType.asString(): String? = when (this) { 25 | LessonType.Lecture -> "Лекция" 26 | LessonType.Practice -> "Практика" 27 | LessonType.LabWork -> "Лабораторная работа" 28 | LessonType.Project -> "Проектная деятельность" 29 | LessonType.Exam -> "Экзамен" 30 | LessonType.CourseCredit -> "Зачёт" 31 | LessonType.Consultation -> "Консультация" 32 | is LessonType.Other -> correctNameOrNull() 33 | } 34 | 35 | fun String?.asLessonType(): LessonType = when (this?.trim()?.lowercase()) { 36 | // обратите внимание: тип должен быть указан маленькими буквами 37 | 38 | "лекция", 39 | -> LessonType.Lecture 40 | "практика", 41 | "практическое занятие", // БГУ Иркутск 42 | "практические занятия", // БГУ Усть-Илимск 43 | "практические (семинарские) занятия", // ИРГУПС 44 | -> LessonType.Practice 45 | "лабораторная работа", 46 | "лабораторная", // ИГУ 47 | "лаб.-практич.занятия", // БГУ Усть-Илимск 48 | -> LessonType.LabWork 49 | "проект", 50 | "проектная деятельность", 51 | -> LessonType.Project 52 | "экзамен", 53 | "экзамены", 54 | -> LessonType.Exam 55 | "зачет", 56 | "зачёт", 57 | -> LessonType.CourseCredit 58 | "консультация", 59 | -> LessonType.Consultation 60 | else -> LessonType.Other(this?.trim() ?: "") 61 | 62 | // если ни один LessonType не подходит к некоторым типам занятия в вашем ВУЗе, 63 | // оставьте эти типы ниже, я обработаю их самостоятельно: 64 | // "ваш_неподходящий_тип_1", "ваш_неподходящий_тип_2" 65 | } 66 | -------------------------------------------------------------------------------- /appmodules/model/common/src/commonMain/kotlin/model/common/LocalDate.kt: -------------------------------------------------------------------------------- 1 | package model.common 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.DateTimeUnit 5 | import kotlinx.datetime.DayOfWeek 6 | import kotlinx.datetime.LocalDate 7 | import kotlinx.datetime.LocalDateTime 8 | import kotlinx.datetime.LocalTime 9 | import kotlinx.datetime.Month 10 | import kotlinx.datetime.TimeZone 11 | import kotlinx.datetime.minus 12 | import kotlinx.datetime.plus 13 | import kotlinx.datetime.todayIn 14 | 15 | fun LocalDate.Companion.current(timeZone: TimeZone = TimeZone.currentSystemDefault()): LocalDate = 16 | Clock.System.todayIn(timeZone) 17 | 18 | fun LocalDate.Companion.current(timeZoneId: String) = Clock.System.todayIn(TimeZone.of(timeZoneId)) 19 | 20 | fun LocalDate.weekStartDay() = this.minus(this.dayOfWeek.ordinal, DateTimeUnit.DAY) 21 | 22 | fun LocalDate.asLocalDateTime(hour: Int = 0, minute: Int = 0, second: Int = 0, nanosecond: Int = 0) = 23 | LocalDateTime(year, month, dayOfMonth, hour, minute, second, nanosecond) 24 | 25 | fun LocalDate.asLocalDateTime(localTime: LocalTime) = 26 | asLocalDateTime(localTime.hour, localTime.minute, localTime.second, localTime.nanosecond) 27 | 28 | fun LocalDate.weekOfYear(): Int { 29 | val currentYearStart = LocalDate(year = this.year, month = Month.JANUARY, dayOfMonth = 1) 30 | val dayOffset = currentYearStart.dayOfWeek.ordinal 31 | return (this.dayOfYear + dayOffset - 1 + 7) / 7 32 | } 33 | 34 | fun LocalDate.weekOfStudyYear(): Int { 35 | val thisWeekStartDate = this.minus(this.dayOfWeek.ordinal, DateTimeUnit.DAY) 36 | 37 | val startStudyYear = if (this.month.ordinal < Month.SEPTEMBER.ordinal) this.year - 1 else this.year 38 | 39 | val startStudyDate = LocalDate(startStudyYear, Month.SEPTEMBER, 1).let { 40 | val is1SepAtWeekend = it.dayOfWeek == DayOfWeek.SATURDAY || it.dayOfWeek == DayOfWeek.SUNDAY 41 | val startWeek = if (is1SepAtWeekend) it.plus(1, DateTimeUnit.WEEK) else it 42 | startWeek.minus(startWeek.dayOfWeek.ordinal, DateTimeUnit.DAY) 43 | } 44 | 45 | return (thisWeekStartDate.toEpochDays() - startStudyDate.toEpochDays()) / 7 + 1 46 | } 47 | 48 | /** Input pattern: "31.12.2024" */ 49 | fun LocalDate.Companion.parseRus(russianDate: String): LocalDate { 50 | val isoDate = russianDate.split('.').reversed().joinToString("-") 51 | return parse(isoDate) 52 | } 53 | -------------------------------------------------------------------------------- /appmodules/library/ui/src/commonMain/kotlin/library/ui/Screen.kt: -------------------------------------------------------------------------------- 1 | package library.ui 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.asPaddingValues 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.safeDrawing 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.LazyListScope 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 13 | import androidx.compose.material3.ExperimentalMaterial3Api 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.IconButton 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.Text 18 | import androidx.compose.material3.TopAppBar 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.unit.dp 23 | 24 | @Composable 25 | fun Screen( 26 | title: String, 27 | onBackClick: (() -> Unit)?, 28 | modifier: Modifier = Modifier, 29 | actions: @Composable RowScope.() -> Unit = {}, 30 | content: LazyListScope.() -> Unit, 31 | ) { 32 | Scaffold( 33 | modifier = modifier, 34 | contentWindowInsets = WindowInsets(0.dp), // disables padding for navbar (enabled by default in contentPadding) 35 | topBar = { 36 | @OptIn(ExperimentalMaterial3Api::class) 37 | TopAppBar( 38 | title = { Text(title) }, 39 | navigationIcon = { 40 | if (onBackClick != null) { 41 | IconButton(onClick = onBackClick) { 42 | Icon( 43 | imageVector = Icons.AutoMirrored.Default.ArrowBack, 44 | contentDescription = "Назад", 45 | ) 46 | } 47 | } 48 | }, 49 | actions = actions, 50 | ) 51 | }, 52 | ) { paddingValues -> 53 | LazyColumn( 54 | modifier = Modifier.fillMaxWidth().padding(paddingValues), 55 | horizontalAlignment = Alignment.CenterHorizontally, 56 | ) { 57 | item { 58 | HorizontalSpacer(8.dp) 59 | } 60 | content() 61 | item { 62 | HorizontalSpacer(8.dp + WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding()) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("DSL_SCOPE_VIOLATION") 2 | 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | alias(libs.plugins.kotlin.multiplatform) 7 | alias(libs.plugins.android.library) 8 | alias(libs.plugins.jetbrains.compose) 9 | alias(libs.plugins.kotlin.compose) 10 | alias(libs.plugins.kotlin.serialization) 11 | } 12 | 13 | kotlin { 14 | applyDefaultHierarchyTemplate() 15 | 16 | jvm() 17 | 18 | androidTarget { 19 | compilerOptions { 20 | jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.target.get())) 21 | } 22 | } 23 | 24 | listOf( 25 | iosArm64(), // iOS 26 | iosSimulatorArm64(), // macOS Apple Silicon 27 | iosX64(), // macOS Intel 28 | ).takeIf { "XCODE_VERSION_MAJOR" in System.getenv().keys } // Export the framework only for Xcode builds 29 | ?.forEach { 30 | it.binaries.framework { 31 | // framework is exported for ios 32 | 33 | baseName = "shared" // Used in Swift export 34 | 35 | export(libs.decompose.core) 36 | export(libs.essenty.lifecycle) 37 | export(projects.appmodules.library.logger) 38 | } 39 | } 40 | 41 | sourceSets { 42 | commonMain.dependencies { 43 | implementation(projects.appmodules.library.logger) 44 | implementation(projects.appmodules.library.navigation) 45 | implementation(projects.appmodules.library.ui) 46 | 47 | implementation(projects.appmodules.model.common) 48 | implementation(projects.appmodules.model.chronus) 49 | 50 | implementation(projects.appmodules.feature.chronus.contributor) 51 | implementation(projects.appmodules.feature.chronus.places) 52 | implementation(projects.appmodules.feature.chronus.schedule) 53 | implementation(projects.appmodules.feature.chronus.search) 54 | } 55 | iosMain.dependencies { 56 | api(libs.decompose.core) 57 | api(libs.essenty.lifecycle) 58 | 59 | api(projects.appmodules.library.logger) 60 | } 61 | } 62 | } 63 | 64 | android { 65 | namespace = "com.example.myapplication.compose" 66 | compileSdk = libs.versions.androidsdk.target.get().toInt() 67 | 68 | defaultConfig { 69 | minSdk = libs.versions.androidsdk.min.get().toInt() 70 | } 71 | 72 | compileOptions { 73 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 74 | targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.target.get()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /appmodules/shared/chronusparsers/src/commonMain/kotlin/shared/chronusparsers/RootComponent.kt: -------------------------------------------------------------------------------- 1 | package shared.chronusparsers 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.decompose.router.stack.ChildStack 5 | import com.arkivanov.decompose.router.stack.StackNavigation 6 | import com.arkivanov.decompose.router.stack.childStack 7 | import com.arkivanov.decompose.router.stack.pop 8 | import com.arkivanov.decompose.router.stack.pushNew 9 | import com.arkivanov.decompose.value.Value 10 | import com.arkivanov.essenty.backhandler.BackHandlerOwner 11 | import feature.chronus.contributor.ContributorComponent 12 | import feature.chronus.places.PlacesComponent 13 | import feature.chronus.schedule.ScheduleComponent 14 | import feature.chronus.search.SearchComponent 15 | 16 | class RootComponent( 17 | componentContext: ComponentContext, 18 | ) : BackHandlerOwner, ComponentContext by componentContext { 19 | private val navigation = StackNavigation() 20 | 21 | val stack: Value> = childStack( 22 | source = navigation, 23 | serializer = Route.serializer(), 24 | initialConfiguration = Route.Places, 25 | handleBackButton = true, 26 | childFactory = ::child, 27 | ) 28 | 29 | fun onBack() = navigation.pop() 30 | 31 | private fun child(currentRoute: Route, childComponentContext: ComponentContext): Child = when (currentRoute) { 32 | is Route.Places -> Child.Places( 33 | PlacesComponent( 34 | componentContext = childComponentContext, 35 | toSearch = { place -> navigation.pushNew(Route.Search(place)) }, 36 | toContributor = { contributor -> navigation.pushNew(Route.AboutContributor(contributor)) }, 37 | ), 38 | ) 39 | 40 | is Route.Search -> Child.Search( 41 | SearchComponent( 42 | componentContext = childComponentContext, 43 | place = currentRoute.place, 44 | onScheduleChoose = { navigation.pushNew(Route.Lessons(it)) }, 45 | onFinish = navigation::pop, 46 | ), 47 | ) 48 | 49 | is Route.Lessons -> Child.Schedule( 50 | ScheduleComponent( 51 | componentContext = childComponentContext, 52 | currentRoute.schedule, 53 | onFinish = navigation::pop, 54 | ), 55 | ) 56 | 57 | is Route.AboutContributor -> Child.AboutContributor( 58 | ContributorComponent( 59 | componentContext = childComponentContext, 60 | contributor = currentRoute.contributor, 61 | onFinish = navigation::pop, 62 | ), 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/main/src/commonMain/kotlin/datasource/network/chronus/main/ChronusNetwork.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.main 2 | 3 | import io.ktor.client.HttpClient 4 | import kotlinx.serialization.json.Json 5 | import library.logger.LogType 6 | import library.logger.log 7 | import model.chronus.Lesson 8 | import model.chronus.Place 9 | import model.chronus.Schedule 10 | 11 | object ChronusNetwork { 12 | // FIXME: прикрутить Manual DI (https://mishkun.xyz/blog/Manual-DI-Cookbook.html) вместо глобального объекта 13 | private val json: Json = defaultJson() 14 | private val client: HttpClient = defaultHttpClient(json) 15 | 16 | private val lessonsCache = NetworkCache>() 17 | private val searchResultsCache = NetworkCache>() 18 | 19 | suspend fun getLessons(schedule: Schedule): Set? { 20 | log(LogType.NetworkRequestUrl, "Trying to getLessons for $schedule") 21 | lessonsCache[schedule.place, schedule.url]?.let { return it } 22 | 23 | val lessons = when (schedule.place) { 24 | Place.IRKUTSK_IRNITU -> datasource.network.chronus.irkutsk.irnitu.getLessons(client, schedule) 25 | Place.IRKUTSK_IGU_IMIT -> datasource.network.chronus.irkutsk.iguimit.getLessons(client, json, schedule) 26 | Place.YOUR_PLACE -> datasource.network.chronus.yourcity.youruniversity.getLessons(client, json, schedule) 27 | }?.toSet() 28 | 29 | if (lessons != null) { 30 | lessonsCache[schedule.place, schedule.url] = lessons 31 | } 32 | 33 | return lessons 34 | } 35 | 36 | suspend fun getSearchResults( 37 | place: Place, 38 | query: String, // name of group, teacher or classroom. Empty ("") if Place.minSearchChars == null 39 | ): List? = try { 40 | log(LogType.NetworkRequestUrl, "Trying to getSearchResults: place=$place, query=$query") 41 | searchResultsCache[place, query]?.let { 42 | log(LogType.NetworkRequestUrl, "Found result in cache: place=$place, query=$query") 43 | return it 44 | } 45 | 46 | val result = when (place) { 47 | Place.IRKUTSK_IRNITU -> datasource.network.chronus.irkutsk.irnitu.getSearchResults(client, query) 48 | Place.IRKUTSK_IGU_IMIT -> datasource.network.chronus.irkutsk.iguimit.getSearchResults(client) 49 | Place.YOUR_PLACE -> datasource.network.chronus.yourcity.youruniversity.getSearchResults(client, json, query) 50 | }?.also { schedules -> 51 | if (schedules.isEmpty()) { 52 | log(LogType.CornerCase, "No schedules found: place=$place, query=$query") 53 | } else { 54 | searchResultsCache[place, query] = schedules 55 | } 56 | } 57 | 58 | result 59 | } catch (e: Exception) { 60 | log(LogType.IOError, "place=$place, query=$query", e) 61 | null 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /appmodules/model/chronus/src/commonMain/kotlin/model/chronus/Contributor.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:standard:max-line-length") 2 | 3 | package model.chronus 4 | 5 | // Данные ContributorAbout в реальном приложении находятся на сервере, поэтому 6 | // устаревшую информацию можно будет обновить для всех пользователей в любой 7 | // момент по вашему запросу 8 | 9 | // В реальном приложении ru карточки отображаются, если на телефоне установлен 10 | // русский язык, во всех остальных случаях будут отображены en карточки 11 | 12 | enum class Contributor( 13 | val nickName: String, 14 | val contributions: Int, 15 | val contributorAbout: ContributorAbout, 16 | ) { 17 | MXKMN( 18 | nickName = "mxkmn", 19 | contributions = 2, 20 | contributorAbout = ContributorAbout( 21 | photoUrl = "https://sun9-73.userapi.com/impg/Ejscq12YiM3h6u7GQS_wZRqDjwAgLao1rxbCSA/H0tN5Vs8pYI.jpg?size=2560x1920&quality=96&sign=ecdf89f8515e64dfbc23f6d69443f90e&type=album", 22 | en = ContributorAboutNameAndCards( 23 | name = "Maksim Yarkov", 24 | cards = listOf( 25 | ContributorAboutCard( 26 | title = "Found earphones but no music?", 27 | text = "I like this, it's very grown up:", 28 | buttons = listOf( 29 | ContributorAboutButton("Eskimo Callboy", "https://youtu.be/wobbf3lb2nk"), 30 | ContributorAboutButton("Astroid Boys", "https://youtu.be/-eb8Jp8BjuY"), 31 | ContributorAboutButton("Infant Annihilator", "https://youtu.be/8dnJpuWuGn8"), 32 | ), 33 | ), 34 | ), 35 | ), 36 | ru = ContributorAboutNameAndCards( 37 | name = "Максим Ярков", 38 | cards = listOf( 39 | ContributorAboutCard( 40 | title = "Ты любишь музыку?", 41 | text = "Лично я люблю музыку, в особенности песни и альбомы", 42 | buttons = listOf( 43 | ContributorAboutButton("ПАНЦУШОТ", "https://youtu.be/qzshKdDCw9A"), 44 | ContributorAboutButton("БАУ", "https://youtu.be/6cIvtibJzAk?t=1556"), 45 | ContributorAboutButton("svalka", "https://youtu.be/VrJE62WyBm4"), 46 | ), 47 | ), 48 | ), 49 | ), 50 | ), 51 | ), 52 | YOU( 53 | nickName = "you!", 54 | contributions = 1, 55 | contributorAbout = ContributorAbout( 56 | photoUrl = "https://pleated-jeans.com/wp-content/uploads/2023/08/what-ai-thinks-life-in-russia-is-like-22.jpg", // фотку можно засунуть из ВК например 57 | en = ContributorAboutNameAndCards( 58 | name = null, // добавлять реальное имя необязательно 59 | cards = listOf(), // оставьте пустым, если с английским плохо - переведу карточки с русского самостоятельно 60 | ), 61 | ru = ContributorAboutNameAndCards( 62 | name = null, // добавлять реальное имя необязательно 63 | cards = listOf( 64 | ContributorAboutCard( 65 | title = "Мой любимый стишок", 66 | text = "Сел на ветку воробей\nИ качается на ней\nРаз-два-три-четыре-пять\nОн не хочет улетать", 67 | buttons = listOf(), // карточка может и не содержать ссылок 68 | ), 69 | ContributorAboutCard( 70 | title = "Карточек может быть несколько", 71 | text = "💅", 72 | buttons = listOf(), // карточка может и не содержать ссылок 73 | ), 74 | ), 75 | ), 76 | ), 77 | ), 78 | } 79 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/chronus parsers.xcodeproj/xcshareddata/xcschemes/app-ios-compose.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | 3 | jvm-target = "11" 4 | androidsdk-min = "24" # не изменять: версии DayOfWeek.MONDAY 84 | "вт", "вторник" -> DayOfWeek.TUESDAY 85 | "ср", "среда" -> DayOfWeek.WEDNESDAY 86 | "чт", "четверг" -> DayOfWeek.THURSDAY 87 | "пт", "пятница" -> DayOfWeek.FRIDAY 88 | "сб", "суббота" -> DayOfWeek.SATURDAY 89 | "вс", "воскресенье" -> DayOfWeek.SUNDAY 90 | else -> null // parsing problems 91 | } 92 | 93 | fun String.asMonth(): Month? = when (this) { 94 | "января" -> Month.JANUARY 95 | "февраля" -> Month.FEBRUARY 96 | "марта" -> Month.MARCH 97 | "апреля" -> Month.APRIL 98 | "мая" -> Month.MAY 99 | "июня" -> Month.JUNE 100 | "июля" -> Month.JULY 101 | "августа" -> Month.AUGUST 102 | "сентября" -> Month.SEPTEMBER 103 | "октября" -> Month.OCTOBER 104 | "ноября" -> Month.NOVEMBER 105 | "декабря" -> Month.DECEMBER 106 | else -> null 107 | } 108 | 109 | /** Supported patterns: "23:59:59" or "23:59" */ 110 | fun String.asLocalTime(): LocalTime? { 111 | val numbers = this.split(':').map { it.toIntOrNull() ?: return null } 112 | return when (numbers.size) { 113 | 3 -> LocalTime(numbers[0], numbers[1], numbers[2]) 114 | 2 -> LocalTime(numbers[0], numbers[1]) 115 | else -> null 116 | } 117 | } 118 | 119 | /** Supported pattern: "31 декабря 2024" */ 120 | fun String.asLocalDate(): LocalDate? { 121 | val date = this.split(' ') 122 | val day = date.getOrNull(0)?.toIntOrNull() 123 | val month = date.getOrNull(1)?.asMonth() 124 | val year = date.getOrNull(2)?.toIntOrNull() 125 | 126 | return if (day != null && month != null && year != null) LocalDate(year, month, day) else null 127 | } 128 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-igu-imit/src/commonMain/kotlin/datasource/network/chronus/irkutsk/iguimit/IguimitGetSearchResults.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.irkutsk.iguimit 2 | 3 | import com.fleeksoft.ksoup.Ksoup 4 | import com.fleeksoft.ksoup.nodes.Element 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.call.body 7 | import io.ktor.client.request.get 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.awaitAll 11 | import kotlinx.coroutines.coroutineScope 12 | import kotlinx.coroutines.withContext 13 | import library.logger.LogType 14 | import library.logger.log 15 | import model.chronus.Place 16 | import model.chronus.Schedule 17 | import model.chronus.ScheduleType 18 | 19 | suspend fun getSearchResults(client: HttpClient): List? = coroutineScope { 20 | val groups = async { 21 | val pageGroups: String = try { 22 | client.get("${Place.IRKUTSK_IGU_IMIT.defaultUrl}selectGroup").body() 23 | } catch (e: Exception) { 24 | log(LogType.NetworkClientError, e) 25 | return@async null 26 | } 27 | 28 | withContext(Dispatchers.Default) { 29 | parseGroupsHtml(pageGroups) 30 | } 31 | } 32 | 33 | val personsAndClassrooms = async { 34 | val pagePersonsAndClassrooms: String = try { 35 | client.get("${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPair").body() 36 | } catch (e: Exception) { 37 | log(LogType.NetworkClientError, e) 38 | return@async null 39 | } 40 | 41 | withContext(Dispatchers.Default) { 42 | parsePersonsAndClassroomsHtml(pagePersonsAndClassrooms) 43 | } 44 | } 45 | 46 | listOf(groups, personsAndClassrooms).awaitAll() 47 | .flatMap { it ?: return@coroutineScope null } 48 | } 49 | 50 | private fun parseGroupsHtml(page: String): List? = try { 51 | val doc = Ksoup.parse(page) 52 | 53 | doc.select("div.selectGroup-course-col").flatMap { course -> 54 | course.select("li.item-schedule a").map { group -> 55 | Schedule( 56 | name = group.text(), 57 | type = ScheduleType.GROUP, 58 | place = Place.IRKUTSK_IGU_IMIT, 59 | url = Place.IRKUTSK_IGU_IMIT.defaultUrl + group.attr("href").removePrefix("/"), 60 | ) 61 | } 62 | } 63 | } catch (e: Exception) { 64 | log(LogType.ParseError, e) 65 | null 66 | } 67 | 68 | private fun parsePersonsAndClassroomsHtml(page: String): List? { 69 | try { 70 | val doc = Ksoup.parse(page) 71 | 72 | val persons = doc.select("select#searchTeacher option").mapNotNull { element -> 73 | getPersonOrClassroomSchedule(element, ScheduleType.PERSON) 74 | } 75 | 76 | val classrooms = doc.select("select#searchClass option").mapNotNull { element -> 77 | getPersonOrClassroomSchedule(element, ScheduleType.CLASSROOM) 78 | } 79 | 80 | return persons + classrooms 81 | } catch (e: Exception) { 82 | log(LogType.ParseError, e) 83 | return null 84 | } 85 | } 86 | 87 | private fun getPersonOrClassroomSchedule(element: Element, scheduleType: ScheduleType): Schedule? { 88 | val text = element.text() 89 | val value = element.attr("value") 90 | return if (value.isNotEmpty() && text.isNotEmpty()) { 91 | val url = "${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPair?" + when (scheduleType) { 92 | ScheduleType.PERSON -> "teacher=" 93 | ScheduleType.CLASSROOM -> "class=" 94 | else -> throw IllegalStateException() 95 | } + value 96 | 97 | Schedule( 98 | name = text, 99 | type = scheduleType, 100 | place = Place.IRKUTSK_IGU_IMIT, 101 | url = url, 102 | ) 103 | } else { 104 | null 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/search/src/commonMain/kotlin/feature/chronus/search/SearchComponent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.search 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import datasource.network.chronus.main.ChronusNetwork 5 | import kotlinx.coroutines.launch 6 | import library.logger.LogType 7 | import library.logger.log 8 | import library.navigation.Retainable 9 | import model.chronus.LessonType 10 | import model.chronus.Place 11 | import model.chronus.ProgressStatus 12 | import model.chronus.Schedule 13 | import model.common.asyncMap 14 | 15 | class SearchComponent( 16 | componentContext: ComponentContext, 17 | place: Place, 18 | val onScheduleChoose: (Schedule) -> Unit, 19 | val onFinish: () -> Unit, 20 | ) : ComponentContext by componentContext { 21 | data class State( 22 | val place: Place, 23 | val input: String = "", 24 | val searchStatus: ProgressStatus = ProgressStatus.NOT_USED, 25 | val foundSchedules: List = emptyList(), 26 | val isFindProblemsDialogShown: Boolean = false, 27 | val problems: String? = null, 28 | ) 29 | 30 | val retainable = Retainable( 31 | instanceKeeper = instanceKeeper, 32 | defaultState = State(place = place), 33 | ) 34 | 35 | init { 36 | if (place.minSearchChars == null) { 37 | search() // starts search when screen is opened 38 | } 39 | } 40 | 41 | fun setInput(string: String) { 42 | retainable.updateState { 43 | it.copy( 44 | input = string, 45 | searchStatus = ProgressStatus.NOT_USED, 46 | foundSchedules = emptyList(), 47 | ) 48 | } 49 | } 50 | 51 | fun search() { 52 | retainable.scope.launch { 53 | retainable.updateState { it.copy(searchStatus = ProgressStatus.LOADING) } 54 | 55 | val getAllEntries = retainable.state.place.minSearchChars == null 56 | val query = if (getAllEntries) "" else retainable.state.input 57 | val searchResults = ChronusNetwork.getSearchResults(retainable.state.place, query) 58 | ?.let { results -> 59 | if (getAllEntries) { // если с сервера пришло всё и требуется фильтрация на клиенте 60 | results.filter { result -> result.name.contains(retainable.state.input, ignoreCase = true) } 61 | } else { // если фильтрация была на сервере 62 | results 63 | } 64 | } 65 | 66 | retainable.updateState { capturedState -> 67 | if (searchResults != null) { 68 | capturedState.copy( 69 | searchStatus = ProgressStatus.SUCCESS, 70 | foundSchedules = searchResults.sortedBy { it.name }.sortedBy { it.type }, 71 | ) 72 | } else { 73 | capturedState.copy( 74 | searchStatus = ProgressStatus.ERROR, 75 | foundSchedules = emptyList(), 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | 82 | fun showProblemsDialog() { 83 | retainable.updateState { it.copy(isFindProblemsDialogShown = true) } 84 | } 85 | 86 | fun hideProblemsDialogAndClearProblems() { 87 | retainable.updateState { it.copy(problems = null, isFindProblemsDialogShown = false) } 88 | } 89 | 90 | fun findProblems() { 91 | val schedules = retainable.state.foundSchedules 92 | 93 | retainable.scope.launch { 94 | val strangeTypes = mutableSetOf() 95 | val problemSchedules = mutableListOf() 96 | 97 | log(LogType.NonProductionCodeDebug, "findProblems started for ${schedules.size} items") 98 | retainable.updateState { it.copy(problems = "findProblems started for ${schedules.size} items") } 99 | 100 | val limit = 25 // to prevent GC problems 101 | val startStep = 1 // if problems found, set last step from console instead of 1 102 | val steps = schedules.size / limit + 1 103 | for (step in startStep - 1.. 120 | val result = ChronusNetwork.getLessons(schedule) 121 | if (result == null) { 122 | problemSchedules += schedule.name 123 | } else { 124 | result.forEach { lesson -> 125 | (lesson.type as? LessonType.Other)?.name?.takeIf { it.isNotBlank() } 126 | ?.let { strangeTypes += "\"${it.lowercase()}\"" } 127 | } 128 | } 129 | } 130 | } 131 | 132 | log( 133 | LogType.NonProductionCodeDebug, 134 | "findProblems end:\n problemSchedules = $problemSchedules\n strangeTypes = $strangeTypes", 135 | ) 136 | retainable.updateState { 137 | it.copy( 138 | problems = it.problems + "\n\nfindProblems end:" + 139 | "\n problemSchedules = $problemSchedules\n strangeTypes = $strangeTypes\n", 140 | ) 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/contributor/src/commonMain/kotlin/feature/chronus/contributor/ContributorContent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.contributor 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.aspectRatio 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material3.LocalTextStyle 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.OutlinedButton 14 | import androidx.compose.material3.OutlinedCard 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.graphics.painter.ColorPainter 22 | import androidx.compose.ui.layout.ContentScale 23 | import androidx.compose.ui.platform.LocalDensity 24 | import androidx.compose.ui.platform.LocalUriHandler 25 | import androidx.compose.ui.unit.Density 26 | import androidx.compose.ui.unit.Dp 27 | import androidx.compose.ui.unit.TextUnit 28 | import androidx.compose.ui.unit.dp 29 | import androidx.compose.ui.unit.sp 30 | import coil3.compose.AsyncImage 31 | import com.arkivanov.decompose.extensions.compose.subscribeAsState 32 | import library.logger.LogType 33 | import library.logger.log 34 | import library.ui.Screen 35 | import library.ui.innerCardPadding 36 | import library.ui.screenElementPadding 37 | import model.chronus.ContributorAboutNameAndCards 38 | 39 | @Composable 40 | fun ContributorContent( 41 | component: ContributorComponent, 42 | modifier: Modifier = Modifier, 43 | ) { 44 | val model by component.retainable.observableState.subscribeAsState() 45 | 46 | Screen( 47 | modifier = modifier, 48 | onBackClick = { component.onFinish() }, 49 | title = "Внедрение", 50 | actions = { 51 | OutlinedButton(onClick = component::onLangChange) { 52 | Text("Сменить язык") 53 | } 54 | }, 55 | ) { 56 | item { 57 | FullInfo( 58 | imageModel = model.contributor.contributorAbout.photoUrl, 59 | nickName = model.contributor.nickName.takeIf { it.isNotBlank() }, 60 | contributions = model.contributor.contributions.takeIf { it > 1 }, 61 | nameAndCards = model.contributor.contributorAbout.let { if (model.isRuLangUsed) it.ru else it.en }, 62 | ) 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | private fun FullInfo( 69 | imageModel: Any?, 70 | nickName: String?, 71 | contributions: Int?, 72 | nameAndCards: ContributorAboutNameAndCards?, 73 | ) { 74 | val uriHandler = LocalUriHandler.current 75 | 76 | Column { 77 | Box( 78 | contentAlignment = Alignment.BottomStart, 79 | modifier = Modifier.screenElementPadding() 80 | .aspectRatio(1f) // square 81 | .clip(RoundedCornerShape(12.dp)) 82 | .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)), 83 | ) { 84 | AsyncImage( 85 | model = imageModel, 86 | contentDescription = null, 87 | placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceContainer), 88 | error = ColorPainter(MaterialTheme.colorScheme.error), 89 | contentScale = ContentScale.Crop, 90 | modifier = Modifier.fillMaxWidth(), 91 | onError = { log(LogType.IOError, it.result.throwable) }, 92 | ) 93 | Column { 94 | ImageText(nickName, true) 95 | ImageText(nameAndCards?.name) 96 | ImageText(contributions?.let { "Внедрил $it учебных заведения" }) 97 | } 98 | } 99 | nameAndCards?.cards?.forEach { card -> 100 | OutlinedCard(modifier = Modifier.screenElementPadding()) { 101 | Text( 102 | modifier = Modifier.innerCardPadding(), 103 | style = MaterialTheme.typography.headlineMedium, 104 | text = card.title, 105 | ) 106 | Text( 107 | modifier = Modifier.innerCardPadding(), 108 | text = card.text, 109 | ) 110 | card.buttons.forEach { 111 | OutlinedButton( 112 | modifier = Modifier.innerCardPadding(), 113 | onClick = { uriHandler.openUri(it.url) }, 114 | ) { 115 | Text(it.text) 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | @Composable 124 | private fun ImageText(text: String?, isLarge: Boolean = false) { 125 | if (text != null) { 126 | Text( 127 | text = text, 128 | style = if (isLarge) MaterialTheme.typography.displaySmall else LocalTextStyle.current, 129 | letterSpacing = if (isLarge) 2.sp else TextUnit.Unspecified, 130 | modifier = Modifier 131 | .background(MaterialTheme.colorScheme.surface) 132 | .padding(horizontal = 4.dp + (if (isLarge) 0.sp else 2.sp).toDp()), 133 | color = MaterialTheme.colorScheme.onSurface, 134 | ) 135 | } 136 | } 137 | 138 | @Composable 139 | private fun TextUnit.toDp(): Dp { 140 | val localDensity = LocalDensity.current 141 | 142 | return toDp(localDensity) 143 | } 144 | 145 | @Composable 146 | private fun TextUnit.toDp(density: Density): Dp = with(density) { this@toDp.toDp() } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Шаблон для создания интеграции учебного заведения с приложением chronus 2 | 3 | ![Промо-картинка](static/promo_pic.png) 4 | 5 | О преимуществах приложения: [статья на Хабре](https://habr.com/ru/articles/888022) 6 | 7 | Скачать приложение: [Google Play](https://play.google.com/store/apps/details?id=mxkmn.chronus) 8 | 9 | ## Смогу ли я создать интеграцию со своим учебным заведением? 10 | 11 | Если расписание доступно на сайте учебного заведения, причём без авторизации и не в виде PDF/Excel файлов, то у вас всё получится: достаточно лишь иметь пару вечеров и минимальные знания программирования. 12 | 13 | Если на вашем сайте используется авторизация, то вы можете помочь мне с передачей общей информации о ней [здесь](https://github.com/mxkmn/ChronusParserTemplate/issues/1). 14 | 15 | Если с языком Kotlin, использующимся в проекте, вы не сталкивались - [посмотрите что-нибудь вроде этого](https://www.youtube.com/watch?v=30tchn0TjaM). 16 | 17 | ## Работа с проектом 18 | 19 | 1. Создайте копию этого репозитория через кнопку `Use this template` -> `Create a new repository`: 20 | 21 | 1. Вы должны быть авторизованы на Github. 22 | 23 | 1. Назовите `Repository name` по шаблону `ChronusParser[Ваш город][Ваш университет][Ваш институт (если требуется)]`. Для названий используйте актуальные официальные названия на русском языке, даже если по-английски аббревиатура будет иной. Например, для иркутского политеха (ИРНИТУ - Иркутский национальный исследовательский технический университет) название репозитория - `ChronusParserIrkutskIrnitu` (институт не указывается, так как все институты используют один сайт с расписанием). Или, например, для ИГУ ИМИТ (иркутский государственный университет, институт математики и информационных технологий) будет `ChronusParserIrkutskIguImit` (у этого института отдельный сайт с расписанием). 24 | 25 | 1. Выберите видимость репозитория `Private`. Я не против, если вы хотите предоставить вашу интеграцию кому-то помимо меня, но не хотел бы, чтобы он лежал в интернете в открытом доступе - тогда вашими трудами смогут воспользоваться приложения-конкуренты, которые смогут нечестно заработать на вашей работе. 26 | 27 | 1. Откройте созданный репозиторий в IDE: 28 | 1. Для работы можно использовать IntelliJ IDEA или Android Studio: 29 | 30 | * Для сборки тестового приложения для ПК (самый быстрый и простой способ) вы можете использовать [IntelliJ IDEA](https://www.jetbrains.com/idea/download/#:~:text=IntelliJ%20IDEA%20Community%20Edition%20is%20completely%20free%20to%20use) версии не ниже 2024.3. 31 | 32 | * После импорта проекта может понадобиться выбрать JDK, в случае использования Windows рекомендую использовать установленную вместе с IDE (`C:\Program Files\JetBrains\IntelliJ IDEA\jbr`). Для этого нужно отказаться от скачивания в диалоговом окне выбора, после выбрать путь вручную через `шестерёнку сверху справа` -> `Project Structure` -> `SDKs` -> `+` -> `Add JDK from disk`. 33 | 34 | * При желании собрать для Android'а потребуется установка [плагина Android](https://plugins.jetbrains.com/plugin/22989-android/versions) и некоторые настройки. 35 | 36 | * При желании запустить на iOS потребуется установленный Xcode и iOS Simulator (лучше не надо, оно вас сожрёт). 37 | 38 | * Для сборки тестового приложения для Android вы можете использовать [Android Studio](https://developer.android.com/studio) (актуальная версия с сайта, либо установленная ранее версия не ниже Ladybug 2024.2.1) - тут, в отличие от IntelliJ IDEA, настраивать под Android ничего не нужно. 39 | 40 | * При желании собрать для ПК/iOS потребуется установка [плагина Kotlin Multiplatform](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform/versions/stable). Для iOS сборки также потребуется установленный Xcode и iOS Simulator (побойтесь Бога). 41 | 42 | * Для скачивания плагинов из IDE потребуется виртуально оказаться в другой стране (это законно, так как блокировка произошла не со стороны России). Если средства обхода блокировок не установлены, то можно поступить иначе - используя браузер с обходом блокировок, откройте JetBrains Marketplace и скачайте расширение файлом, после установите его через `шестерёнку сверху справа` -> `Plugins` -> `шестерёнка` -> `Install plugin from disk`. 43 | 44 | 1. Клонируйте репозиторий (на компьютере должен быть установлен [Git](https://git-scm.com/downloads)) через `Clone repository` или `File` -> `New` -> `Project from Version Control`, выберите во вкладке `GitHub` свежий репозиторий. 45 | 46 | 1. После полной загрузки попробуйте запустить приложение, выбрав сверху (чуть левее зелёного треугольника) параметр `Run on desktop`. При желании возможен запуск на устройствах Android через выбор `composeApp` (потребуется включить ADB в настройках разработчика). Запуск на iOS в теории возможен, однако не проверялся. 47 | 48 | 1. Начните написание своей интеграции: 49 | 50 | 1. Выберите просмотр `Project` вместо `Android` 51 | 52 | ![Окно IDE с выбором отображения](static/choose_project_view.png) 53 | 54 | 1. Откройте код проекта, который находится в директории `appmodules`. 55 | 56 | 1. В enum классе `City` (пакет `model.chronus`) измените `cyrillicName` и `timezoneId` (узнать ID часового пояса можно [здесь](https://askgeo.com/#:~:text=degrees-,TimeZoneId,-Olson%20time%20zone)). Менять название записи `YOUR_CITY` необязательно. 57 | 58 | 1. Создайте интеграцию, воспользовавшись [отдельным гайдом](static/integration.md). 59 | 60 | 1. Наконец, добавьте информацию о себе, модифицировав enum класс `Contributor` (пакет `model.chronus`): измените `nickName`, `photo`, `en`, `ru`. Менять название записи `YOU` необязательно. 61 | 62 | 1. Поделитесь вашим результатом: 63 | 64 | 1. Найдите вкладку `Commit` в IDE слева, выберите все изменения и опубликуйте их, нажав на `Commit and Push`. Подтвердите проверки. 65 | 66 | 1. Откройте ваш репозиторий на GitHub и проверьте, что изменения опубликовались. 67 | 68 | 1. Если ваш репозиторий приватный, в репозитории на GitHub зайдите в `Settings` -> `Collaborators`, нажмите на `Add people` и добавьте меня (`mxkmn`). 69 | 70 | 1. Сообщите об окончании разработки, написав мне в телеграм ([@mxkmn](https://t.me/mxkmn)) или на почту (mxkmn.inc@gmail.com), либо создав issue со ссылкой на ваш репозиторий. 71 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-igu-imit/src/commonMain/kotlin/datasource/network/chronus/irkutsk/iguimit/IguimitGetLessons.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.irkutsk.iguimit 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.call.body 5 | import io.ktor.client.request.post 6 | import io.ktor.client.request.setBody 7 | import io.ktor.http.ContentType 8 | import io.ktor.http.contentType 9 | import kotlinx.datetime.DateTimeUnit 10 | import kotlinx.datetime.LocalDate 11 | import kotlinx.datetime.plus 12 | import kotlinx.serialization.json.Json 13 | import library.logger.LogType 14 | import library.logger.log 15 | import model.chronus.EntryInfo 16 | import model.chronus.Lesson 17 | import model.chronus.Place 18 | import model.chronus.Schedule 19 | import model.chronus.ScheduleType 20 | import model.chronus.addOrExtend 21 | import model.chronus.asLessonType 22 | import model.common.asLocalDateTime 23 | import model.common.current 24 | import model.common.isEven 25 | import model.common.weekOfYear 26 | import model.common.weekStartDay 27 | 28 | suspend fun getLessons(client: HttpClient, json: Json, schedule: Schedule): List? { 29 | val response: String = try { 30 | when (schedule.type) { 31 | // example of schedule.url: https://raspmath.isu.ru/schedule/12 32 | ScheduleType.GROUP -> client.post("${Place.IRKUTSK_IGU_IMIT.defaultUrl}fillSchedule") { 33 | setBody("groupId=${schedule.url.substringAfterLast("/")}") 34 | contentType(ContentType.parse("application/x-www-form-urlencoded; charset=UTF-8")) 35 | }.body() 36 | // example of schedule.url: https://raspmath.isu.ru/searchPair?teacher=91 37 | ScheduleType.PERSON -> client.post("${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPairSTC") { 38 | setBody("subject_id=&teacher_id=${schedule.url.substringAfterLast("=")}&class_id=") 39 | contentType(ContentType.parse("application/x-www-form-urlencoded; charset=UTF-8")) 40 | }.body() 41 | // example of schedule.url: https://raspmath.isu.ru/searchPair?class=341 42 | ScheduleType.CLASSROOM -> client.post("${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPairSTC") { 43 | setBody("subject_id=&teacher_id=&class_id=${schedule.url.substringAfterLast("=")}") 44 | contentType(ContentType.parse("application/x-www-form-urlencoded; charset=UTF-8")) 45 | }.body() 46 | ScheduleType.OTHER -> throw IllegalStateException() 47 | } 48 | } catch (e: Exception) { 49 | log(LogType.NetworkClientError, e) 50 | return null 51 | } 52 | 53 | val currentWeekStart = LocalDate.current(Place.IRKUTSK_IGU_IMIT.city.timeZoneId).weekStartDay() 54 | val isCurrentWeekEven = currentWeekStart.weekOfYear().isEven() 55 | 56 | return try { 57 | val lessons = mutableListOf() 58 | val lessonDtos: List = json.decodeFromString(response) 59 | 60 | lessonDtos.forEach { 61 | val date = currentWeekStart.plus(it.weekdayId - 1, DateTimeUnit.DAY) 62 | val startDate = LocalDate.parse(it.beginDatePairs) 63 | val endDate = LocalDate.parse(it.endDatePairs) 64 | 65 | val hours = when (it.timeId) { 66 | 1 -> 8 67 | 2 -> 10 68 | 3 -> 11 69 | 4 -> 13 70 | 5 -> 15 71 | 6 -> 17 72 | 7 -> 18 73 | else -> throw IllegalStateException() 74 | } 75 | 76 | val minutes = when (it.timeId) { 77 | 1, 5 -> 30 78 | 2, 6 -> 10 79 | 3, 4, 7 -> 50 80 | else -> throw IllegalStateException() 81 | } 82 | 83 | // subgroup is available for rare set of groups (e.g. `02123-ДБ`) 84 | val (name, subgroup) = if (it.subjectName.endsWith(" подгруппа)")) { 85 | val subjectNameWithoutPrefix = it.subjectName.removeSuffix(" подгруппа)") 86 | val realSubjectName = subjectNameWithoutPrefix.substringBeforeLast(" (") 87 | val subgroup = subjectNameWithoutPrefix.substringAfterLast(" (").toIntOrNull() 88 | if (subgroup != null) { 89 | realSubjectName to setOf(subgroup) 90 | } else { // on error, fallback to default implementation 91 | it.subjectName to emptySet() 92 | } 93 | } else { 94 | it.subjectName to emptySet() 95 | } 96 | 97 | val basicLesson = Lesson( 98 | name = name, 99 | startTime = date.asLocalDateTime(hours, minutes), 100 | durationInMinutes = Place.IRKUTSK_IGU_IMIT.lessonDurationInMinutes, 101 | type = it.typeSubjectName.asLessonType(), 102 | groups = setOf( 103 | EntryInfo(it.group ?: it.groupName ?: "", "${Place.IRKUTSK_IGU_IMIT.defaultUrl}schedule/${it.groupId}"), 104 | ), 105 | subgroups = subgroup, 106 | persons = setOf( 107 | EntryInfo(it.teacherName, "${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPair?teacher=${it.teacherId}"), 108 | ), 109 | classrooms = if (it.className != null) { 110 | setOf( 111 | EntryInfo(it.className, "${Place.IRKUTSK_IGU_IMIT.defaultUrl}searchPair?class=${it.classroomId}"), 112 | ) 113 | } else { 114 | emptySet() 115 | }, 116 | additionalInfo = "Ссылка на предмет: " + 117 | "${Place.IRKUTSK_IGU_IMIT.defaultUrl.substringAfter("https://")}searchPair?subject=${it.subjectId}", 118 | ) 119 | 120 | if (it.week.isEmpty()) { // на чётных и нечётных неделях 121 | lessons.addIfInDateRange( 122 | lesson = basicLesson, 123 | startDate = startDate, 124 | endDate = endDate, 125 | ) 126 | lessons.addIfInDateRange( 127 | lesson = basicLesson.copy(startTime = date.plus(1, DateTimeUnit.WEEK).asLocalDateTime(hours, minutes)), 128 | startDate = startDate, 129 | endDate = endDate, 130 | ) 131 | } else { // на одной неделе 132 | val isLessonAtEvenWeek = it.week == "нижняя" 133 | if (isLessonAtEvenWeek == isCurrentWeekEven) { 134 | lessons.addIfInDateRange( 135 | lesson = basicLesson, 136 | startDate = startDate, 137 | endDate = endDate, 138 | ) 139 | } else { 140 | lessons.addIfInDateRange( 141 | lesson = basicLesson.copy(startTime = date.plus(1, DateTimeUnit.WEEK).asLocalDateTime(hours, minutes)), 142 | startDate = startDate, 143 | endDate = endDate, 144 | ) 145 | } 146 | } 147 | } 148 | 149 | lessons 150 | } catch (e: Exception) { 151 | log(LogType.ParseError, e) 152 | return null 153 | } 154 | } 155 | 156 | private fun MutableList.addIfInDateRange(lesson: Lesson, startDate: LocalDate, endDate: LocalDate) { 157 | val lessonDate = lesson.startTime.date 158 | if (startDate <= lessonDate && lessonDate <= endDate) { 159 | this.addOrExtend(lesson) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/schedule/src/commonMain/kotlin/feature/chronus/schedule/ScheduleContent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.schedule 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.OutlinedButton 9 | import androidx.compose.material3.OutlinedCard 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.saveable.rememberSaveable 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import com.arkivanov.decompose.extensions.compose.subscribeAsState 19 | import kotlinx.datetime.DayOfWeek 20 | import library.ui.Screen 21 | import library.ui.innerCardPadding 22 | import library.ui.screenElementPadding 23 | import model.chronus.EntryInfo 24 | import model.chronus.Lesson 25 | import model.chronus.ProgressStatus 26 | import model.chronus.asString 27 | 28 | @Composable 29 | fun ScheduleContent( 30 | component: ScheduleComponent, 31 | modifier: Modifier = Modifier, 32 | ) { 33 | val state by component.retainable.observableState.subscribeAsState() 34 | var isDescriptionVisible by rememberSaveable { mutableStateOf(false) } 35 | 36 | Screen( 37 | title = "${state.schedule.name}: занятия", 38 | onBackClick = { component.onFinish() }, 39 | actions = { 40 | OutlinedButton({ isDescriptionVisible = !isDescriptionVisible }) { 41 | Text(if (isDescriptionVisible) "Скрыть" else "Расширить") 42 | } 43 | }, 44 | modifier = modifier, 45 | ) { 46 | item { 47 | when (state.searchStatus) { 48 | ProgressStatus.LOADING -> { 49 | CircularProgressIndicator() 50 | } 51 | ProgressStatus.ERROR -> { 52 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 53 | Text("Ошибка при поиске") 54 | OutlinedButton(onClick = component::getLessons) { 55 | Text("Загрузить заново") 56 | } 57 | } 58 | } 59 | ProgressStatus.SUCCESS -> { 60 | if (state.foundLessons.isEmpty()) { 61 | Text("Занятия не найдены") 62 | } 63 | } 64 | ProgressStatus.NOT_USED -> {} 65 | } 66 | } 67 | items(state.foundLessons) { 68 | val lesson = it.copy( 69 | groups = it.groups.sortAndRemoveProtocol(), 70 | subgroups = it.subgroups.sorted().toSet(), 71 | classrooms = it.classrooms.sortAndRemoveProtocol(), 72 | persons = it.persons.sortAndRemoveProtocol(), 73 | ) 74 | OutlinedCard(modifier = Modifier.screenElementPadding()) { 75 | Text( 76 | text = "${lesson.startTime.date.dayOfWeek.asString()} ${lesson.startTime.date} | " + 77 | "${lesson.startTime.hour}:${twoDigitNum(lesson.startTime.minute)} (${lesson.durationInMinutes} минут)", 78 | style = MaterialTheme.typography.bodySmall, 79 | modifier = Modifier.innerCardPadding(), 80 | ) 81 | Text( 82 | text = lesson.getTitle(), 83 | modifier = Modifier.innerCardPadding(), 84 | ) 85 | AnimatedVisibility(isDescriptionVisible) { 86 | Text( 87 | text = lesson.getDescription(), 88 | style = MaterialTheme.typography.bodySmall, 89 | modifier = Modifier.innerCardPadding(), 90 | ) 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | private fun twoDigitNum(num: Int) = "${if (num > 9) "" else "0"}$num" 98 | 99 | private fun DayOfWeek.asString(): String = when (this) { 100 | DayOfWeek.MONDAY -> "пн" 101 | DayOfWeek.TUESDAY -> "вт" 102 | DayOfWeek.WEDNESDAY -> "ср" 103 | DayOfWeek.THURSDAY -> "чт" 104 | DayOfWeek.FRIDAY -> "пт" 105 | DayOfWeek.SATURDAY -> "сб" 106 | DayOfWeek.SUNDAY -> "вс" 107 | else -> "" 108 | } 109 | 110 | private fun String.minimizeLessonType(): String = when (this) { 111 | "Лабораторная работа" -> "Лаб." 112 | "Лекция" -> "Лек." 113 | "Практика" -> "Пр." 114 | "Проектная деятельность" -> "Проект" 115 | "Экзамен" -> "Экз." 116 | "Консультация" -> "Конс." 117 | else -> this 118 | } 119 | 120 | private fun Lesson.getTitle() = 121 | ( 122 | this.classrooms.takeIf { it.isNotEmpty() } 123 | ?.let { "${it.joinToString(transform = { it.name }, separator = " / ") } | " } ?: "" 124 | ) + 125 | (this.type.asString()?.let { "${it.minimizeLessonType()} | " } ?: "") + 126 | this.name + 127 | (this.subgroups.takeIf { it.isNotEmpty() }?.let { " (подгруппа ${it.joinToString(separator = " / ") })" } ?: "") 128 | 129 | private fun Lesson.getDescription() = ( 130 | when { 131 | (this.type.asString() != null && this.persons.size == 1) -> 132 | "${this.type.asString()}, ${this.persons.first().name}\n" 133 | this.type.asString() != null -> "${this.type.asString()}\n" 134 | this.persons.size == 1 -> "Преподаватель ${this.persons.first().calendarStyle()}" 135 | else -> "" 136 | } + when { 137 | (this.persons.size == 1 && this.persons.first().url != null && this.type.asString() != null) -> 138 | "\nСсылка на преподавателя: ${this.persons.first().url}\n" 139 | (this.persons.size > 1) -> 140 | "\nПреподаватели:\n" + this.persons.joinToString(separator = "") { it.calendarStyle() } 141 | else -> "" 142 | } + when { 143 | (this.classrooms.size == 1 && this.classrooms.first().url != null) -> 144 | "\nСсылка на аудиторию: ${this.classrooms.first().url}\n" 145 | else -> { 146 | val classroomsWithUrl = this.classrooms.filter { it.url != null } 147 | 148 | if (classroomsWithUrl.isNotEmpty()) { 149 | "\nАудитории${if (classroomsWithUrl.size == this.classrooms.size) "" else " с доступными ссылками"}:\n" + 150 | this.classrooms.joinToString(separator = "") { it.calendarStyle() } 151 | } else { 152 | "" 153 | } 154 | } 155 | } + when (this.groups.size) { 156 | 0 -> "" 157 | 1 -> "\nГруппа ${this.groups.first().calendarStyle()}" 158 | else -> "\nГруппы:\n" + this.groups.joinToString(separator = "") { it.calendarStyle() } 159 | } + when { 160 | this.additionalInfo != null -> "\n${this.additionalInfo}" 161 | else -> "" 162 | } 163 | ).trim() 164 | 165 | private fun Set.sortAndRemoveProtocol(): Set = this.map { (name, url) -> 166 | EntryInfo( 167 | name = name, 168 | url = when { 169 | url?.startsWith("https://") == true -> url.substringAfter("https://") 170 | url?.startsWith("http://") == true -> url.substringAfter("http://") 171 | else -> url 172 | }, 173 | ) 174 | }.sortedBy { it.name }.toSet() 175 | 176 | private fun EntryInfo.calendarStyle() = "${this.name}${this.url?.let { ": $it" } ?: ""}\n" 177 | -------------------------------------------------------------------------------- /appmodules/feature/chronus/search/src/commonMain/kotlin/feature/chronus/search/SearchContent.kt: -------------------------------------------------------------------------------- 1 | package feature.chronus.search 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.text.KeyboardActions 11 | import androidx.compose.foundation.text.KeyboardOptions 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.Search 15 | import androidx.compose.material.icons.filled.Warning 16 | import androidx.compose.material3.AlertDialog 17 | import androidx.compose.material3.CircularProgressIndicator 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.OutlinedButton 21 | import androidx.compose.material3.OutlinedIconButton 22 | import androidx.compose.material3.OutlinedTextField 23 | import androidx.compose.material3.Text 24 | import androidx.compose.material3.TextButton 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.input.key.Key 30 | import androidx.compose.ui.input.key.key 31 | import androidx.compose.ui.input.key.onPreviewKeyEvent 32 | import androidx.compose.ui.text.input.ImeAction 33 | import androidx.compose.ui.unit.dp 34 | import androidx.compose.ui.window.DialogProperties 35 | import com.arkivanov.decompose.extensions.compose.subscribeAsState 36 | import library.ui.Screen 37 | import library.ui.screenElementPadding 38 | import model.chronus.ProgressStatus 39 | import model.chronus.SearchType 40 | 41 | private const val FIND_PROBLEMS_DESCRIPTION = "Будут проверены все найденные расписания на " + 42 | "наличие ошибок, а также на наличие необработанных LessonType.\n\n" + 43 | 44 | "Эта автоматическая проверка направлена на быстрое нахождение проблем при парсинге и на " + 45 | "необходимость расширения String?.asLessonType(). Вы всё ещё должны проверять соответствие " + 46 | "полученных данных с данными на сайте вручную (перейдите в найденное расписание).\n\n" + 47 | 48 | "Ни одного расписания не должно появиться в problemSchedules. Корректно обработанные " + 49 | "расписания, но без занятий (группа перестала существовать, препод уволился, каникулы, " + 50 | "и т.д.) должны выдавать пустой List.\n\n" + 51 | 52 | "strangeTypes - типы, не найденные в String?.asLessonType(). Расширьте эту функцию." 53 | 54 | @Composable 55 | fun SearchContent( 56 | component: SearchComponent, 57 | modifier: Modifier = Modifier, 58 | ) { 59 | val state by component.retainable.observableState.subscribeAsState() 60 | 61 | Screen( 62 | modifier = modifier, 63 | onBackClick = { component.onFinish() }, 64 | title = "${state.place.cyrillicName}: поиск", 65 | actions = { 66 | AnimatedVisibility(state.foundSchedules.isNotEmpty()) { 67 | OutlinedButton(onClick = component::showProblemsDialog) { 68 | Text("Найти проблемы") 69 | } 70 | } 71 | }, 72 | ) { 73 | item { 74 | if (state.searchStatus != ProgressStatus.LOADING) { 75 | val isSearchPossible = 76 | state.input.trim().length >= (state.place.minSearchChars ?: 0) && 77 | state.searchStatus != ProgressStatus.SUCCESS 78 | 79 | Row( 80 | verticalAlignment = Alignment.CenterVertically, 81 | horizontalArrangement = Arrangement.Center, 82 | modifier = Modifier.screenElementPadding(), 83 | ) { 84 | OutlinedTextField( 85 | value = state.input, 86 | onValueChange = component::setInput, 87 | label = { 88 | Text( 89 | when (state.place.searchType) { 90 | SearchType.BY_GROUP_PERSON_PLACE -> "Группа, фамилия или аудитория" 91 | SearchType.BY_GROUP_PERSON -> "Группа или фамилия" 92 | SearchType.BY_GROUP_PLACE -> "Группа или аудитория" 93 | SearchType.BY_GROUP -> "Учебная группа" 94 | }, 95 | ) 96 | }, 97 | placeholder = { 98 | Text(state.place.minSearchChars?.let { "Минимум $it символ(-а)" } ?: "") 99 | }, 100 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 101 | keyboardActions = KeyboardActions(onSearch = { if (isSearchPossible) component.search() }), 102 | modifier = Modifier.weight(1f).onPreviewKeyEvent { 103 | if (it.key == Key.Enter) { 104 | if (isSearchPossible) { 105 | component.search() 106 | } 107 | true 108 | } else { 109 | false 110 | } 111 | }, 112 | ) 113 | AnimatedVisibility(isSearchPossible) { 114 | OutlinedIconButton(onClick = component::search, Modifier.padding(start = 8.dp)) { 115 | Icon(Icons.Default.Search, null) 116 | } 117 | } 118 | } 119 | } else { 120 | CircularProgressIndicator() 121 | } 122 | } 123 | item { 124 | if (state.searchStatus == ProgressStatus.ERROR) { 125 | Text("Ошибка при поиске") 126 | } else if (state.searchStatus == ProgressStatus.SUCCESS && state.foundSchedules.isEmpty()) { 127 | Text("Ничего не найдено") 128 | } 129 | } 130 | items(state.foundSchedules) { 131 | OutlinedButton( 132 | onClick = { component.onScheduleChoose(it) }, 133 | modifier = Modifier.padding(vertical = 8.dp), 134 | ) { 135 | Column { 136 | Text(it.name) 137 | Text(it.type.toString(), style = MaterialTheme.typography.bodySmall) 138 | Text(it.url, style = MaterialTheme.typography.bodySmall) 139 | } 140 | } 141 | } 142 | } 143 | 144 | if (state.isFindProblemsDialogShown) { 145 | AlertDialog( 146 | properties = DialogProperties(usePlatformDefaultWidth = false), 147 | modifier = Modifier.screenElementPadding(), 148 | icon = { Icon(Icons.Default.Warning, null) }, 149 | text = { 150 | Text( 151 | text = state.problems ?: FIND_PROBLEMS_DESCRIPTION, 152 | modifier = Modifier.verticalScroll(rememberScrollState()), 153 | ) 154 | }, 155 | confirmButton = { 156 | if (state.problems == null) { 157 | TextButton( 158 | content = { Text("Запустить проверку") }, 159 | onClick = component::findProblems, 160 | ) 161 | } 162 | }, 163 | dismissButton = { 164 | TextButton( 165 | content = { Text("Скрыть") }, 166 | onClick = component::hideProblemsDialogAndClearProblems, 167 | enabled = state.problems?.endsWith('\n') != false, 168 | ) 169 | }, 170 | onDismissRequest = {}, 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /appmodules/datasource/network/chronus/irkutsk-irnitu/src/commonMain/kotlin/datasource/network/chronus/irkutsk/irnitu/IrnituGetLessons.kt: -------------------------------------------------------------------------------- 1 | package datasource.network.chronus.irkutsk.irnitu 2 | 3 | import com.fleeksoft.ksoup.Ksoup 4 | import com.fleeksoft.ksoup.nodes.Element 5 | import com.fleeksoft.ksoup.select.Elements 6 | import io.ktor.client.HttpClient 7 | import io.ktor.client.call.body 8 | import io.ktor.client.request.get 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.datetime.DateTimeUnit 12 | import kotlinx.datetime.LocalDate 13 | import kotlinx.datetime.LocalDateTime 14 | import kotlinx.datetime.plus 15 | import library.logger.LogType 16 | import library.logger.log 17 | import model.chronus.EntryInfo 18 | import model.chronus.Lesson 19 | import model.chronus.Place 20 | import model.chronus.Schedule 21 | import model.chronus.asLessonType 22 | import model.common.asDayOfWeek 23 | import model.common.asLocalDateTime 24 | import model.common.asyncMap 25 | import model.common.current 26 | import model.common.parseRus 27 | import model.common.trimAndCapitalizeFirstChar 28 | import kotlin.text.split 29 | 30 | suspend fun getLessons(client: HttpClient, schedule: Schedule): List? { 31 | return (-2..2).toList().asyncMap { week -> // take the previous two, the current and the next two weeks 32 | val date = LocalDate.current().plus(week, DateTimeUnit.WEEK) 33 | // example of schedule.url: https://www.istu.edu/schedule/?group=470511 34 | val url = "${schedule.url}&date=${date.year}-${date.month.ordinal + 1}-${date.dayOfMonth}" 35 | 36 | val page: String = try { 37 | log(LogType.NetworkRequestUrl, "Trying to get data from $url") // additional data for customized URLs 38 | client.get(url).body() 39 | } catch (e: Exception) { 40 | log(LogType.NetworkClientError, e) 41 | return@asyncMap null 42 | } 43 | 44 | withContext(Dispatchers.Default) { // at maximum CPU speed 45 | parseScheduleHtml(page)?.also { // parse data from HTML and also, after parsing 46 | if (it.isEmpty()) { // if the schedule is empty (no lessons found on the page) 47 | log(LogType.CornerCase, "No schedule info at $url") // logging this just in case 48 | } 49 | } 50 | } 51 | }.flatMap { it ?: return null } // if an error occurred during data request, terminate the execution 52 | } 53 | 54 | private fun parseScheduleHtml(page: String): List? = try { 55 | val doc = Ksoup.parse(page) 56 | 57 | val startDate = doc.getElementsByClass("alert alert-info") 58 | .select("p:containsOwn(действия:)").first()?.let { // select the date description row 59 | it.text().removePrefix("время действия: ") // select an interval 60 | .substringBefore(' ') // select only the start date 61 | .let { LocalDate.parseRus(it) } 62 | } 63 | 64 | if (startDate != null) { 65 | doc.getElementsByClass("full-odd-week").firstOrNull()?.let { 66 | return parseWeekContainer(it, startDate, isEven = false) 67 | } 68 | 69 | doc.getElementsByClass("full-even-week").firstOrNull()?.let { 70 | return parseWeekContainer(it, startDate, isEven = true) 71 | } 72 | } 73 | 74 | emptyList() 75 | } catch (e: Exception) { 76 | log(LogType.ParseError, e) 77 | null 78 | } 79 | 80 | private fun parseWeekContainer( 81 | weekContainer: Element, 82 | startDate: LocalDate, 83 | isEven: Boolean, // is it an even week? 84 | ): List { 85 | val permittedLessonTypesForWeek = listOf( 86 | "class-tail class-all-week", 87 | if (isEven) "class-tail class-even-week" else "class-tail class-odd-week", 88 | ) 89 | 90 | val lessons = mutableListOf() 91 | var currentDate: LocalDate? = null 92 | 93 | weekContainer.children().forEach { dayItem -> 94 | if (dayItem.className() == "day-heading") { // date header 95 | currentDate = dayItem.text().substringBefore(',').asDayOfWeek() 96 | ?.let { startDate.plus(it.ordinal, DateTimeUnit.DAY) } 97 | } else if (dayItem.className() == "class-lines") { // day container with date and pairs 98 | dayItem.children().map { it.child(0).children() } // selecting time and lessons from every day 99 | .forEach { timeAndLessonsContainer -> 100 | addLessonsFromContainer( 101 | timeAndLessonsContainer = timeAndLessonsContainer, 102 | permittedLessonTypesForWeek = permittedLessonTypesForWeek, 103 | currentDate = currentDate ?: return@forEach, 104 | lessons = lessons, 105 | ) 106 | } 107 | } 108 | } 109 | 110 | return lessons 111 | } 112 | 113 | private fun addLessonsFromContainer( 114 | timeAndLessonsContainer: Elements, 115 | permittedLessonTypesForWeek: List, 116 | currentDate: LocalDate, 117 | lessons: MutableList, 118 | ) { 119 | var time: LocalDateTime? = null 120 | 121 | timeAndLessonsContainer.forEach { timeOrLesson -> 122 | val containerType = timeOrLesson.className() 123 | if (containerType == "class-time") { // if the card provides a time for next lessons 124 | val (hour, minute) = timeOrLesson.text().split(":").map { it.toIntOrNull() } 125 | .takeIf { it.size == 2 && !it.contains(null) }?.filterNotNull() ?: return@forEach 126 | 127 | time = currentDate.asLocalDateTime(hour, minute) 128 | } else if (time != null && containerType in permittedLessonTypesForWeek) { // if the card shows a lesson info 129 | var name: String? = null 130 | var type: String? = null 131 | val groups = mutableSetOf() 132 | val subgroups = mutableSetOf() 133 | val persons = mutableSetOf() 134 | val classrooms = mutableSetOf() 135 | 136 | timeOrLesson.children().forEach { info -> 137 | when (info.className()) { 138 | "class-pred" -> { 139 | name = info.text().trimAndCapitalizeFirstChar() 140 | } 141 | "class-aud" -> { 142 | info.children().takeIf { it.isNotEmpty() }?.forEach { child -> 143 | classrooms += getClassroom(child) 144 | } ?: run { 145 | classrooms += getClassroom(info) 146 | } 147 | } 148 | "class-info" -> { 149 | val contentInTag = info.html() 150 | if (info.children().isEmpty() || !info.child(0).attributes().toString().contains("group")) { 151 | type = contentInTag.substringBefore(" 158 | child.attr("href").takeIf { it.contains("group") }?.let { relativeUrl -> 159 | groups.add(EntryInfo(child.ownText(), Place.IRKUTSK_IRNITU.defaultUrl + relativeUrl)) 160 | } 161 | } 162 | 163 | contentInTag.let { if (it.contains("")) it.substringAfterLast("") else it }.trim() 164 | .takeIf { it.contains("подгруппа ") }?.substringAfter("подгруппа ")?.toIntOrNull()?.let { 165 | subgroups.add(it) 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | if (name?.startsWith("Проект | ") == true) { 173 | name = name!!.substringAfter("Проект | ") 174 | type = "Проект" 175 | } 176 | 177 | name?.takeIf { it.isNotBlank() }?.let { name -> 178 | lessons.add( 179 | Lesson( 180 | name = name, 181 | startTime = time!!, 182 | durationInMinutes = Place.IRKUTSK_IRNITU.lessonDurationInMinutes, 183 | type = type.asLessonType(), 184 | groups = groups, 185 | subgroups = subgroups, 186 | persons = persons, 187 | classrooms = classrooms, 188 | ), 189 | ) 190 | } 191 | } 192 | } 193 | } 194 | 195 | private fun getClassroom(classroomInfo: Element): List { 196 | val classroomName = classroomInfo.ownText().takeIf { it.isNotBlank() } 197 | val classroomUrl = classroomInfo.attr("href").takeIf { it.isNotBlank() }?.let { Place.IRKUTSK_IRNITU.defaultUrl + it } 198 | 199 | return if (classroomName.isNullOrBlank() || classroomName == "-") { 200 | emptyList() 201 | } else if (classroomUrl != null) { 202 | listOf(EntryInfo(classroomName, classroomUrl)) 203 | } else { 204 | classroomName.split(" ").filter { it != "/" }.map { EntryInfo(it, null) } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /static/integration.md: -------------------------------------------------------------------------------- 1 | # Написание интеграции с учебным заведением 2 | 3 | ## Выбор источника расписания 4 | 5 |
Основные ценности chronus 6 | 7 | При выборе источника расписания следует преследовать эти цели: 8 | 9 | * **Предоставление максимально актуального расписания**. Если есть несколько источников расписаний, но в некоторых из них расписание постоянно устаревшее, то источники с устаревшим расписанием стоит бойкотировать. 10 | * **Максимальный охват полезных расписаний**. Расписание групп, преподавателей, аудиторий окажется полезным для пользователей и стоит добавить их все при наличии такой возможности, а вот расписание дисциплин, например, лучше проигнорировать. 11 | * **Авторизация нежелательна**. Во-первых, сейчас она не поддерживается. Во-вторых, даже если и будет поддерживаться, не каждый пользователь захочет вводить свои данные в стороннее приложение. 12 | 13 |
14 |
Доступность информации 15 | 16 | Выбирая источник расписания, вы должны быть уверены в том, что он доступен вам. Возможны эти варианты: 17 | 18 | 1. Данные доступны любому пользователю интернета, без авторизации или после её осуществления. Например, сайты ВУЗов можно спокойно использовать. 19 | 1. API является общедоступным (это где-то указано напрямую, либо эта информация получена от автора в переписке). В этом случае может потребоваться указать его название и добавить ссылку в `Place.apiCredits` (пакет `model.chronus`). 20 | 1. API не общедоступен. В этом случае вам необходимо удостовериться, что автор этого API разрешил вам использование его интеллектуальной собственности и внедрение в стороннее приложение. 21 | 22 | Получение данных из PDF/Excel файлов, VK/TG ботов не поддерживается (и не планируется). Если вы готовы поддержать такой набор данных для своего учебного заведения и править возможные ошибки при их возникновении в течение длительного времени - создайте свой бекенд и подключите его к chronus. Рекомендуется использовать для передачи данных модели, копирующие `Schedule` и `Lesson` (пакет `model.chronus`). 23 | 24 |
25 |
Исследуем доступные источники расписания 26 | 27 | Иногда можно встретиться с разными источниками, в которых можно получить расписание. Например, для моего учебного заведения выбор был следующим: 28 | 29 | * Сайт ВУЗа: 30 | * [+] Предоставляет расписание для любой группы, преподавателя или аудитории 31 | * [+] Расписание всегда актуально 32 | * [+] Не требуется авторизация 33 | * [-] Немного тормозной 34 | * [-] Нет API, поэтому придётся парсить HTML, и нет гарантий что сайт не поменяется (придётся дорабатывать парсер) 35 | 36 | * Кампус (микросоцсеть для студентов и сотрудников): 37 | * [-] Только личное расписание 38 | * [---] Расписание иногда отсутствует 39 | * [---] Требуется авторизация 40 | * [-] Прилично тормозной 41 | * [-] Нет API, поэтому придётся парсить HTML, и нет гарантий что сайт не поменяется (придётся дорабатывать парсер) 42 | 43 | * Бекенд приложения-конкурента №1: 44 | * [+-] Предоставляет расписание для группы или преподавателя, но не для аудитории 45 | * [---] Расписание редко обновляется, поэтому почти всегда устаревшее 46 | * [+] Не требуется авторизация 47 | * [+-] API не является публичным, возможно не получится договориться о легальном использовании 48 | * [---] Создан студентом, который не развивает приложение: велик шанс отключения серверов 49 | 50 | * Бекенд приложения-конкурента №2: 51 | * Плюсы и минусы полностью идентичны приложению-конкуренту №1 52 | * [-] Но ещё и API максимально непродуманное: требует отдельного запроса на каждый день, следовательно, для получения расписания на 2 недели придётся сделать аж 35 запросов к серверу 53 | 54 | * Телеграм-бот: 55 | * Те же (устаревшие) данные, что у приложения-конкурента №2 (так как используется тот же сервер данных) 56 | * [---] chronus не поддерживает парсинг из Telegram 57 | 58 | Как можно заметить, наилучший выбор в моём случае - сайт ВУЗа. Единственная проблема - отсутствие API и необходимость парсить HTML - это несколько сложнее, плюс нет гарантий, что сайт всегда будет оставаться таким же и не потребует изменений. Тем не менее, этот сайт не менялся за последние 5 лет, поэтому парсинг остаётся стабильным. 59 | 60 | В вашем случае также можно встретиться со множеством источников расписаний, но в большинстве случаев лучше всего подойдёт сайт (разумеется, если там данные не в виде PDF/Excel). Начинайте изучение с него - скорее всего это наилучший источник, другие не понадобятся. 61 | 62 |
63 | 64 | ## Получение данных из источника 65 | 66 |
Типы передачи данных 67 | 68 | Если мы не используем источник с API и вместо этого пользуемся сайтом учебного заведения, то необходимо разобраться, как происходит передача данных через сайт. 69 | 70 | Начнём с отображения расписания. Войдите на страницу с любым расписанием. Требуется разобраться, как именно оно передаётся. 71 | 72 | Отключите JavaScript в браузере - для этого войдите в его настройки, введите в поиске `JavaScript`, отключите его. 73 | 74 | ![Отключение JS](disable_js.png) 75 | 76 | После перезагрузите изначальную страницу с расписанием. Видно ли расписание после отключения JavaScript? Тогда оно передаётся прямо в HTML - в дальнейшем будем анализировать его. 77 | 78 | Если расписание не отобразилось, то мы видим не обычную веб-страницу, а веб-приложение, которое получает данные после дополнительного запроса к серверу. Нужно анализировать запросы - читайте об этом ниже. 79 | 80 |
81 | 82 |
Анализ страницы при передаче данных в HTML 83 | 84 | HTML - это структура данных с иерархической структурой, то есть внутри элементов могут находиться другие вложенные элементы. 85 | 86 | Чтобы проанализировать передаваемые данные, достаточно в браузере нажать по элементу расписания правой кнопкой мыши и выбрать "Посмотреть код элемента" или "Проверить". Будет показано дерево элементов, с которыми нам предстоит работать. 87 | 88 | ![Дерево элементов](html.png) 89 | 90 | В этой структуре требуется понять, какие данные нам понадобятся - мы их будем получать в дальнейшем при помощи Ksoup. 91 | 92 | Пример обработки таких данных можно увидеть в файлах `IguimitGetSearchResults` (пакет `datasource.network.chronus.irkutsk-igu-imit`), `IrnituGetSearchResults` и `IrnituGetLessons` (пакет `datasource.network.chronus.irkutsk-irnitu`). 93 | 94 |
95 | 96 |
Анализ страницы при передаче данных в отдельных запросах 97 | 98 | Обычно с отдельными запросами гораздо удобнее работать, но чуть сложнее разобраться в том, как их правильно составить. 99 | 100 | Итак, необходимо открыть Инструменты разработчика во вкладке Сеть. После перезагрузки страницы появятся запросы (скорее всего во вкладке Fetch/XHR): 101 | 102 | ![Полученный JSON](json.png) 103 | 104 | После выбора нужного запроса (в нашем случае `fillSchedule`) появится вся информация: в Заголовках и Полезных данных будет информация, переданная серверу, а в Предварительном просмотре - полученная информация. 105 | 106 | Необходимо проанализировать, какая нагрузка важна, а какая является мусорной. 107 | 108 | На примере взято получение данных расписания ИГУ ИМИТ, реализацию которого можно просмотреть в файле `IguimitGetLessons` (пакет `datasource.network.chronus.irkutsk-igu-imit`). Здесь в Полезных данных (это `body`) обнаружится строка `groupId=3` - однозначно полезно, это ID группы, которую мы просматриваем. Однако этого будет недостаточно - на самом деле сервер также требует `content-type`, который необходимо скопировать из Заголовков - после этого сервер начнёт отдавать полезную информацию. 109 | 110 | Полезная информация почти наверняка придёт в виде JSON - её мы будем обрабатывать при помощи `kotlinx.serialization`. 111 | 112 |
113 | 114 |
Работа с поиском 115 | 116 | Источники расписаний обычно предоставляют информацию о доступных группах, преподавателях, аудиториях в двух видах: 117 | 118 | * **Набор доступных расписаний**: где-то находится полный набор всех возможных расписаний, нужно просто единоразово достать оттуда данные. Пример - `IguimitGetSearchResults`, который вытаскивает эти данные из пары HTML-страниц. 119 | * **Реальный поиск**: требуется ввести название, отправить её на сервер, и он вернёт расписания, удовлетворяющие запросу. Пример - `IrnituGetSearchResults`. На самом деле у ИРНИТУ тоже есть страницы с наборами доступных расписаний, но их очень много и там лишь расписание групп - гораздо лучше воспользоваться поиском по всей базе. 120 | 121 | При реальном поиске может потребоваться: 122 | 123 | * **GET-запрос**: требуется добавить искомое название в `URL`, после пропарсить полученную HTML-страницу 124 | * **POST-запрос**: требуется добавить искомое название в `body` или `header`, после разобрать полученный ответ (скорее всего в виде JSON). Здесь помогут разобраться Инструменты разработчика, как уже было описано выше в "Анализе страницы при передаче данных в отдельных запросах". 125 | 126 |
127 | 128 | ## Добавление интеграции в chronus 129 | 130 |
Внутренняя структура chronus 131 | 132 | Вернёмся к коду. 133 | 134 | Пора изменить запись `Place.YOUR_PLACE` (пакет `model.chronus`): 135 | 136 | * В `cyrillicName` добавьте отображаемое название учебного заведения (а также института, если требуется) 137 | * В `defaultUrl` укажите базовую ссылку до вашего источника расписания (префикс, используемый для всех запросов) 138 | * В `minSearchChars` укажите `null`, если для поиска будет использоваться "Набор доступных расписаний", либо укажите минимальное количество символов для работы поиска, если используется "Реальный поиск" (при указании `null` запрос будет происходить мгновенно, и на основе полученных результатов приложение будет искать расписания без обращений к серверу) 139 | * В `searchType` укажите доступные типы расписаний (все возможные типы перечислены в файле `SearchType` из пакета `model.chronus`) 140 | * В `apiCredits` укажите название и ссылку на API, если требуется (читайте "Доступность информации"), или `null`, если это не требуется. 141 | * По необходимости измените количество минут на занятие в `lessonDurationInMinutes` 142 | 143 | Важно! Если требуется использовать расписание по ссылке с http (не http**s**) и запускать приложение на Android, то добавьте сайт в файл `permitted_http_connections.xml`, находящийся в `res/xml` модуля `app.chronusparsers.android`. 144 | 145 | После останется написать обработчики данных вашего учебного заведения. Они находятся в пакете `datasource.network.chronus.yourcity-youruniversity`. Вам необходимо реализовать две функции: `getSearchResults` и `getLessons`. 146 | 147 | `getSearchResults`: 148 | 149 | * Нужна для поиска, возвращает список расписаний `List` 150 | * Если `minSearchChars == null`, функция должна возвращать весь список доступных расписаний, введённые в поисковую строку данные ей не передаются 151 | * Если `minSearchChars != null`, функция должна отправлять строку из поисковой строки на сервер и возвращать список расписаний, удовлетворяющий запросу 152 | * В структуре данных `Schedule` в строке `url` необходимо поместить полный адрес, по которому можно посмотреть расписание через браузер. Но, если там какой-то уникальный ID, который нельзя связать с url, то можно передать в `url` строку `Place.IRKUTSK_IGU_IMIT.defaultUrl + scheduleId` - наличие defaultUrl обязательно, её потом можно будет отрезать в `getLessons`, чтобы получить `scheduleId` обратно. 153 | 154 | `getLessons`: 155 | 156 | * Нужна для получения конкретного расписания, выдаёт список занятий `List` 157 | * Если структура данных `Lesson` не содержит каких-то важных данных, которые передаются вашим источником, то их можно передать в `additionalInfo` в виде человекочитаемой строки (пример есть в `IguimitGetLessons`). 158 | 159 | Возникновение ошибок: 160 | 161 | * `getSearchResults` и `getLessons` могут выдавать `null` при возникновении ошибки 162 | * Ошибка не должна произойти, если поисковый запрос не содержит расписаний, удовлетворяющих запросу - в этом случае необходимо вернуть пустой `List` 163 | * Аналогично, если занятий нет (на одной неделе, нескольких или сразу всех), то нужно выдавать пустой `List` 164 | * Если сервер выбрасывает ошибки даже при правильно сформированных запросах (например, выдаёт 404 при отсутствии расписания на неделе), то вы должны нормально это обрабатывать - поскольку это ожидаемое поведение сервера, вы должны просто проигнорировать такой ответ, и даже если сервер выдал только 404-ые, выдать не null, а пустой `List` 165 | * Важно: полностью рабочая интеграция не должна выдавать ни одной ошибки, они могут происходить только в исключительных случаях, то есть если HTML/API изменился и парсер сломался, либо если нет интернета 166 | 167 |
168 | 169 |
Получение данных 170 | 171 | Получение данных происходит в первом блоке try-catch - там требуется получить ответ сервера при помощи [Ktor Client](https://ktor.io/docs/client-requests.html). Полученные данные будут переданы второму блоку try-catch. Можно пользоваться примерами из соседних пакетов `irkutsk-igu-imit` и `irkutsk-irnitu`. 172 | 173 | В случае работы с расписанием, идеально, когда вы получаете информацию на текущую неделю, две предыдущие и две следующие. Итого, у пользователя будет до 5 учебных недель в календаре, чего более чем достаточно. Если сервер не предоставляет такое количество информации, требуется отобразить лишь доступную информацию. Например, если сайт предоставляет расписание только для чётной и нечётной группы, следует добавить расписание только на текущую и следующую недели - в остальные недели расписание может быть иным, мы не должны отображать потенциально неверное расписание. 174 | 175 |
176 | 177 |
Обработка данных 178 | 179 | Обработка данных происходит во втором блоке try-catch - там требуется обработать информацию при помощи `Ksoup` для HTML (используется синтаксис [Jsoup](https://jsoup.org)) или `kotlinx.serialization` для JSON, [XML, CBOR или ProtoBuf](https://ktor.io/docs/client-serialization.html#serialization_dependency). Можно пользоваться примерами из соседних пакетов `irkutsk-igu-imit` и `irkutsk-irnitu`. 180 | 181 | Для работы со временем используется [`kotlinx.datetime`](https://github.com/Kotlin/kotlinx-datetime/blob/master/README.md). 182 | 183 | На этом этапе ошибки могут произойти только в случае, если сервер возвращает неожиданный результат, который убивает ваш парсер. Это может произойти только спустя длительное время, если данные на сервере станут приходить в другом виде - ни одной ошибки не должно происходить на этапе тестирования вашего парсера. 184 | 185 | Парсеры должны быть написаны таким образом, чтобы максимально предотвращать ошибки при неожиданном ответе. Например, если сервер не прислал имя преподавателя, то идеально просто его не добавлять в Lesson. Если из-за отсутствующего имени преподавателя не формируется целое занятие и оно не будет показано пользователю, то это очень плохо, это необходимо исправлять. Если из-за отсутствующего имени преподавателя ломается парсинг и выдаётся ошибка (`null`), то это тоже очень плохо. 186 | 187 |
188 | 189 |
Тестирование интеграции 190 | 191 | 1. Протестируйте полученную интеграцию автоматически: найдите через поиск максимальное количество расписаний и нажмите на `Найти проблемы`. Прогресс отобразится в приложении, по необходимости его можно скопировать из IDE в панели Run (ПК/iOS) или LogCat (Android). Исправьте найденные ошибки обработки данных (пакет `datasource.network.chronus.yourcity-youruniversity`) и расширьте `LessonType` (пакет `model.chronus`). 192 | 1. Протестируйте полученную интеграцию вручную: для хорошего результата стоит проверить 7-15 расписаний **каждого** типа (групп, преподавателей, аудиторий). Тщательно сверьте наличие всех данных с данными, отображающимися на сайте. 193 | 194 |
195 | 196 | --- 197 | 198 | После реализации интеграции вернитесь к чтению README. Осталось совсем немного! 199 | -------------------------------------------------------------------------------- /appmodules/app/chronusparsers/ios/chronus parsers.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 79C0D4422B04058B0060E3BF /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C0D4412B04058B0060E3BF /* iOSApp.swift */; }; 11 | 79C0D4462B04058C0060E3BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 79C0D4452B04058C0060E3BF /* Assets.xcassets */; }; 12 | 79C0D44A2B04058C0060E3BF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 79C0D4492B04058C0060E3BF /* Preview Assets.xcassets */; }; 13 | 79C0D4542B04113E0060E3BF /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C0D4532B04113E0060E3BF /* RootView.swift */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | 79C0D43E2B04058B0060E3BF /* chronus parsers.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "chronus parsers.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 18 | 79C0D4412B04058B0060E3BF /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; 19 | 79C0D4452B04058C0060E3BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 20 | 79C0D4492B04058C0060E3BF /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 21 | 79C0D4532B04113E0060E3BF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 22 | /* End PBXFileReference section */ 23 | 24 | /* Begin PBXFrameworksBuildPhase section */ 25 | 79C0D43B2B04058B0060E3BF /* Frameworks */ = { 26 | isa = PBXFrameworksBuildPhase; 27 | buildActionMask = 2147483647; 28 | files = ( 29 | ); 30 | runOnlyForDeploymentPostprocessing = 0; 31 | }; 32 | /* End PBXFrameworksBuildPhase section */ 33 | 34 | /* Begin PBXGroup section */ 35 | 79C0D4352B04058B0060E3BF = { 36 | isa = PBXGroup; 37 | children = ( 38 | 79C0D4402B04058B0060E3BF /* app-ios-compose */, 39 | 79C0D43F2B04058B0060E3BF /* Products */, 40 | ); 41 | sourceTree = ""; 42 | }; 43 | 79C0D43F2B04058B0060E3BF /* Products */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | 79C0D43E2B04058B0060E3BF /* chronus parsers.app */, 47 | ); 48 | name = Products; 49 | sourceTree = ""; 50 | }; 51 | 79C0D4402B04058B0060E3BF /* app-ios-compose */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | 79C0D4412B04058B0060E3BF /* iOSApp.swift */, 55 | 79C0D4532B04113E0060E3BF /* RootView.swift */, 56 | 79C0D4452B04058C0060E3BF /* Assets.xcassets */, 57 | 79C0D4482B04058C0060E3BF /* Preview Content */, 58 | ); 59 | path = "app-ios-compose"; 60 | sourceTree = ""; 61 | }; 62 | 79C0D4482B04058C0060E3BF /* Preview Content */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | 79C0D4492B04058C0060E3BF /* Preview Assets.xcassets */, 66 | ); 67 | path = "Preview Content"; 68 | sourceTree = ""; 69 | }; 70 | /* End PBXGroup section */ 71 | 72 | /* Begin PBXNativeTarget section */ 73 | 79C0D43D2B04058B0060E3BF /* chronus parsers */ = { 74 | isa = PBXNativeTarget; 75 | buildConfigurationList = 79C0D44D2B04058C0060E3BF /* Build configuration list for PBXNativeTarget "chronus parsers" */; 76 | buildPhases = ( 77 | 79C0D4502B0407380060E3BF /* ShellScript */, 78 | 79C0D43A2B04058B0060E3BF /* Sources */, 79 | 79C0D43B2B04058B0060E3BF /* Frameworks */, 80 | 79C0D43C2B04058B0060E3BF /* Resources */, 81 | ); 82 | buildRules = ( 83 | ); 84 | dependencies = ( 85 | ); 86 | name = "chronus parsers"; 87 | productName = "app-ios-compose"; 88 | productReference = 79C0D43E2B04058B0060E3BF /* chronus parsers.app */; 89 | productType = "com.apple.product-type.application"; 90 | }; 91 | /* End PBXNativeTarget section */ 92 | 93 | /* Begin PBXProject section */ 94 | 79C0D4362B04058B0060E3BF /* Project object */ = { 95 | isa = PBXProject; 96 | attributes = { 97 | BuildIndependentTargetsInParallel = 1; 98 | LastSwiftUpdateCheck = 1430; 99 | LastUpgradeCheck = 1430; 100 | TargetAttributes = { 101 | 79C0D43D2B04058B0060E3BF = { 102 | CreatedOnToolsVersion = 14.3.1; 103 | }; 104 | }; 105 | }; 106 | buildConfigurationList = 79C0D4392B04058B0060E3BF /* Build configuration list for PBXProject "chronus parsers" */; 107 | compatibilityVersion = "Xcode 14.0"; 108 | developmentRegion = en; 109 | hasScannedForEncodings = 0; 110 | knownRegions = ( 111 | en, 112 | Base, 113 | ); 114 | mainGroup = 79C0D4352B04058B0060E3BF; 115 | productRefGroup = 79C0D43F2B04058B0060E3BF /* Products */; 116 | projectDirPath = ""; 117 | projectRoot = ""; 118 | targets = ( 119 | 79C0D43D2B04058B0060E3BF /* chronus parsers */, 120 | ); 121 | }; 122 | /* End PBXProject section */ 123 | 124 | /* Begin PBXResourcesBuildPhase section */ 125 | 79C0D43C2B04058B0060E3BF /* Resources */ = { 126 | isa = PBXResourcesBuildPhase; 127 | buildActionMask = 2147483647; 128 | files = ( 129 | 79C0D44A2B04058C0060E3BF /* Preview Assets.xcassets in Resources */, 130 | 79C0D4462B04058C0060E3BF /* Assets.xcassets in Resources */, 131 | ); 132 | runOnlyForDeploymentPostprocessing = 0; 133 | }; 134 | /* End PBXResourcesBuildPhase section */ 135 | 136 | /* Begin PBXShellScriptBuildPhase section */ 137 | 79C0D4502B0407380060E3BF /* ShellScript */ = { 138 | isa = PBXShellScriptBuildPhase; 139 | buildActionMask = 2147483647; 140 | files = ( 141 | ); 142 | inputFileListPaths = ( 143 | ); 144 | inputPaths = ( 145 | ); 146 | outputFileListPaths = ( 147 | ); 148 | outputPaths = ( 149 | ); 150 | runOnlyForDeploymentPostprocessing = 0; 151 | shellPath = /bin/sh; 152 | shellScript = "cd \"$SRCROOT/../../../..\"\n./gradlew :appmodules:shared:chronusparsers:embedAndSignAppleFrameworkForXcode\n"; 153 | }; 154 | /* End PBXShellScriptBuildPhase section */ 155 | 156 | /* Begin PBXSourcesBuildPhase section */ 157 | 79C0D43A2B04058B0060E3BF /* Sources */ = { 158 | isa = PBXSourcesBuildPhase; 159 | buildActionMask = 2147483647; 160 | files = ( 161 | 79C0D4542B04113E0060E3BF /* RootView.swift in Sources */, 162 | 79C0D4422B04058B0060E3BF /* iOSApp.swift in Sources */, 163 | ); 164 | runOnlyForDeploymentPostprocessing = 0; 165 | }; 166 | /* End PBXSourcesBuildPhase section */ 167 | 168 | /* Begin XCBuildConfiguration section */ 169 | 79C0D44B2B04058C0060E3BF /* Debug */ = { 170 | isa = XCBuildConfiguration; 171 | buildSettings = { 172 | ALWAYS_SEARCH_USER_PATHS = NO; 173 | CLANG_ANALYZER_NONNULL = YES; 174 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 175 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 176 | CLANG_ENABLE_MODULES = YES; 177 | CLANG_ENABLE_OBJC_ARC = YES; 178 | CLANG_ENABLE_OBJC_WEAK = YES; 179 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 180 | CLANG_WARN_BOOL_CONVERSION = YES; 181 | CLANG_WARN_COMMA = YES; 182 | CLANG_WARN_CONSTANT_CONVERSION = YES; 183 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 184 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 185 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 186 | CLANG_WARN_EMPTY_BODY = YES; 187 | CLANG_WARN_ENUM_CONVERSION = YES; 188 | CLANG_WARN_INFINITE_RECURSION = YES; 189 | CLANG_WARN_INT_CONVERSION = YES; 190 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 191 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 192 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 193 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 194 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 195 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 196 | CLANG_WARN_STRICT_PROTOTYPES = YES; 197 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 198 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 199 | CLANG_WARN_UNREACHABLE_CODE = YES; 200 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 201 | COPY_PHASE_STRIP = NO; 202 | DEBUG_INFORMATION_FORMAT = dwarf; 203 | ENABLE_STRICT_OBJC_MSGSEND = YES; 204 | ENABLE_TESTABILITY = YES; 205 | GCC_C_LANGUAGE_STANDARD = gnu11; 206 | GCC_DYNAMIC_NO_PIC = NO; 207 | GCC_NO_COMMON_BLOCKS = YES; 208 | GCC_OPTIMIZATION_LEVEL = 0; 209 | GCC_PREPROCESSOR_DEFINITIONS = ( 210 | "DEBUG=1", 211 | "$(inherited)", 212 | ); 213 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 214 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 215 | GCC_WARN_UNDECLARED_SELECTOR = YES; 216 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 217 | GCC_WARN_UNUSED_FUNCTION = YES; 218 | GCC_WARN_UNUSED_VARIABLE = YES; 219 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 220 | MTL_FAST_MATH = YES; 221 | ONLY_ACTIVE_ARCH = YES; 222 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 223 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 224 | }; 225 | name = Debug; 226 | }; 227 | 79C0D44C2B04058C0060E3BF /* Release */ = { 228 | isa = XCBuildConfiguration; 229 | buildSettings = { 230 | ALWAYS_SEARCH_USER_PATHS = NO; 231 | CLANG_ANALYZER_NONNULL = YES; 232 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 233 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 234 | CLANG_ENABLE_MODULES = YES; 235 | CLANG_ENABLE_OBJC_ARC = YES; 236 | CLANG_ENABLE_OBJC_WEAK = YES; 237 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 238 | CLANG_WARN_BOOL_CONVERSION = YES; 239 | CLANG_WARN_COMMA = YES; 240 | CLANG_WARN_CONSTANT_CONVERSION = YES; 241 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 242 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 243 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 244 | CLANG_WARN_EMPTY_BODY = YES; 245 | CLANG_WARN_ENUM_CONVERSION = YES; 246 | CLANG_WARN_INFINITE_RECURSION = YES; 247 | CLANG_WARN_INT_CONVERSION = YES; 248 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 249 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 250 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 251 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 252 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 253 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 254 | CLANG_WARN_STRICT_PROTOTYPES = YES; 255 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 256 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 257 | CLANG_WARN_UNREACHABLE_CODE = YES; 258 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 259 | COPY_PHASE_STRIP = NO; 260 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 261 | ENABLE_NS_ASSERTIONS = NO; 262 | ENABLE_STRICT_OBJC_MSGSEND = YES; 263 | GCC_C_LANGUAGE_STANDARD = gnu11; 264 | GCC_NO_COMMON_BLOCKS = YES; 265 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 266 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 267 | GCC_WARN_UNDECLARED_SELECTOR = YES; 268 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 269 | GCC_WARN_UNUSED_FUNCTION = YES; 270 | GCC_WARN_UNUSED_VARIABLE = YES; 271 | MTL_ENABLE_DEBUG_INFO = NO; 272 | MTL_FAST_MATH = YES; 273 | SWIFT_COMPILATION_MODE = wholemodule; 274 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 275 | }; 276 | name = Release; 277 | }; 278 | 79C0D44E2B04058C0060E3BF /* Debug */ = { 279 | isa = XCBuildConfiguration; 280 | buildSettings = { 281 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 282 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 283 | CODE_SIGN_ENTITLEMENTS = ""; 284 | CODE_SIGN_IDENTITY = "Apple Development"; 285 | CODE_SIGN_STYLE = Automatic; 286 | CURRENT_PROJECT_VERSION = 1; 287 | DEVELOPMENT_ASSET_PATHS = "\"app-ios-compose/Preview Content\""; 288 | DEVELOPMENT_TEAM = 8V3342PZWW; 289 | ENABLE_PREVIEWS = YES; 290 | FRAMEWORK_SEARCH_PATHS = "\"$(inherited) $(SRCROOT)/../../../shared/chronusparsers/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\""; 291 | GENERATE_INFOPLIST_FILE = YES; 292 | INFOPLIST_FILE = "app-ios-compose/Info.plist"; 293 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 294 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 295 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 296 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 297 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 298 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 299 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 300 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 301 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 302 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; 303 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 304 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 305 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 306 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 307 | MACOSX_DEPLOYMENT_TARGET = 13.3; 308 | MARKETING_VERSION = 1.0; 309 | OTHER_LDFLAGS = ( 310 | "$(inherited)", 311 | "-framework", 312 | shared, 313 | ); 314 | PRODUCT_BUNDLE_IDENTIFIER = mxkmnchronusparsers; 315 | PRODUCT_NAME = "$(TARGET_NAME)"; 316 | PROVISIONING_PROFILE_SPECIFIER = ""; 317 | SDKROOT = auto; 318 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 319 | SUPPORTS_MACCATALYST = NO; 320 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 321 | SWIFT_EMIT_LOC_STRINGS = YES; 322 | SWIFT_VERSION = 5.0; 323 | TARGETED_DEVICE_FAMILY = 1; 324 | }; 325 | name = Debug; 326 | }; 327 | 79C0D44F2B04058C0060E3BF /* Release */ = { 328 | isa = XCBuildConfiguration; 329 | buildSettings = { 330 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 331 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 332 | CODE_SIGN_ENTITLEMENTS = ""; 333 | CODE_SIGN_IDENTITY = "Apple Development"; 334 | CODE_SIGN_STYLE = Automatic; 335 | CURRENT_PROJECT_VERSION = 1; 336 | DEVELOPMENT_ASSET_PATHS = "\"app-ios-compose/Preview Content\""; 337 | DEVELOPMENT_TEAM = 8V3342PZWW; 338 | ENABLE_PREVIEWS = YES; 339 | FRAMEWORK_SEARCH_PATHS = "\"$(inherited) $(SRCROOT)/../../../shared/chronusparsers/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\""; 340 | GENERATE_INFOPLIST_FILE = YES; 341 | INFOPLIST_FILE = "app-ios-compose/Info.plist"; 342 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 343 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 344 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 345 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 346 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 347 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 348 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 349 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 350 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 351 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; 352 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 353 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 354 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 355 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 356 | MACOSX_DEPLOYMENT_TARGET = 13.3; 357 | MARKETING_VERSION = 1.0; 358 | OTHER_LDFLAGS = ( 359 | "$(inherited)", 360 | "-framework", 361 | shared, 362 | ); 363 | PRODUCT_BUNDLE_IDENTIFIER = mxkmnchronusparsers; 364 | PRODUCT_NAME = "$(TARGET_NAME)"; 365 | PROVISIONING_PROFILE_SPECIFIER = ""; 366 | SDKROOT = auto; 367 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 368 | SUPPORTS_MACCATALYST = NO; 369 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 370 | SWIFT_EMIT_LOC_STRINGS = YES; 371 | SWIFT_VERSION = 5.0; 372 | TARGETED_DEVICE_FAMILY = 1; 373 | }; 374 | name = Release; 375 | }; 376 | /* End XCBuildConfiguration section */ 377 | 378 | /* Begin XCConfigurationList section */ 379 | 79C0D4392B04058B0060E3BF /* Build configuration list for PBXProject "chronus parsers" */ = { 380 | isa = XCConfigurationList; 381 | buildConfigurations = ( 382 | 79C0D44B2B04058C0060E3BF /* Debug */, 383 | 79C0D44C2B04058C0060E3BF /* Release */, 384 | ); 385 | defaultConfigurationIsVisible = 0; 386 | defaultConfigurationName = Debug; 387 | }; 388 | 79C0D44D2B04058C0060E3BF /* Build configuration list for PBXNativeTarget "chronus parsers" */ = { 389 | isa = XCConfigurationList; 390 | buildConfigurations = ( 391 | 79C0D44E2B04058C0060E3BF /* Debug */, 392 | 79C0D44F2B04058C0060E3BF /* Release */, 393 | ); 394 | defaultConfigurationIsVisible = 0; 395 | defaultConfigurationName = Debug; 396 | }; 397 | /* End XCConfigurationList section */ 398 | }; 399 | rootObject = 79C0D4362B04058B0060E3BF /* Project object */; 400 | } 401 | --------------------------------------------------------------------------------