remove user study
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-08-23 20:19:08 +02:00
parent 195f6091f2
commit 1fb6b3c94a
9 changed files with 9 additions and 573 deletions

View file

@ -50,7 +50,6 @@ class Routes {
static const String settingsHelpContactUs = '/settings/help/contact_us';
static const String settingsHelpNews = '/settings/help/news';
static const String settingsHelpDiagnostics = '/settings/help/diagnostics';
static const String settingsHelpUserStudy = '/settings/help/user_study';
static const String settingsHelpUserStudyQuestionnaire =
'/settings/help/user_study/questionnaire';
static const String settingsHelpCredits = '/settings/help/credits';

View file

@ -174,21 +174,6 @@ class UserData {
// Used for push notifcation via FCM.
String? fcmToken;
// For my master thesis I want to create a anonymous user study:
// - users in the "Tester" Plan can, if they want, take part of the user study
@JsonKey(defaultValue: false)
bool askedForUserStudyPermission = false;
// So update data can be assigned. If set the user choose to participate.
String? userStudyParticipantsToken;
@JsonKey(defaultValue: 0)
int userStudyCountNewFriendsViaSuggestion = 0;
// Once a day the anonymous data is collected and send to the server
DateTime? lastUserStudyDataUpload;
String? currentSetupPage;
@JsonKey(defaultValue: false)

View file

@ -47,8 +47,6 @@ import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
import 'package:twonly/src/visual/views/settings/settings_main.view.dart';
import 'package:twonly/src/visual/views/settings/share_with_friends.view.dart';
import 'package:twonly/src/visual/views/settings/subscription/subscription.view.dart';
import 'package:twonly/src/visual/views/user_study/user_study_questionnaire.view.dart';
import 'package:twonly/src/visual/views/user_study/user_study_welcome.view.dart';
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
@ -268,19 +266,6 @@ final routerProvider = GoRouter(
path: 'diagnostics',
builder: (context, state) => const DiagnosticsView(),
),
GoRoute(
path: 'user_study',
builder: (context, state) => UserStudyWelcomeView(
wasOpenedAutomatic: state.extra as bool? ?? false,
),
routes: [
GoRoute(
path: 'questionnaire',
builder: (context, state) =>
const UserStudyQuestionnaireView(),
),
],
),
GoRoute(
path: 'credits',
builder: (context, state) => const CreditsView(),

View file

@ -44,7 +44,6 @@ import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/services/user_study.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -147,11 +146,6 @@ class ApiService {
unawaited(UserDiscoveryService.checkForNewAnnouncedUsers());
memoriesCloudService.init();
if (userService.currentUser.userStudyParticipantsToken != null) {
// In case the user participates in the user study, call the handler after authenticated, to be sure there is a internet connection
unawaited(handleUserStudyUpload());
}
}
}

View file

@ -1,141 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
const userStudySurveyKey = 'user_study_survey';
// PLEASE DO NOT SPAM OR TRY SENDING DIRECTLY TO THIS URL!
// You're just making my master's thesis more difficult and destroy scientific data. :/
const surveyUrlBase = 'https://survey.twonly.org/upload.php';
Future<void> handleUserStudyUpload() async {
try {
final token = userService.currentUser.userStudyParticipantsToken;
if (token == null) return;
// in case the survey was taken offline try again
final userStudySurvey = await KeyValueStore.get(userStudySurveyKey);
if (userStudySurvey != null) {
final response = await http.post(
Uri.parse('$surveyUrlBase/create/$token'),
body: jsonEncode(userStudySurvey),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode != 200) {
Log.warn(
'Got different status code for survey upload: ${response.statusCode}',
);
return;
}
await KeyValueStore.delete(userStudySurveyKey);
}
if (userService.currentUser.lastUserStudyDataUpload != null &&
isToday(userService.currentUser.lastUserStudyDataUpload!)) {
// Only send updates once a day.
// This enables to see if improvements to actually work.
return;
}
final contacts = await twonlyDB.contactsDao.getAllContacts();
final verifications = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
final udVerifiedByContactsCount = await twonlyDB.keyVerificationDao
.getTransferredTrustVerificationsCount();
final totalContactsWithVerificationBadge = await twonlyDB.keyVerificationDao
.getCountOfContactsWithVerificationBadge();
final udFriendsShared = await twonlyDB.contactsDao
.getContactsAnnouncedViaUserDiscovery();
final udAllAnnouncedUsers = await twonlyDB.userDiscoveryDao
.getAllAnnouncedUsersWithRelations();
var udUnknownAnnouncedUsers = 0;
for (final udUser in udAllAnnouncedUsers.keys) {
if (!contacts.any((c) => c.userId == udUser.announcedUserId)) {
udUnknownAnnouncedUsers += 1;
}
}
final dataCollection = {
'total_contacts': contacts.length,
'user_discovery_enabled': userService.currentUser.isUserDiscoveryEnabled,
'user_discovery_required_send_images':
userService.currentUser.requiredSendImages,
'user_discovery_threshold':
userService.currentUser.userDiscoveryThreshold,
'user_discovery_requires_manual_approval':
userService.currentUser.userDiscoveryRequiresManualApproval,
'user_discovery_share_promotion':
userService.currentUser.userDiscoverySharePromotion,
'user_discovery_count_friends_shared': udFriendsShared.length,
'user_discovery_count_announced_users': udAllAnnouncedUsers.length,
'user_discovery_count_unknown_announced_users': udUnknownAnnouncedUsers,
'user_study_count_new_friends_via_suggestion':
userService.currentUser.userStudyCountNewFriendsViaSuggestion,
'user_discovery_count_verified_by_contacts': udVerifiedByContactsCount,
'accepted_contacts': contacts.where((c) => c.accepted).length,
'verified_contacts': verifications.length,
'total_contacts_with_verification_badge':
totalContactsWithVerificationBadge,
'verified_contacts_via_migrated_from_old_version': verifications.values
.where((c) => c == VerificationType.migratedFromOldVersion)
.length,
'verified_contacts_via_qr_scanned': verifications.values
.where((c) => c == VerificationType.qrScanned)
.length,
'verified_contacts_via_link': verifications.values
.where((c) => c == VerificationType.link)
.length,
'verified_contacts_via_secret_qr_token': verifications.values
.where((c) => c == VerificationType.secretQrToken)
.length,
'verified_contacts_via_contact_shared_by_verified': verifications.values
.where((c) => c == VerificationType.contactSharedByVerified)
.length,
};
final response = await http.post(
Uri.parse('$surveyUrlBase/push/$token'),
body: jsonEncode(dataCollection),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
await UserService.update((u) {
u.lastUserStudyDataUpload = DateTime.now();
});
}
if (response.statusCode == 404) {
// Token is unknown to the server...
await UserService.update((u) {
u
..lastUserStudyDataUpload = null
..userStudyParticipantsToken = null;
});
}
} catch (e) {
if (e is http.ClientException ||
e is SocketException ||
e is TimeoutException) {
Log.warn('Error uploading user study data: $e');
} else {
Log.error(e);
}
}
}

View file

@ -7,7 +7,6 @@ import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -68,10 +67,6 @@ class FriendSuggestionsComp extends StatelessWidget {
);
if (added > 0) await importSignalContactAndCreateRequest(userdata);
await UserService.update(
(u) => u.userStudyCountNewFriendsViaSuggestion += 1,
);
}
Future<void> _hideAnnouncedUser(int userId) async {
@ -110,11 +105,14 @@ class FriendSuggestionsComp extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: friends.map((f) {
final contact = f.$1;
final isSelected =
selectedFriends.contains(contact.userId);
final isSelected = selectedFriends.contains(
contact.userId,
);
return CheckboxListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(contact.displayName ?? contact.username),
title: Text(
contact.displayName ?? contact.username,
),
value: isSelected,
onChanged: (val) {
setState(() {
@ -255,7 +253,8 @@ class FriendSuggestionsComp extends StatelessWidget {
left: 4,
),
).merge(secondaryGreyButtonStyle(context)),
onPressed: () => _askFriends(context, user, friends),
onPressed: () =>
_askFriends(context, user, friends),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
@ -61,12 +60,6 @@ class _HelpViewState extends State<HelpView> {
onTap: () => context.push(Routes.settingsHelpDiagnostics),
),
const Divider(),
if (userService.currentUser.userStudyParticipantsToken == null ||
kDebugMode)
ListTile(
title: const Text('Teilnahme an Nutzerstudie'),
onTap: () => context.push(Routes.settingsHelpUserStudy),
),
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (context, snap) {

View file

@ -1,267 +0,0 @@
// ignore_for_file: avoid_dynamic_calls
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_study.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
class UserStudyQuestionnaireView extends StatefulWidget {
const UserStudyQuestionnaireView({super.key});
@override
State<UserStudyQuestionnaireView> createState() =>
_UserStudyQuestionnaireViewState();
}
class _UserStudyQuestionnaireViewState
extends State<UserStudyQuestionnaireView> {
final Map<String, dynamic> _responses = {
'age': null,
'education': null,
'education_free': '',
'vocational': null,
'vocational_free': '',
'enrolled': null,
'study_program': '',
'working': null,
'work_field': '',
'smartphone_2years': null,
'comp_knowledge': null,
'security_knowledge': null,
'messengers': [],
'is_release_mode': kReleaseMode,
};
final List<String> _messengerOptions = [
'WhatsApp',
'Signal',
'Telegram',
'Facebook Messenger',
'iMessage',
'Teams',
'Viber',
'Element',
'Andere',
];
Future<void> _submitData() async {
await KeyValueStore.put(userStudySurveyKey, _responses);
await UserService.update((u) {
// generate a random participants id to identify data send later while keeping the user anonym
u
..userStudyParticipantsToken = getRandomString(25)
..askedForUserStudyPermission = true;
});
await handleUserStudyUpload();
if (!mounted) return;
showSnackbar(
context,
'Vielen Dank für deine Teilnahme!',
level: SnackbarLevel.success,
);
context.pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Befragung')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Demografische Daten'),
_questionText('Wie alt bist du?'),
_buildRadioList(
[
'18-22',
'23-27',
'28-32',
'33-37',
'38-42',
'43-47',
'48-52',
'53-57',
'58-62',
'63-67',
'68-72',
'73-77',
'77 oder älter',
'Keine Angabe',
],
'age',
),
_questionText('Was ist dein höchster Schulabschluss?'),
_buildRadioList(
[
'Noch in der Schule',
'Hauptschulabschluss',
'POS (Polytechnische Oberschule)',
'Realschulabschluss',
'Abitur / Hochschulreife',
'Kein Abschluss',
'Keine Angabe',
],
'education',
),
_buildTextField(
'Freitext (optional)',
(val) => _responses['education_free'] = val,
),
_questionText('Was ist dein höchster beruflicher Abschluss?'),
_buildRadioList(
[
'Berufsausbildung / Duales System',
'Fachschulabschluss',
'Fachschulabschluss (ehem. DDR)',
'Bachelor',
'Master',
'Diplom',
'Promotion (PhD)',
'Kein beruflicher Abschluss',
'Keine Angabe',
],
'vocational',
),
_buildTextField(
'Freitext (optional)',
(val) => _responses['vocational_free'] = val,
),
_questionText(
'Bist du derzeit in einem Studiengang eingeschrieben? (Bachelor, Master, Diplom, Staatsexamen, Magister)',
),
_buildRadioList(['Ja', 'Nein', 'Keine Angabe'], 'enrolled'),
_questionText('Wenn ja, welcher Studiengang?'),
_buildTextField(
'Studiengang eingeben',
(val) => _responses['study_program'] = val,
),
_questionText('Bist du derzeit berufstätig?'),
_buildRadioList(['Ja', 'Nein', 'Keine Angabe'], 'working'),
_questionText('Wenn ja, in welchem Bereich arbeiten Sie?'),
_buildTextField(
'Arbeitsbereich eingeben',
(val) => _responses['work_field'] = val,
),
const SizedBox(height: 30),
// const Divider(),
_sectionTitle('Technisches Wissen'),
_questionText(
'Nutzt du seit mehr als zwei Jahren ein Smartphone?',
),
_buildRadioList(['Ja', 'Nein'], 'smartphone_2years'),
_questionText(
'Wie schätzt du deine allgemeinen Computerkenntnisse ein?',
),
_buildRadioList(
['Anfänger', 'Mittel', 'Fortgeschritten'],
'comp_knowledge',
),
_questionText(
'Wie schätzt du dein Wissen im Bereich IT-Sicherheit ein?',
),
_buildRadioList(
['Anfänger', 'Mittel', 'Fortgeschritten'],
'security_knowledge',
),
_questionText(
'Welche der folgenden Messenger hast du schon einmal benutzt?',
),
..._messengerOptions.map(
(m) => CheckboxListTile.adaptive(
title: Text(m),
visualDensity: const VisualDensity(vertical: -4),
value: (_responses['messengers'] as List<dynamic>).contains(m),
onChanged: (value) {
setState(() {
value!
? _responses['messengers'].add(m)
: _responses['messengers'].remove(m);
});
},
),
),
const SizedBox(height: 30),
Center(
child: FilledButton(
onPressed: _submitData,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 15),
child: Text(
'Jetzt teilnehmen',
style: TextStyle(fontSize: 18),
),
),
),
),
const SizedBox(height: 50),
],
),
),
);
}
// Hilfsmethoden für das UI
Widget _sectionTitle(String title) => Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
);
Widget _questionText(String text) => Padding(
padding: const EdgeInsets.only(top: 20, bottom: 5),
child: Text(
text,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
);
Widget _buildRadioList(List<String> options, String key) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: DropdownButtonFormField<String>(
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
labelText: 'Bitte wählen...',
),
initialValue: _responses[key] as String?,
items: options.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (val) {
setState(() {
_responses[key] = val;
});
},
),
);
}
Widget _buildTextField(String hint, void Function(String) onChanged) {
return TextField(
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: hint,
border: const OutlineInputBorder(),
),
onChanged: onChanged,
);
}
}

