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)
+ }
+ }
+}