> =
110 | jobQueries.searchForContent("%$query%").asFlow()
111 | .mapToList(dispatcher)
112 | }
113 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://github.com/andremion/Jobster/actions/workflows/ci.yml)
4 |
5 |
6 |
7 | # Jobster
8 |
9 | A proof of concept of [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) targeting Android and iOS and using [Google Gemini API](https://ai.google.dev/).
10 |
11 |
12 |
13 |
18 |
19 |
20 |
21 |
22 |
23 | ### The app is almost 100% built with multiplatform code, including the UI.
24 |
25 | Android|iOS
26 | -|-
27 | |
28 |
29 |
30 |
31 | ## Gemini API
32 |
33 | This is the only one that is NOT a Multiplatform Kotlin library.
34 | There are two implementations, one for [Android](shared/data/src/androidMain/kotlin/io/github/andremion/jobster/data/remote/api/GeminiApiImpl.kt) and one for [iOS](iosApp/iosApp/data/GeminiApiImpl.swift).
35 |
36 | To use the Gemini API, you'll need an API key. If you don't already have one, create a key in Google AI Studio.
37 |
38 | [Get an API key](https://makersuite.google.com/app/apikey)
39 |
40 | There is one configuration for each platform:
41 |
42 | - **Android:**
43 | Add `geminiApiKey=YOUR_API_KEY` to your user's `gradle.properties` file
44 |
45 | - **iOS:**
46 | Update the [GeminiInfo.plist](iosApp/iosApp/data/GeminiInfo.plist) file with your API key
47 |
48 | ## Available regions
49 |
50 | The Gemini API is currently available in [180+ countries](https://ai.google.dev/available_regions#available_regions).
51 |
52 | ## TODO
53 | - Make the text strings multiplatform resources.
54 |
55 | ## Other multiplatform libraries used
56 |
57 | - [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform): A declarative framework based on Jetpack Compose and developed by JetBrains and open-source contributors for sharing UIs across multiple platforms with Kotlin.
58 | - [PreCompose](https://github.com/Tlaster/PreCompose): Supports navigation and view models providing similar APIs to Jetpack ones.
59 | - [Compottie](https://github.com/alexzhirkevich/compottie): A port of [Lottie Compose](https://github.com/airbnb/lottie/blob/master/android-compose.md).
60 | - [SQLdelight](https://github.com/cashapp/sqldelight): Generates typesafe Kotlin APIs from SQL statements.
61 | - [Koin](https://github.com/InsertKoinIO/koin): A pragmatic lightweight dependency injection framework.
62 | - [Ktor Client](https://github.com/ktorio/ktor): A library for fetching data from the internet. Written in Kotlin from the ground up.
63 | - [Ksoup](https://github.com/MohamedRejeb/Ksoup): A multiplatform library for parsing HTML and XML. It's a port of the renowned Java library, [jsoup](https://jsoup.org/).
64 |
65 | ## Credits
66 |
67 | Icon by Sorembadesignz on freeicons.io
68 |
69 | ## License
70 |
71 | Copyright 2024 André Luiz Oliveira Rêgo
72 |
73 | Licensed under the Apache License, Version 2.0 (the "License");
74 | you may not use this file except in compliance with the License.
75 | You may obtain a copy of the License at
76 |
77 | http://www.apache.org/licenses/LICENSE-2.0
78 |
79 | Unless required by applicable law or agreed to in writing, software
80 | distributed under the License is distributed on an "AS IS" BASIS,
81 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
82 | See the License for the specific language governing permissions and
83 | limitations under the License.
84 |
--------------------------------------------------------------------------------
/shared/ui/src/commonMain/kotlin/io/github/andremion/jobster/ui/contentlist/ContentItem.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024. André Luiz Oliveira Rêgo
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package io.github.andremion.jobster.ui.contentlist
18 |
19 | import androidx.compose.animation.AnimatedVisibility
20 | import androidx.compose.animation.core.AnimationConstants
21 | import androidx.compose.animation.core.tween
22 | import androidx.compose.animation.fadeOut
23 | import androidx.compose.animation.shrinkVertically
24 | import androidx.compose.foundation.background
25 | import androidx.compose.foundation.clickable
26 | import androidx.compose.foundation.layout.Arrangement
27 | import androidx.compose.foundation.layout.Box
28 | import androidx.compose.foundation.layout.Column
29 | import androidx.compose.foundation.layout.Row
30 | import androidx.compose.foundation.layout.fillMaxSize
31 | import androidx.compose.foundation.layout.padding
32 | import androidx.compose.material.icons.Icons
33 | import androidx.compose.material.icons.rounded.Delete
34 | import androidx.compose.material3.ExperimentalMaterial3Api
35 | import androidx.compose.material3.Icon
36 | import androidx.compose.material3.MaterialTheme
37 | import androidx.compose.material3.SwipeToDismissBox
38 | import androidx.compose.material3.SwipeToDismissBoxValue
39 | import androidx.compose.material3.Text
40 | import androidx.compose.material3.rememberSwipeToDismissBoxState
41 | import androidx.compose.runtime.Composable
42 | import androidx.compose.runtime.LaunchedEffect
43 | import androidx.compose.runtime.getValue
44 | import androidx.compose.runtime.mutableStateOf
45 | import androidx.compose.runtime.remember
46 | import androidx.compose.runtime.setValue
47 | import androidx.compose.ui.Alignment
48 | import androidx.compose.ui.Modifier
49 | import androidx.compose.ui.draw.alpha
50 | import androidx.compose.ui.graphics.Color
51 | import androidx.compose.ui.unit.dp
52 | import io.github.andremion.jobster.domain.entity.Job
53 | import kotlinx.coroutines.delay
54 |
55 | @OptIn(ExperimentalMaterial3Api::class)
56 | @Composable
57 | fun ContentItem(
58 | modifier: Modifier,
59 | content: Job.Content,
60 | onClick: () -> Unit,
61 | onSwipeToDelete: () -> Unit
62 | ) {
63 | var isDismissed by remember { mutableStateOf(false) }
64 | LaunchedEffect(isDismissed) {
65 | if (isDismissed) {
66 | delay(AnimationConstants.DefaultDurationMillis.toLong())
67 | onSwipeToDelete()
68 | }
69 | }
70 | val dismissState = rememberSwipeToDismissBoxState(
71 | confirmValueChange = { value ->
72 | if (value == SwipeToDismissBoxValue.EndToStart) {
73 | isDismissed = true
74 | true
75 | } else {
76 | false
77 | }
78 | }
79 | )
80 | AnimatedVisibility(
81 | visible = !isDismissed,
82 | exit = shrinkVertically(animationSpec = tween())
83 | + fadeOut(animationSpec = tween()),
84 | ) {
85 | SwipeToDismissBox(
86 | modifier = modifier,
87 | state = dismissState,
88 | backgroundContent = {
89 | val color = if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) {
90 | MaterialTheme.colorScheme.error
91 | } else {
92 | Color.Transparent
93 | }
94 | val alpha = if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) {
95 | 1f
96 | } else {
97 | 0f
98 | }
99 | Box(
100 | modifier = Modifier
101 | .fillMaxSize()
102 | .background(color = color)
103 | .alpha(alpha),
104 | contentAlignment = Alignment.CenterEnd
105 | ) {
106 | Icon(
107 | modifier = Modifier.padding(16.dp),
108 | imageVector = Icons.Rounded.Delete,
109 | contentDescription = "Delete",
110 | tint = MaterialTheme.colorScheme.onError
111 | )
112 | }
113 | },
114 | enableDismissFromStartToEnd = false
115 | ) {
116 | val color = if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) {
117 | MaterialTheme.colorScheme.background
118 | } else {
119 | Color.Transparent
120 | }
121 | Row(
122 | modifier = Modifier
123 | .background(color = color)
124 | .clickable { onClick() }
125 | .padding(
126 | horizontal = 16.dp,
127 | vertical = 8.dp
128 | ),
129 | verticalAlignment = Alignment.CenterVertically,
130 | ) {
131 | Column(
132 | modifier = Modifier.weight(1f),
133 | verticalArrangement = Arrangement.spacedBy(8.dp)
134 | ) {
135 | Text(
136 | text = content.title,
137 | style = MaterialTheme.typography.bodyLarge,
138 | )
139 | Text(
140 | text = content.description,
141 | style = MaterialTheme.typography.bodySmall
142 | )
143 | }
144 | }
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/shared/ui/src/commonMain/kotlin/io/github/andremion/jobster/ui/jobdetails/JobDetailsScreen.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024. André Luiz Oliveira Rêgo
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package io.github.andremion.jobster.ui.jobdetails
18 |
19 | import androidx.compose.foundation.ExperimentalFoundationApi
20 | import androidx.compose.foundation.layout.Column
21 | import androidx.compose.foundation.layout.PaddingValues
22 | import androidx.compose.foundation.layout.Row
23 | import androidx.compose.foundation.layout.Spacer
24 | import androidx.compose.foundation.layout.padding
25 | import androidx.compose.foundation.layout.size
26 | import androidx.compose.foundation.lazy.LazyColumn
27 | import androidx.compose.foundation.lazy.items
28 | import androidx.compose.material3.HorizontalDivider
29 | import androidx.compose.material3.MaterialTheme
30 | import androidx.compose.material3.Text
31 | import androidx.compose.runtime.Composable
32 | import androidx.compose.runtime.LaunchedEffect
33 | import androidx.compose.runtime.getValue
34 | import androidx.compose.ui.Modifier
35 | import androidx.compose.ui.unit.dp
36 | import io.github.andremion.jobster.domain.entity.Job
37 | import io.github.andremion.jobster.presentation.jobdetails.JobDetailsUiEffect
38 | import io.github.andremion.jobster.presentation.jobdetails.JobDetailsUiEvent
39 | import io.github.andremion.jobster.presentation.jobdetails.JobDetailsUiState
40 | import io.github.andremion.jobster.presentation.jobdetails.JobDetailsViewModel
41 | import io.github.andremion.jobster.ui.contentlist.ContentItem
42 | import kotlinx.coroutines.flow.launchIn
43 | import kotlinx.coroutines.flow.onEach
44 | import moe.tlaster.precompose.flow.collectAsStateWithLifecycle
45 | import moe.tlaster.precompose.koin.koinViewModel
46 | import org.koin.core.parameter.parametersOf
47 |
48 | @Composable
49 | fun JobDetailsScreen(
50 | jobId: String,
51 | onNavigateToUrl: (url: String) -> Unit,
52 | ) {
53 | val viewModel = koinViewModel(JobDetailsViewModel::class) {
54 | parametersOf(jobId)
55 | }
56 |
57 | val uiState by viewModel.uiState.collectAsStateWithLifecycle()
58 |
59 | ScreenContent(
60 | uiState = uiState,
61 | onUiEvent = viewModel::onUiEvent,
62 | )
63 |
64 | LaunchedEffect(viewModel) {
65 | viewModel.uiEffect.onEach { uiEffect ->
66 | when (uiEffect) {
67 | is JobDetailsUiEffect.NavigateToUrl -> {
68 | onNavigateToUrl(uiEffect.url)
69 | }
70 | }
71 | }.launchIn(this)
72 | }
73 | }
74 |
75 | @OptIn(ExperimentalFoundationApi::class)
76 | @Composable
77 | private fun ScreenContent(
78 | uiState: JobDetailsUiState,
79 | onUiEvent: (JobDetailsUiEvent) -> Unit,
80 | ) {
81 | uiState.job?.let { job ->
82 | LazyColumn(
83 | contentPadding = PaddingValues(vertical = 16.dp),
84 | ) {
85 | item {
86 | Row(
87 | modifier = Modifier.padding(horizontal = 16.dp),
88 | ) {
89 | Column {
90 | Text(
91 | text = job.title,
92 | style = MaterialTheme.typography.headlineMedium,
93 | )
94 | Text(
95 | text = job.company,
96 | style = MaterialTheme.typography.headlineSmall,
97 | )
98 | }
99 | }
100 | Spacer(modifier = Modifier.size(8.dp))
101 | HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
102 | }
103 | items(
104 | items = job.contents,
105 | key = Job.Content::id
106 | ) { content ->
107 | ContentItem(
108 | modifier = Modifier.animateItemPlacement(),
109 | content = content,
110 | onClick = { onUiEvent(JobDetailsUiEvent.ContentClick(content.url)) },
111 | onSwipeToDelete = { onUiEvent(JobDetailsUiEvent.DeleteContent(content.id)) },
112 | )
113 | }
114 | }
115 | }
116 | }
117 |
118 | //@Preview(showBackground = true)
119 | //@Composable
120 | //private fun ScreenContentPreview() {
121 | // ScreenContent(
122 | // uiState = JobDetailsUiState.Initial.copy(
123 | // job = Job(
124 | // id = "1",
125 | // title = "Android Developer",
126 | // company = "Google",
127 | // url = "https://google.com",
128 | // logo = "https://google.com/logo.png",
129 | // contents = listOf(
130 | // Job.Content(
131 | // id = "1",
132 | // title = "Content 1",
133 | // description = "Content 1 description",
134 | // url = "https://google.com",
135 | // image = "https://google.com/logo.png",
136 | // ),
137 | // Job.Content(
138 | // id = "2",
139 | // title = "Content 2",
140 | // description = "Content 2 description",
141 | // url = "https://google.com",
142 | // image = "https://google.com/logo.png",
143 | // ),
144 | // Job.Content(
145 | // id = "3",
146 | // title = "Content 3",
147 | // description = "Content 3 description",
148 | // url = "https://google.com",
149 | // image = "https://google.com/logo.png",
150 | // ),
151 | // )
152 | // )
153 | // ),
154 | // onUiEvent = {}
155 | // )
156 | //}
157 |
--------------------------------------------------------------------------------
/shared/ui/src/commonMain/kotlin/io/github/andremion/jobster/ui/joblist/JobListScreen.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024. André Luiz Oliveira Rêgo
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package io.github.andremion.jobster.ui.joblist
18 |
19 | import androidx.compose.foundation.clickable
20 | import androidx.compose.foundation.layout.Arrangement
21 | import androidx.compose.foundation.layout.Column
22 | import androidx.compose.foundation.layout.PaddingValues
23 | import androidx.compose.foundation.layout.fillMaxSize
24 | import androidx.compose.foundation.layout.fillMaxWidth
25 | import androidx.compose.foundation.layout.padding
26 | import androidx.compose.foundation.lazy.LazyColumn
27 | import androidx.compose.foundation.lazy.items
28 | import androidx.compose.foundation.shape.RoundedCornerShape
29 | import androidx.compose.material3.Card
30 | import androidx.compose.material3.CardDefaults
31 | import androidx.compose.material3.MaterialTheme
32 | import androidx.compose.material3.Text
33 | import androidx.compose.runtime.Composable
34 | import androidx.compose.runtime.LaunchedEffect
35 | import androidx.compose.runtime.getValue
36 | import androidx.compose.ui.Modifier
37 | import androidx.compose.ui.text.style.TextOverflow
38 | import androidx.compose.ui.unit.dp
39 | import io.github.andremion.jobster.presentation.joblist.JobListUiEffect
40 | import io.github.andremion.jobster.presentation.joblist.JobListUiEvent
41 | import io.github.andremion.jobster.presentation.joblist.JobListUiState
42 | import io.github.andremion.jobster.presentation.joblist.JobListViewModel
43 | import kotlinx.coroutines.flow.launchIn
44 | import kotlinx.coroutines.flow.onEach
45 | import moe.tlaster.precompose.flow.collectAsStateWithLifecycle
46 | import moe.tlaster.precompose.koin.koinViewModel
47 |
48 | @Composable
49 | fun JobListScreen(
50 | onNavigateToJobDetails: (jobId: String) -> Unit,
51 | ) {
52 | val viewModel = koinViewModel(JobListViewModel::class)
53 |
54 | val uiState by viewModel.uiState.collectAsStateWithLifecycle()
55 |
56 | ScreenContent(
57 | uiState = uiState,
58 | onUiEvent = viewModel::onUiEvent,
59 | )
60 |
61 | LaunchedEffect(viewModel) {
62 | viewModel.uiEffect.onEach { uiEffect ->
63 | when (uiEffect) {
64 | is JobListUiEffect.NavigateToJobDetails -> {
65 | onNavigateToJobDetails(uiEffect.jobId)
66 | }
67 | }
68 | }.launchIn(this)
69 | }
70 | }
71 |
72 | @Composable
73 | private fun ScreenContent(
74 | uiState: JobListUiState,
75 | onUiEvent: (JobListUiEvent) -> Unit,
76 | ) {
77 | uiState.jobs?.let { items ->
78 | LazyColumn(
79 | modifier = Modifier
80 | .fillMaxSize(),
81 | contentPadding = PaddingValues(16.dp),
82 | verticalArrangement = Arrangement.spacedBy(16.dp)
83 | ) {
84 | items(
85 | items = items,
86 | key = JobListUiState.Job::id
87 | ) { job ->
88 | Card(
89 | shape = RoundedCornerShape(
90 | topEnd = CardCornerSize,
91 | bottomStart = CardCornerSize
92 | ),
93 | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
94 | ) {
95 | Column(
96 | modifier = Modifier
97 | .clickable { onUiEvent(JobListUiEvent.JobClick(job.id)) }
98 | .padding(16.dp)
99 | .fillMaxWidth(),
100 | verticalArrangement = Arrangement.spacedBy(8.dp)
101 | ) {
102 | Text(
103 | text = job.title,
104 | style = MaterialTheme.typography.bodyLarge,
105 | )
106 | Text(
107 | text = job.company,
108 | style = MaterialTheme.typography.bodySmall
109 | )
110 | job.content?.let { contents ->
111 | Text(
112 | text = contents,
113 | style = MaterialTheme.typography.bodyMedium,
114 | maxLines = 2,
115 | overflow = TextOverflow.Ellipsis,
116 | )
117 | }
118 | }
119 | }
120 | }
121 | }
122 | }
123 | }
124 |
125 | private val CardCornerSize = 16.dp
126 |
127 | //@Preview(showBackground = true)
128 | //@Composable
129 | //private fun ScreenContentPreview() {
130 | // ScreenContent(
131 | // uiState = JobListUiState.Initial.copy(
132 | // jobs = listOf(
133 | // JobListUiState.Job(
134 | // id = "1",
135 | // title = "Job title 1",
136 | // company = "Company name 1",
137 | // url = "https://www.google.com",
138 | // logo = "https://www.google.com",
139 | // contents = "Content title 1, Content title 2",
140 | // ),
141 | // JobListUiState.Job(
142 | // id = "2",
143 | // title = "Job title 2",
144 | // company = "Company name 2",
145 | // url = "https://www.google.com",
146 | // logo = "https://www.google.com",
147 | // contents = "Content title 3, Content title 4, Content title 5, Content title 6, " +
148 | // "Content title 7, Content title 8, Content title 9",
149 | // ),
150 | // ),
151 | // ),
152 | // onUiEvent = {},
153 | // )
154 | //}
155 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or 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 UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/shared/ui/src/commonMain/composeResources/drawable/ic_gemini.xml:
--------------------------------------------------------------------------------
1 |
3 |
5 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
15 |
16 |
18 |
19 |
20 |
21 |
22 |
23 |
25 |
26 |
28 |
29 |
30 |
31 |
32 |
33 |
35 |
36 |
38 |
39 |
40 |
41 |
42 |
43 |
45 |
46 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
56 |
57 |
59 |
60 |
61 |
62 |
63 |
64 |
66 |
67 |
--------------------------------------------------------------------------------
/docs/privacy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Privacy Policy
7 |
8 |
9 |
10 | Privacy Policy
11 |
12 | André Luiz Oliveira Rêgo built the Jobster app as
13 | a Free app. This SERVICE is provided by
14 | André Luiz Oliveira Rêgo at no cost and is intended for use as
15 | is.
16 |
17 |
18 | This page is used to inform visitors regarding my
19 | policies with the collection, use, and disclosure of Personal
20 | Information if anyone decided to use my Service.
21 |
22 |
23 | If you choose to use my Service, then you agree to
24 | the collection and use of information in relation to this
25 | policy. The Personal Information that I collect is
26 | used for providing and improving the Service. I will not use or share your information with
27 | anyone except as described in this Privacy Policy.
28 |
29 |
30 | The terms used in this Privacy Policy have the same meanings
31 | as in our Terms and Conditions, which are accessible at
32 | Jobster unless otherwise defined in this Privacy Policy.
33 |
34 | Information Collection and Use
35 |
36 | For a better experience, while using our Service, I
37 | may require you to provide us with certain personally
38 | identifiable information. The information that
39 | I request will be retained on your device and is not collected by me in any way.
40 |
41 |
42 | The app does use third-party services that may collect
43 | information used to identify you.
44 |
45 |
46 | Link to the privacy policy of third-party service providers used
47 | by the app
48 |
49 |
53 |
54 | Log Data
55 |
56 | I want to inform you that whenever you
57 | use my Service, in a case of an error in the app
58 | I collect data and information (through third-party
59 | products) on your phone called Log Data. This Log Data may
60 | include information such as your device Internet Protocol
61 | (“IP”) address, device name, operating system version, the
62 | configuration of the app when utilizing my Service,
63 | the time and date of your use of the Service, and other
64 | statistics.
65 |
66 | Cookies
67 |
68 | Cookies are files with a small amount of data that are
69 | commonly used as anonymous unique identifiers. These are sent
70 | to your browser from the websites that you visit and are
71 | stored on your device's internal memory.
72 |
73 |
74 | This Service does not use these “cookies” explicitly. However,
75 | the app may use third-party code and libraries that use
76 | “cookies” to collect information and improve their services.
77 | You have the option to either accept or refuse these cookies
78 | and know when a cookie is being sent to your device. If you
79 | choose to refuse our cookies, you may not be able to use some
80 | portions of this Service.
81 |
82 | Service Providers
83 |
84 | I may employ third-party companies and
85 | individuals due to the following reasons:
86 |
87 |
88 | - To facilitate our Service;
89 | - To provide the Service on our behalf;
90 | - To perform Service-related services; or
91 | - To assist us in analyzing how our Service is used.
92 |
93 |
94 | I want to inform users of this Service
95 | that these third parties have access to their Personal
96 | Information. The reason is to perform the tasks assigned to
97 | them on our behalf. However, they are obligated not to
98 | disclose or use the information for any other purpose.
99 |
100 | Security
101 |
102 | I value your trust in providing us your
103 | Personal Information, thus we are striving to use commercially
104 | acceptable means of protecting it. But remember that no method
105 | of transmission over the internet, or method of electronic
106 | storage is 100% secure and reliable, and I cannot
107 | guarantee its absolute security.
108 |
109 | Links to Other Sites
110 |
111 | This Service may contain links to other sites. If you click on
112 | a third-party link, you will be directed to that site. Note
113 | that these external sites are not operated by me.
114 | Therefore, I strongly advise you to review the
115 | Privacy Policy of these websites. I have
116 | no control over and assume no responsibility for the content,
117 | privacy policies, or practices of any third-party sites or
118 | services.
119 |
120 | Children’s Privacy
121 |
122 | These Services do not address anyone under the age of 13.
123 | I do not knowingly collect personally
124 | identifiable information from children under 13 years of age. In the case
125 | I discover that a child under 13 has provided
126 | me with personal information, I immediately
127 | delete this from our servers. If you are a parent or guardian
128 | and you are aware that your child has provided us with
129 | personal information, please contact me so that
130 | I will be able to do the necessary actions.
131 |
Changes to This Privacy Policy
132 |
133 | I may update our Privacy Policy from
134 | time to time. Thus, you are advised to review this page
135 | periodically for any changes. I will
136 | notify you of any changes by posting the new Privacy Policy on
137 | this page.
138 |
139 | This policy is effective as of 2024-01-18
140 | Contact Us
141 |
142 | If you have any questions or suggestions about my
143 | Privacy Policy, do not hesitate to contact me at andremion@gmail.com.
144 |
145 | This privacy policy page was created at privacypolicytemplate.net and
147 | modified/generated by App Privacy Policy Generator
149 |
150 |
151 |
--------------------------------------------------------------------------------
/shared/presentation/src/commonMain/kotlin/io/github/andremion/jobster/presentation/jobpostingsearch/JobPostingSearchViewModel.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024. André Luiz Oliveira Rêgo
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package io.github.andremion.jobster.presentation.jobpostingsearch
18 |
19 | import io.github.andremion.jobster.domain.JobRepository
20 | import io.github.andremion.jobster.domain.entity.Job
21 | import io.github.andremion.jobster.domain.entity.JobPosting
22 | import io.github.andremion.jobster.domain.exception.JobPostingSearchException
23 | import io.github.andremion.jobster.presentation.AbsViewModel
24 | import io.github.andremion.jobster.presentation.WhileSubscribed
25 | import io.github.andremion.jobster.presentation.jobpostingsearch.mapper.transform
26 | import kotlinx.coroutines.flow.MutableStateFlow
27 | import kotlinx.coroutines.flow.StateFlow
28 | import kotlinx.coroutines.flow.firstOrNull
29 | import kotlinx.coroutines.flow.stateIn
30 | import kotlinx.coroutines.flow.update
31 | import kotlinx.coroutines.launch
32 | import moe.tlaster.precompose.viewmodel.viewModelScope
33 |
34 | class JobPostingSearchViewModel(
35 | private val jobRepository: JobRepository,
36 | ) : AbsViewModel() {
37 |
38 | private var searchJob: kotlinx.coroutines.Job? = null
39 |
40 | private val mutableUiState = MutableStateFlow(JobPostingSearchUiState())
41 |
42 | override val uiState: StateFlow = mutableUiState
43 | .stateIn(
44 | scope = viewModelScope,
45 | started = WhileSubscribed,
46 | initialValue = JobPostingSearchUiState(isSearchBarActive = true)
47 | )
48 |
49 | override fun onUiEvent(uiEvent: JobPostingSearchUiEvent) {
50 | when (uiEvent) {
51 | is JobPostingSearchUiEvent.BackClick -> {
52 | mutableUiEffect.tryEmit(JobPostingSearchUiEffect.NavigateBack)
53 | }
54 |
55 | is JobPostingSearchUiEvent.UpdateUrl -> {
56 | searchJob?.cancel()
57 | mutableUiState.update { uiState ->
58 | uiState.copy(
59 | url = uiEvent.url,
60 | isLoading = false,
61 | error = null,
62 | )
63 | }
64 | }
65 |
66 | is JobPostingSearchUiEvent.UpdateSearchBarActive -> {
67 | searchJob?.cancel()
68 | mutableUiState.update { uiState ->
69 | uiState.copy(
70 | isSearchBarActive = uiEvent.isActive,
71 | isLoading = false,
72 | error = null,
73 | )
74 | }
75 | }
76 |
77 | is JobPostingSearchUiEvent.SearchBarBackClick -> {
78 | searchJob?.cancel()
79 | mutableUiState.update { uiState ->
80 | uiState.copy(
81 | isSearchBarActive = false,
82 | isLoading = false,
83 | error = null,
84 | )
85 | }
86 | }
87 |
88 | is JobPostingSearchUiEvent.SearchBarClearClick -> {
89 | searchJob?.cancel()
90 | mutableUiState.update { uiState ->
91 | uiState.copy(
92 | url = "",
93 | isLoading = false,
94 | error = null,
95 | )
96 | }
97 | }
98 |
99 | is JobPostingSearchUiEvent.SearchClick -> {
100 | val url = uiState.value.url
101 | if (url.isNotBlank()) {
102 | mutableUiState.update { uiState ->
103 | uiState.copy(
104 | isLoading = true,
105 | jobPosting = null,
106 | error = null,
107 | )
108 | }
109 | searchJob?.cancel()
110 | searchJob = viewModelScope.launch {
111 | runCatching {
112 | val jobPosting = jobRepository.searchJobPosting(url)
113 | val contentIds = jobPosting.contents.map(JobPosting.Content::url)
114 | val existingContentIds = jobRepository.getContentsByIds(contentIds)
115 | .firstOrNull()?.map(Job.Content::id) ?: emptyList()
116 | jobPosting.transform(existingContentIds)
117 | }.onSuccess { jobPosting ->
118 | mutableUiState.update { uiState ->
119 | uiState.copy(
120 | isSearchBarActive = false,
121 | isLoading = false,
122 | jobPosting = jobPosting,
123 | )
124 | }
125 | }.onFailure { cause ->
126 | cause.printStackTrace()
127 | mutableUiState.update { uiState ->
128 | uiState.copy(
129 | isLoading = false,
130 | error = cause as? JobPostingSearchException,
131 | )
132 | }
133 | }
134 | }
135 | }
136 | }
137 |
138 | is JobPostingSearchUiEvent.ContentTitleClick -> {
139 | mutableUiEffect.tryEmit(JobPostingSearchUiEffect.NavigateToUrl(uiEvent.url))
140 | }
141 |
142 | is JobPostingSearchUiEvent.ContentSwitchClick -> {
143 | viewModelScope.launch {
144 | val state = uiState.value
145 | val jobPosting = requireNotNull(state.jobPosting)
146 | if (uiEvent.isChecked) {
147 | jobRepository.save(
148 | jobPosting = jobPosting.transform(state.url),
149 | contents = listOf(uiEvent.content.transform())
150 | )
151 | } else {
152 | jobRepository.delete(
153 | jobId = state.url,
154 | contentId = uiEvent.content.url
155 | )
156 | }
157 | mutableUiState.update { uiState ->
158 | uiState.copy(
159 | jobPosting = jobPosting.copy(
160 | contents = jobPosting.contents.map { content ->
161 | if (content.url == uiEvent.content.url) {
162 | content.copy(isChecked = uiEvent.isChecked)
163 | } else {
164 | content
165 | }
166 | }
167 | )
168 | )
169 | }
170 | }
171 | }
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
16 |
19 |
24 |
27 |
30 |
35 |
38 |
41 |
46 |
49 |
54 |
59 |
62 |
65 |
68 |
71 |
74 |
77 |
80 |
81 |
82 |
--------------------------------------------------------------------------------