View file

@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/user.service.dart';
class UserStudyWelcomeView extends StatefulWidget {
const UserStudyWelcomeView({super.key, this.wasOpenedAutomatic = false});
final bool wasOpenedAutomatic;
@override
State<UserStudyWelcomeView> createState() => _UserStudyWelcomeViewState();
}
class _UserStudyWelcomeViewState extends State<UserStudyWelcomeView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Teilnahme an Nutzerstudie'),
),
body: Padding(
padding: const EdgeInsets.all(12),
child: ListView(
children: [
const SizedBox(height: 30),
const Text(
'Es dauert nur ein paar Minuten.',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
const SizedBox(height: 20),
const Text(
'Im Rahmen meiner Masterarbeit möchte ich die Benutzerfreundlichkeit von anonymen und dezentralen Messenger-Diensten verbessern.',
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
const Text(
'Zu diesem Zweck werden in den nächsten Monaten verschiedene Änderungen an der App vorgenommen. Um die Wirksamkeit der Änderungen zu messen, möchte ich einige Daten über deine Nutzung der App sammeln sowie eine kurze Befragung durchführen.',
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
const Text(
'Die Daten bestehen ausschließlich aus Zahlen, z. B. Anzahl deiner Kontakte. Alle Daten werden anonym übermittelt und können nicht mit deinem Benutzerkonto verknüpft werden.',
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
const Text(
'Die Masterarbeit und damit die Nutzerstudie wird bis September durchgeführt. Nach Abschluss erhältst du eine Benachrichtigung und wirst über die Ergebnisse informiert.',
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
Center(
child: FilledButton(
onPressed: () => context.pushReplacement(
Routes.settingsHelpUserStudyQuestionnaire,
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 30, vertical: 15),
child: Text(
'Weiter zur Befragung',
style: TextStyle(fontSize: 18),
),
),
),
),
const SizedBox(height: 10),
if (widget.wasOpenedAutomatic)
Center(
child: OutlinedButton(
onPressed: () => context.pop(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Text(
'Frag mich später noch mal',
style: TextStyle(fontSize: 18),
),
),
),
),
const SizedBox(height: 10),
if (widget.wasOpenedAutomatic)
Center(
child: GestureDetector(
onTap: () async {
await UserService.update(
(u) => u.askedForUserStudyPermission = true,
);
if (context.mounted) context.pop();
},
child: const Text(
'Nicht mehr anzeigen',
style: TextStyle(fontSize: 12),
),
),
),
const SizedBox(height: 10),
const Text(
'PS: twonly ist Open Source, wenn du also genau wissen willst, welche Daten übertragen werden, schau dir einfach die Datei "lib/src/views/user_study/user_study_data_collection.dart" im Repository an :).',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10),
),
],
),
),
);
}
}