diff --git a/backend/controllers/mobileFeedbackController.js b/backend/controllers/mobileFeedbackController.js new file mode 100644 index 00000000..d6c252aa --- /dev/null +++ b/backend/controllers/mobileFeedbackController.js @@ -0,0 +1,29 @@ +import User from '../models/User.js'; +import { sendMobileFeedbackEmail } from '../services/emailService.js'; + +const clean = (value, max = 4000) => String(value ?? '').trim().slice(0, max); + +export const sendMobileFeedback = async (req, res) => { + try { + const message = clean(req.body?.message, 5000); + if (!message) { + return res.status(400).json({ error: 'message_required' }); + } + + const user = req.user?.id ? await User.findByPk(req.user.id) : null; + await sendMobileFeedbackEmail({ + message, + screen: clean(req.body?.screen, 200), + clubId: req.body?.clubId ?? null, + appVersion: clean(req.body?.appVersion, 80), + platform: clean(req.body?.platform, 80) || 'Android', + backendBaseUrl: clean(req.body?.backendBaseUrl, 300), + user: user ? { id: user.id, username: user.username, email: user.email } : { id: req.user?.id }, + }); + + return res.status(200).json({ success: true }); + } catch (error) { + console.error('[sendMobileFeedback] - error:', error); + return res.status(500).json({ error: 'internalerror' }); + } +}; diff --git a/backend/routes/mobileFeedbackRoutes.js b/backend/routes/mobileFeedbackRoutes.js new file mode 100644 index 00000000..34ed4236 --- /dev/null +++ b/backend/routes/mobileFeedbackRoutes.js @@ -0,0 +1,9 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { sendMobileFeedback } from '../controllers/mobileFeedbackController.js'; + +const router = express.Router(); + +router.post('/', authenticate, sendMobileFeedback); + +export default router; diff --git a/backend/server.js b/backend/server.js index c23f52af..48d28402 100644 --- a/backend/server.js +++ b/backend/server.js @@ -67,6 +67,7 @@ import friendlyMatchSharedRoutes from './routes/friendlyMatchSharedRoutes.js'; import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoutes.js'; import calendarRoutes from './routes/calendarRoutes.js'; import calendarEventRoutes from './routes/calendarEventRoutes.js'; +import mobileFeedbackRoutes from './routes/mobileFeedbackRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; import HttpError from './exceptions/HttpError.js'; @@ -366,6 +367,7 @@ app.use('/api/friendly-matches', friendlyMatchRoutes); app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes); app.use('/api/calendar', calendarRoutes); app.use('/api/calendar-events', calendarEventRoutes); +app.use('/api/mobile-feedback', mobileFeedbackRoutes); // Middleware für dynamischen kanonischen Tag (vor express.static) const setCanonicalTag = (req, res, next) => { diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 4a231cfb..7eba4337 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -95,4 +95,43 @@ const sendFriendlyMatchInvitationEmail = async ({ await transporter.sendMail(mailOptions); }; -export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail }; +const escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +const sendMobileFeedbackEmail = async ({ + message, + screen, + clubId, + appVersion, + platform, + backendBaseUrl, + user, +}) => { + const mailOptions = { + from: process.env.EMAIL_USER, + to: 'tsschulz2001@gmail.com', + subject: `Android Feedback${screen ? ` - ${screen}` : ''}`, + html: ` +
+

Android Feedback

+

Seite: ${escapeHtml(screen || '-')}

+

Verein-ID: ${escapeHtml(clubId ?? '-')}

+

User: ${escapeHtml(user?.username || user?.email || user?.id || '-')}

+

User-ID: ${escapeHtml(user?.id || '-')}

+

App-Version: ${escapeHtml(appVersion || '-')}

+

Plattform: ${escapeHtml(platform || 'Android')}

+

Backend: ${escapeHtml(backendBaseUrl || '-')}

+
+

Meldung

