Skip to content

Commit 355908b

Browse files
committed
Feat: 제보하기 화면 구현
- 제보하기 화면의 UI 및 기본 로직을 구현합니다.
1 parent 3852d66 commit 355908b

4 files changed

Lines changed: 436 additions & 0 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
xmlns:tools="http://schemas.android.com/tools">
44

55
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.CAMERA" />
7+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
8+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
9+
10+
<uses-feature android:name="android.hardware.camera" android:required="true" />
611

712
<application
813
android:name=".BitnagilApplication"
@@ -41,5 +46,15 @@
4146
android:scheme="kakao${KAKAO_NATIVE_APP_KEY}" />
4247
</intent-filter>
4348
</activity>
49+
50+
<provider
51+
android:name="androidx.core.content.FileProvider"
52+
android:authorities="${applicationId}.provider"
53+
android:exported="false"
54+
android:grantUriPermissions="true">
55+
<meta-data
56+
android:name="android.support.FILE_PROVIDER_PATHS"
57+
android:resource="@xml/file_paths" />
58+
</provider>
4459
</application>
4560
</manifest>
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
package com.threegap.bitnagil.presentation.report
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.net.Uri
6+
import androidx.activity.compose.rememberLauncherForActivityResult
7+
import androidx.activity.result.PickVisualMediaRequest
8+
import androidx.activity.result.contract.ActivityResultContracts
9+
import androidx.compose.foundation.layout.Arrangement
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.PaddingValues
12+
import androidx.compose.foundation.layout.Row
13+
import androidx.compose.foundation.layout.WindowInsets
14+
import androidx.compose.foundation.layout.fillMaxSize
15+
import androidx.compose.foundation.layout.fillMaxWidth
16+
import androidx.compose.foundation.layout.height
17+
import androidx.compose.foundation.layout.ime
18+
import androidx.compose.foundation.layout.padding
19+
import androidx.compose.foundation.layout.statusBarsPadding
20+
import androidx.compose.foundation.layout.windowInsetsPadding
21+
import androidx.compose.foundation.lazy.LazyRow
22+
import androidx.compose.foundation.lazy.items
23+
import androidx.compose.foundation.rememberScrollState
24+
import androidx.compose.foundation.verticalScroll
25+
import androidx.compose.material3.ExperimentalMaterial3Api
26+
import androidx.compose.material3.Text
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableStateOf
30+
import androidx.compose.runtime.remember
31+
import androidx.compose.runtime.setValue
32+
import androidx.compose.ui.Alignment
33+
import androidx.compose.ui.Modifier
34+
import androidx.compose.ui.platform.LocalContext
35+
import androidx.compose.ui.text.style.TextAlign
36+
import androidx.compose.ui.tooling.preview.Preview
37+
import androidx.compose.ui.unit.dp
38+
import androidx.core.content.FileProvider
39+
import androidx.hilt.navigation.compose.hiltViewModel
40+
import com.google.accompanist.permissions.ExperimentalPermissionsApi
41+
import com.threegap.bitnagil.designsystem.BitnagilTheme
42+
import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButton
43+
import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor
44+
import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextField
45+
import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar
46+
import com.threegap.bitnagil.presentation.common.premission.rememberPermissionHandler
47+
import com.threegap.bitnagil.presentation.report.component.AddPhotoButton
48+
import com.threegap.bitnagil.presentation.report.component.CurrentLocationInput
49+
import com.threegap.bitnagil.presentation.report.component.ImageSourceBottomSheet
50+
import com.threegap.bitnagil.presentation.report.component.PhotoItem
51+
import com.threegap.bitnagil.presentation.report.component.ReportCategoryBottomSheet
52+
import com.threegap.bitnagil.presentation.report.component.ReportCategorySelector
53+
import com.threegap.bitnagil.presentation.report.component.ReportField
54+
import org.orbitmvi.orbit.compose.collectAsState
55+
import org.orbitmvi.orbit.compose.collectSideEffect
56+
import java.io.File
57+
58+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
59+
@Composable
60+
fun ReportScreenContainer(
61+
navigateToBack: () -> Unit,
62+
viewModel: ReportViewModel = hiltViewModel(),
63+
) {
64+
val context = LocalContext.current
65+
val uiState by viewModel.collectAsState()
66+
67+
viewModel.collectSideEffect { sideEffect ->
68+
when (sideEffect) {
69+
is ReportSideEffect.NavigateToBack -> navigateToBack()
70+
}
71+
}
72+
73+
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) }
74+
75+
val pickMultipleMediaLauncher = rememberLauncherForActivityResult(
76+
contract = ActivityResultContracts.PickMultipleVisualMedia(ReportViewModel.MAX_IMAGE_COUNT),
77+
onResult = viewModel::addImages,
78+
)
79+
80+
val takePictureLauncher = rememberLauncherForActivityResult(
81+
contract = ActivityResultContracts.TakePicture(),
82+
onResult = { success ->
83+
if (success) {
84+
pendingCameraPhotoUri?.let { uri ->
85+
viewModel.addImages(listOf(uri))
86+
}
87+
}
88+
pendingCameraPhotoUri = null
89+
},
90+
)
91+
92+
fun createImageUri(context: Context): Uri {
93+
val cameraDir = File(context.cacheDir, "camera").apply { if (!exists()) mkdirs() }
94+
val imageFile = File(cameraDir, "camera_${System.currentTimeMillis()}.jpg")
95+
return FileProvider.getUriForFile(context, "${context.packageName}.provider", imageFile)
96+
}
97+
98+
val cameraPermissionHandler = rememberPermissionHandler(
99+
permission = Manifest.permission.CAMERA,
100+
dialogDescription = "카메라 권한이 비활성화됐어요.\n설정에서 허용해 주세요.",
101+
onGranted = {
102+
val imageUri = createImageUri(context)
103+
pendingCameraPhotoUri = imageUri
104+
takePictureLauncher.launch(imageUri)
105+
},
106+
)
107+
108+
val locationPermissionHandler = rememberPermissionHandler(
109+
permission = Manifest.permission.ACCESS_FINE_LOCATION,
110+
dialogDescription = "위치 권한이 비활성화됐어요.\n설정에서 허용해 주세요.",
111+
onGranted = viewModel::fetchCurrentAddress,
112+
)
113+
114+
cameraPermissionHandler.PermissionDialogs()
115+
locationPermissionHandler.PermissionDialogs()
116+
117+
if (uiState.imageSourceBottomSheetVisible) {
118+
ImageSourceBottomSheet(
119+
onCameraClick = {
120+
if (uiState.canAddMoreImages) cameraPermissionHandler.requestPermission()
121+
},
122+
onAlbumClick = {
123+
if (uiState.canAddMoreImages) {
124+
pickMultipleMediaLauncher.launch(
125+
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
126+
)
127+
}
128+
},
129+
onDismiss = viewModel::hideImageSourceBottomSheet,
130+
)
131+
}
132+
133+
if (uiState.reportCategoryBottomSheetVisible) {
134+
ReportCategoryBottomSheet(
135+
selectedCategory = uiState.selectedCategory,
136+
onSelected = viewModel::selectReportCategory,
137+
onDismiss = viewModel::hideReportCategoryBottomSheet,
138+
)
139+
}
140+
141+
ReportScreen(
142+
uiState = uiState,
143+
onReportTitle = viewModel::updateReportTitle,
144+
onReportDescriptionChange = viewModel::updateReportDescription,
145+
onShowImageSourceBottomSheet = viewModel::showImageSourceBottomSheet,
146+
onShowReportCategoryBottomSheet = viewModel::showReportCategoryBottomSheet,
147+
onRemoveImage = viewModel::removeImage,
148+
onGetCurrentLocationClick = locationPermissionHandler::requestPermission,
149+
onBackClick = viewModel::navigateToBack,
150+
)
151+
}
152+
153+
@OptIn(ExperimentalMaterial3Api::class)
154+
@Composable
155+
private fun ReportScreen(
156+
uiState: ReportState,
157+
onReportTitle: (String) -> Unit,
158+
onReportDescriptionChange: (String) -> Unit,
159+
onShowImageSourceBottomSheet: () -> Unit,
160+
onShowReportCategoryBottomSheet: () -> Unit,
161+
onRemoveImage: (Uri) -> Unit,
162+
onGetCurrentLocationClick: () -> Unit,
163+
onBackClick: () -> Unit,
164+
) {
165+
val scrollState = rememberScrollState()
166+
167+
Column(
168+
modifier = Modifier
169+
.fillMaxSize()
170+
.statusBarsPadding()
171+
.windowInsetsPadding(WindowInsets.ime),
172+
) {
173+
BitnagilTopBar(
174+
title = "제보하기",
175+
showBackButton = true,
176+
onBackClick = onBackClick,
177+
)
178+
179+
Column(
180+
modifier = Modifier
181+
.weight(1f)
182+
.verticalScroll(state = scrollState)
183+
.padding(top = 32.dp)
184+
.padding(horizontal = 16.dp),
185+
verticalArrangement = Arrangement.spacedBy(28.dp),
186+
) {
187+
ReportField(title = "사진 첨부") {
188+
Row(
189+
verticalAlignment = Alignment.Bottom,
190+
) {
191+
AddPhotoButton(
192+
onClick = onShowImageSourceBottomSheet,
193+
imageCount = uiState.reportImages.size,
194+
maxImageCount = ReportViewModel.MAX_IMAGE_COUNT,
195+
)
196+
197+
LazyRow(
198+
modifier = Modifier.weight(1f),
199+
horizontalArrangement = Arrangement.spacedBy(14.dp),
200+
contentPadding = PaddingValues(horizontal = 14.dp),
201+
) {
202+
items(uiState.reportImages) { uri ->
203+
PhotoItem(
204+
uri = uri,
205+
onRemove = onRemoveImage,
206+
)
207+
}
208+
}
209+
}
210+
}
211+
212+
ReportField(title = "제목") {
213+
BitnagilTextField(
214+
value = uiState.reportTitle,
215+
onValueChange = onReportTitle,
216+
singleLine = true,
217+
placeholder = {
218+
Text(
219+
text = "제보 제목을 작성해주세요.",
220+
style = BitnagilTheme.typography.body2Medium,
221+
color = BitnagilTheme.colors.coolGray80,
222+
)
223+
},
224+
)
225+
}
226+
227+
ReportField(title = "카테고리") {
228+
ReportCategorySelector(
229+
title = uiState.selectedCategory?.title,
230+
onClick = onShowReportCategoryBottomSheet,
231+
)
232+
}
233+
234+
ReportField(title = "상세 제보 내용") {
235+
BitnagilTextField(
236+
value = uiState.reportDescription,
237+
onValueChange = onReportDescriptionChange,
238+
modifier = Modifier.height(88.dp),
239+
placeholder = {
240+
Text(
241+
text = "어떤 위험인지 간단히 설명해주세요.(100자 내외)",
242+
style = BitnagilTheme.typography.body2Medium,
243+
color = BitnagilTheme.colors.coolGray80,
244+
)
245+
},
246+
)
247+
248+
Text(
249+
text = "${uiState.reportDescription.length} / 150",
250+
style = BitnagilTheme.typography.caption1Medium,
251+
color = BitnagilTheme.colors.coolGray80,
252+
textAlign = TextAlign.End,
253+
modifier = Modifier.fillMaxWidth(),
254+
)
255+
}
256+
257+
ReportField(title = " 신고 위치") {
258+
CurrentLocationInput(
259+
currentLocation = uiState.currentAddress,
260+
onClick = onGetCurrentLocationClick,
261+
)
262+
}
263+
}
264+
265+
BitnagilTextButton(
266+
text = "제보하기",
267+
onClick = {},
268+
colors = BitnagilTextButtonColor.default(
269+
disabledBackgroundColor = BitnagilTheme.colors.coolGray98,
270+
disabledTextColor = BitnagilTheme.colors.coolGray90,
271+
),
272+
enabled = false,
273+
modifier = Modifier
274+
.fillMaxWidth()
275+
.padding(14.dp),
276+
)
277+
}
278+
}
279+
280+
@Preview(showBackground = true)
281+
@Composable
282+
private fun Preview() {
283+
ReportScreen(
284+
uiState = ReportState.Init,
285+
onReportTitle = {},
286+
onReportDescriptionChange = {},
287+
onRemoveImage = {},
288+
onShowImageSourceBottomSheet = {},
289+
onShowReportCategoryBottomSheet = {},
290+
onGetCurrentLocationClick = {},
291+
onBackClick = {},
292+
)
293+
}

0 commit comments

Comments
 (0)