├── .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 |
4 |
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 |
5 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
5 |
6 |
7 |
8 |
11 |
16 |
17 |
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 | 
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 | 
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 | 
75 |
76 | После перезагрузите изначальную страницу с расписанием. Видно ли расписание после отключения JavaScript? Тогда оно передаётся прямо в HTML - в дальнейшем будем анализировать его.
77 |
78 | Если расписание не отобразилось, то мы видим не обычную веб-страницу, а веб-приложение, которое получает данные после дополнительного запроса к серверу. Нужно анализировать запросы - читайте об этом ниже.
79 |
80 |
81 |
82 | Анализ страницы при передаче данных в HTML
83 |
84 | HTML - это структура данных с иерархической структурой, то есть внутри элементов могут находиться другие вложенные элементы.
85 |
86 | Чтобы проанализировать передаваемые данные, достаточно в браузере нажать по элементу расписания правой кнопкой мыши и выбрать "Посмотреть код элемента" или "Проверить". Будет показано дерево элементов, с которыми нам предстоит работать.
87 |
88 | 
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 | 
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 |
--------------------------------------------------------------------------------