+
${escapeHtml(message)}
+
+ `, + }; + + await transporter.sendMail(mailOptions); +}; + +export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail }; diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index 754e8850..c3f31f08 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt index 1dc40a03..c1d4ef5b 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt @@ -17,6 +17,7 @@ import de.tsschulz.tt_tagebuch.shared.api.ClubsApi import de.tsschulz.tt_tagebuch.shared.api.DiaryApi import de.tsschulz.tt_tagebuch.shared.api.DiaryMemberActivitiesApi import de.tsschulz.tt_tagebuch.shared.api.DiaryMemberApi +import de.tsschulz.tt_tagebuch.shared.api.FeedbackApi import de.tsschulz.tt_tagebuch.shared.api.GroupApi import de.tsschulz.tt_tagebuch.shared.api.ParticipantsApi import de.tsschulz.tt_tagebuch.shared.api.PredefinedActivitiesApi @@ -88,6 +89,7 @@ class AppDependencies(context: Context) { ) val publicAuthApi = PublicAuthApi(publicHttpClient) + val feedbackApi = FeedbackApi(client) val authManager = AuthManager( tokenProvider = tokenProvider, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index be147b81..6ce13a11 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -100,12 +100,14 @@ import androidx.compose.ui.unit.sp import kotlin.math.max import de.tsschulz.tt_tagebuch.app.AppDependencies import de.tsschulz.tt_tagebuch.R +import de.tsschulz.tt_tagebuch.BuildConfig import de.tsschulz.tt_tagebuch.app.pdf.sharePdfFile import de.tsschulz.tt_tagebuch.app.pdf.writeMembersPhoneListPdf import de.tsschulz.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf import de.tsschulz.tt_tagebuch.app.pdf.writeTrainingPlanPdf import de.tsschulz.tt_tagebuch.shared.api.memberProfileImagePath import de.tsschulz.tt_tagebuch.shared.api.toAbsoluteUrl +import de.tsschulz.tt_tagebuch.shared.api.MobileFeedbackBody import de.tsschulz.tt_tagebuch.shared.api.models.MemberGroupPhotoDto import de.tsschulz.tt_tagebuch.shared.api.models.canReadApprovals import de.tsschulz.tt_tagebuch.shared.api.models.canReadClubPermissions @@ -296,6 +298,8 @@ private fun MainTabs( val visibleTabs = visibleMainTabs(clubState.currentPermissions) val networkConnected by dependencies.networkConnectivity.connected.collectAsState() var lastNetworkConnected by remember { mutableStateOf(true) } + var feedbackOpen by remember { mutableStateOf(false) } + val currentScreenTitle = tabTitle(selectedTab) /** Tagebuch- und Mitglieder-Listen vorladen, sobald Verein + Rechte feststehen. */ LaunchedEffect(clubState.currentClubId, clubState.currentPermissions) { @@ -494,6 +498,10 @@ private fun MainTabs( settingsStammdatenRequest = settingsStammdatenRequest, onConsumeSettingsStammdatenRequest = { settingsStammdatenRequest = null }, ) + MobileFeedbackButton( + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + onClick = { feedbackOpen = true }, + ) } } } else { @@ -519,6 +527,10 @@ private fun MainTabs( settingsStammdatenRequest = settingsStammdatenRequest, onConsumeSettingsStammdatenRequest = { settingsStammdatenRequest = null }, ) + MobileFeedbackButton( + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + onClick = { feedbackOpen = true }, + ) } BottomNavigation( backgroundColor = MaterialTheme.colors.surface, @@ -547,6 +559,92 @@ private fun MainTabs( } } } + + if (feedbackOpen) { + MobileFeedbackDialog( + dependencies = dependencies, + screenTitle = currentScreenTitle, + onDismiss = { feedbackOpen = false }, + ) + } +} + +@Composable +private fun MobileFeedbackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.heightIn(min = 40.dp), + ) { + Text("Feedback") + } +} + +@Composable +private fun MobileFeedbackDialog( + dependencies: AppDependencies, + screenTitle: String, + onDismiss: () -> Unit, +) { + val clubState by dependencies.clubManager.state.collectAsState() + val scope = rememberCoroutineScope() + val context = LocalContext.current + var message by remember { mutableStateOf("") } + var sending by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text("Feedback senden") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Seite: $screenTitle", style = MaterialTheme.typography.caption) + OutlinedTextField( + value = message, + onValueChange = { message = it }, + label = { Text("Was funktioniert nicht oder könnte besser sein?") }, + minLines = 5, + enabled = !sending, + modifier = Modifier.fillMaxWidth(), + ) + error?.let { Text(it, color = MaterialTheme.colors.error) } + } + }, + confirmButton = { + TextButton( + enabled = !sending && message.trim().isNotBlank(), + onClick = { + scope.launch { + sending = true + error = null + val result = runCatching { + dependencies.feedbackApi.send( + MobileFeedbackBody( + message = message.trim(), + screen = screenTitle, + clubId = clubState.currentClubId, + appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + backendBaseUrl = BuildConfig.BACKEND_BASE_URL, + ), + ) + } + sending = false + if (result.isSuccess) { + Toast.makeText(context, "Feedback gesendet", Toast.LENGTH_SHORT).show() + onDismiss() + } else { + error = result.exceptionOrNull()?.message ?: "Feedback konnte nicht gesendet werden." + } + } + }, + ) { Text(if (sending) "Sende..." else "Senden") } + }, + dismissButton = { + TextButton(enabled = !sending, onClick = onDismiss) { Text("Abbrechen") } + }, + ) } @Composable diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml index e9561339..79e463e9 100644 --- a/mobile-app/gradle/libs.versions.toml +++ b/mobile-app/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # composeApp (Play Store / „Über die App“-Build) -appVersionCode = "24" -appVersionName = "1.7.4" +appVersionCode = "25" +appVersionName = "1.7.5" agp = "9.2.1" android-compileSdk = "35" android-minSdk = "24" diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/FeedbackApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/FeedbackApi.kt new file mode 100644 index 00000000..24a5c282 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/FeedbackApi.kt @@ -0,0 +1,26 @@ +package de.tsschulz.tt_tagebuch.shared.api + +import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import kotlinx.serialization.Serializable + +@Serializable +data class MobileFeedbackBody( + val message: String, + val screen: String, + val clubId: Int? = null, + val appVersion: String = "", + val platform: String = "Android", + val backendBaseUrl: String = "", +) + +class FeedbackApi( + private val client: AuthedHttpClient, +) { + suspend fun send(body: MobileFeedbackBody) { + client.http.post("/api/mobile-feedback") { + setBody(body) + } + } +}