refactored data providers with centralized cache resolution, unified UI using custom dialogs and bottom sheets, and enhanced network error handling for Dio and TLS errors

This commit is contained in:
2026-05-08 20:01:45 +02:00
parent c62a14645a
commit 9e139b5704
37 changed files with 595 additions and 753 deletions
+8 -28
View File
@@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/focus_behaviour.dart';
import '../../../widget/info_dialog.dart';
class FilesUploadDialog extends StatefulWidget {
final List<String> filePaths;
@@ -47,20 +48,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}).toList();
}
void showHttpErrorCode(int httpErrorCode){
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Ein Fehler ist aufgetreten'),
contentPadding: const EdgeInsets.all(10),
content: Text('Error code: $httpErrorCode'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Schließen', textAlign: TextAlign.center),
),
],
)
void showHttpErrorCode(int httpErrorCode) {
InfoDialog.show(
context,
'Error code: $httpErrorCode',
title: 'Ein Fehler ist aufgetreten',
copyable: true,
);
}
@@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
_overallProgressValue = 0.0;
_infoText = '';
});
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Upload fehlgeschlagen'),
contentPadding: const EdgeInsets.all(10),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Schließen', textAlign: TextAlign.center),
),
],
),
);
InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true);
}
Future<void> uploadFiles({bool override = false}) async {
+74 -88
View File
@@ -11,6 +11,7 @@ import '../../../../utils/download_manager.dart';
import '../../../../utils/file_clipboard.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import 'file_details_sheet.dart';
@@ -77,13 +78,7 @@ class _FileElementState extends State<FileElement> {
DownloadManager.instance.clear(widget.file.path);
_detachJob();
setState(() {});
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Download'),
content: Text(message),
),
);
InfoDialog.show(context, message, title: 'Download', copyable: true);
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(widget.file.path);
_detachJob();
@@ -172,32 +167,36 @@ class _FileElementState extends State<FileElement> {
Future<void> _rename() async {
final controller = TextEditingController(text: widget.file.name);
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Umbenennen'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Neuer Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Umbenennen'),
try {
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Umbenennen'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Neuer Name'),
autofocus: true,
),
],
),
);
if (newName == null || newName.isEmpty || newName == widget.file.name) return;
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Umbenennen'),
),
],
),
);
if (newName == null || newName.isEmpty || newName == widget.file.name) return;
final parent = _parentPathOf(widget.file.path);
final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory);
await _runWebdavOp(() async {
final webdav = await WebdavApi.webdav;
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
}, errorTitle: 'Umbenennen fehlgeschlagen');
final parent = _parentPathOf(widget.file.path);
final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory);
await _runWebdavOp(() async {
final webdav = await WebdavApi.webdav;
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
}, errorTitle: 'Umbenennen fehlgeschlagen');
} finally {
controller.dispose();
}
}
void _putOnClipboard({required bool copy}) {
@@ -234,68 +233,55 @@ class _FileElementState extends State<FileElement> {
widget.refetch();
} on Object catch (e) {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(errorTitle),
content: Text(e.toString()),
actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))],
),
);
InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true);
}
}
void _showActionSheet() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.info_outline)),
title: const Text('Info'),
onTap: () {
Navigator.of(sheetCtx).pop();
showFileDetailsSheet(context, widget.file);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)),
title: const Text('Umbenennen'),
onTap: () {
Navigator.of(sheetCtx).pop();
_rename();
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)),
title: const Text('Verschieben'),
onTap: () {
Navigator.of(sheetCtx).pop();
_putOnClipboard(copy: false);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.copy_outlined)),
title: const Text('Kopieren'),
onTap: () {
Navigator.of(sheetCtx).pop();
_putOnClipboard(copy: true);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.delete_outline)),
title: const Text('Löschen'),
onTap: () {
Navigator.of(sheetCtx).pop();
_delete();
},
),
],
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.info_outline)),
title: const Text('Info'),
onTap: () {
Navigator.of(sheetCtx).pop();
showFileDetailsSheet(context, widget.file);
},
),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)),
title: const Text('Umbenennen'),
onTap: () {
Navigator.of(sheetCtx).pop();
_rename();
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)),
title: const Text('Verschieben'),
onTap: () {
Navigator.of(sheetCtx).pop();
_putOnClipboard(copy: false);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.copy_outlined)),
title: const Text('Kopieren'),
onTap: () {
Navigator.of(sheetCtx).pop();
_putOnClipboard(copy: true);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.delete_outline)),
title: const Text('Löschen'),
onTap: () {
Navigator.of(sheetCtx).pop();
_delete();
},
),
],
);
}
@@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
import '../data/sort_options.dart';
/// AppBar action buttons for sort direction (asc/desc) and sort field
/// (name/date/size). Pure UI owners pass current values + selection
/// callbacks.
class FilesSortActions extends StatelessWidget {
final SortOption currentSort;
final bool ascending;
+26 -23
View File
@@ -10,6 +10,8 @@ import '../../../state/app/modules/holidays/bloc/holidays_state.dart';
import '../../../widget/animated_time.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/debug/debug_tile.dart';
import '../../../widget/details_bottom_sheet.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/list_view_util.dart';
import '../../../widget/string_extensions.dart';
@@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget {
create: (context) => HolidaysBloc(),
autoRebuild: true,
child: (context, bloc, state) {
void showDisclaimer() {
showDialog(context: context, builder: (context) => AlertDialog(
title: const Text('Richtigkeit und Bereitstellung der Daten'),
content: const Text(''
'Sämtliche Datumsangaben sind ohne Gewähr.\n'
'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n'
'Die Daten stammen von https://ferien-api.de/'),
actions: [
TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()),
],
));
}
void showDisclaimer() => InfoDialog.show(
context,
'Sämtliche Datumsangaben sind ohne Gewähr.\n'
'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n'
'Die Daten stammen von https://ferien-api.de/',
title: 'Richtigkeit und Bereitstellung der Daten',
);
return Scaffold(
appBar: AppBar(
@@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget {
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'),
subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'),
onTap: () => showDialog(context: context, builder: (context) => SimpleDialog(
title: Text('$holidayType ${holiday.year} in Hessen'),
children: [
onTap: () => showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'$holidayType ${holiday.year} in Hessen',
style: Theme.of(context).textTheme.titleLarge,
),
),
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.signpost_outlined)),
title: Text(holiday.name.capitalize()),
@@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget {
leading: const Icon(Icons.date_range_outlined),
title: Text('bis zum ${formatDate(holiday.end)}'),
),
Visibility(
visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative,
replacement: ListTile(
if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative)
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(Jiffy.parse(holiday.start).fromNow()),
),
child: ListTile(
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())),
subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
),
),
DebugTile(context).jsonData(holiday.toJson()),
DebugTile(sheetCtx).jsonData(holiday.toJson()),
],
)),
),
trailing: const Icon(Icons.arrow_right),
);
}),
@@ -5,34 +5,43 @@ import '../../../../widget/share_position_origin.dart';
enum ShareTargetType { qr }
class SelectShareTypeDialog extends StatelessWidget {
const SelectShareTypeDialog({super.key});
@override
Widget build(BuildContext context) => SimpleDialog(
children: [
ListTile(
leading: const Icon(Icons.qr_code_2_outlined),
title: const Text('Per QR-Code'),
trailing: const Icon(Icons.arrow_right),
onTap: () => Navigator.of(context).pop(ShareTargetType.qr),
),
ListTile(
leading: const Icon(Icons.link_outlined),
title: const Text('Per Link teilen'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
Navigator.of(context).pop();
SharePlus.instance.share(ShareParams(
sharePositionOrigin: SharePositionOrigin.get(context),
subject: 'App Teilen',
text: 'Hol dir die für das Marianum maßgeschneiderte App:'
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
'\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 '
'\n\nViel Spaß!',
));
},
)
],
);
/// Bottom sheet that lets the user pick how they want to share the app.
/// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the
/// sheet is dismissed (link sharing fires immediately and resolves null).
Future<ShareTargetType?> showSelectShareTypeSheet(BuildContext context) {
return showModalBottomSheet<ShareTargetType>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.qr_code_2_outlined),
title: const Text('Per QR-Code'),
trailing: const Icon(Icons.arrow_right),
onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr),
),
ListTile(
leading: const Icon(Icons.link_outlined),
title: const Text('Per Link teilen'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
Navigator.of(sheetCtx).pop();
SharePlus.instance.share(ShareParams(
sharePositionOrigin: SharePositionOrigin.get(sheetCtx),
subject: 'App Teilen',
text: 'Hol dir die für das Marianum maßgeschneiderte App:'
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
'\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 '
'\n\nViel Spaß!',
));
},
),
],
),
),
);
}
+1 -4
View File
@@ -42,10 +42,7 @@ class _OverhangState extends State<Overhang> {
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
final result = await showDialog<ShareTargetType>(
context: context,
builder: (_) => const SelectShareTypeDialog(),
);
final result = await showSelectShareTypeSheet(context);
if (!mounted || result != ShareTargetType.qr) return;
if (context.mounted) AppRoutes.openQrShare(context);
},
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/default_settings.dart';
import '../widgets/privacy_info.dart';
import 'dev_tools_section.dart';
@@ -69,45 +70,43 @@ class AboutSection extends StatelessWidget {
);
}
void _showPrivacyDialog(BuildContext context) => showDialog(
context: context,
builder: (context) => SimpleDialog(
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
subtitle: const Text('Für Talk-Chats und Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
imprintUrl: 'https://www.marianum-fulda.de/impressum',
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
).showPopup(context),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Untis',
imprintUrl: 'https://www.untis.at/impressum',
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
).showPopup(context),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
imprintUrl: 'https://mhsl.eu/id.html',
privacyUrl: 'https://mhsl.eu/datenschutz.html',
).showPopup(context),
),
],
),
void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
subtitle: const Text('Für Talk-Chats und Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
imprintUrl: 'https://www.marianum-fulda.de/impressum',
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Untis',
imprintUrl: 'https://www.untis.at/impressum',
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
imprintUrl: 'https://mhsl.eu/id.html',
privacyUrl: 'https://mhsl.eu/datenschutz.html',
).showPopup(sheetCtx),
),
],
);
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
@@ -11,6 +11,7 @@ import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart';
import '../../../../widget/debug/json_viewer.dart';
import '../../../../widget/details_bottom_sheet.dart';
class DevToolsSection extends StatefulWidget {
final SettingsCubit settings;
@@ -29,42 +30,45 @@ class _DevToolsSectionState extends State<DevToolsSection> {
title: const Text('Performance overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDialog(
context: context,
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return SimpleDialog(
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
),
),
),
ListTile(
leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
ListTile(
leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
),
),
),
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
),
),
),
],
);
},
),
],
);
},
),
],
);
},
),
@@ -122,9 +126,6 @@ class _DevToolsSectionState extends State<DevToolsSection> {
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'),
onTap: () {
// Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView()));
},
onLongPress: () {
ConfirmDialog(
title: 'BLOC-Cache löschen',
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
class PrivacyInfo {
String providerText;
@@ -11,22 +12,29 @@ class PrivacyInfo {
PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl});
void showPopup(BuildContext context) {
showDialog(context: context, builder: (context) => SimpleDialog(
title: Text('Betreiberinformation | $providerText'),
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.person_pin_outlined)),
title: const Text('Impressum'),
subtitle: Text(imprintUrl),
onTap: () => ConfirmDialog.openBrowser(context, imprintUrl),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)),
title: const Text('Datenschutzerklärung'),
subtitle: Text(privacyUrl),
onTap: () => ConfirmDialog.openBrowser(context, privacyUrl),
),
],
));
showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'Betreiberinformation | $providerText',
style: Theme.of(context).textTheme.titleLarge,
),
),
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.person_pin_outlined)),
title: const Text('Impressum'),
subtitle: Text(imprintUrl),
onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)),
title: const Text('Datenschutzerklärung'),
subtitle: Text(privacyUrl),
onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl),
),
],
);
}
}
+4 -5
View File
@@ -12,6 +12,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/placeholder_view.dart';
import 'join_chat.dart';
import 'search_chat.dart';
@@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> {
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
break;
case AuthorizationStatus.denied:
showDialog(
context: context,
builder: (_) => const AlertDialog(
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
),
InfoDialog.show(
context,
'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
);
break;
default:
+2 -3
View File
@@ -40,9 +40,8 @@ class BubbleStyle {
final double borderRadius;
}
/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a
/// rounded container with optional shadow / border. The nip is conveyed by
/// flattening one corner so the bubble visually anchors to the speaker side.
/// The "nip" is faked by flattening one corner so the bubble anchors to
/// the speaker side.
class Bubble extends StatelessWidget {
const Bubble({required this.child, required this.style, super.key});
@@ -246,9 +246,6 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
}
}
/// Stack inside the bubble: actor name (top-left, optional), message body
/// (centre), timestamp + read marker (bottom-right, optional), and a
/// download progress bar overlaid at the bottom while a job is running.
class _BubbleContent extends StatelessWidget {
final Text actorText;
final Text timeText;
@@ -14,13 +14,14 @@ import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
Future<void> showChatMessageOptionsDialog(
void showChatMessageOptionsDialog(
BuildContext context, {
required GetRoomResponseObject chatData,
required GetChatResponseObject bubbleData,
@@ -34,63 +35,61 @@ Future<void> showChatMessageOptionsDialog(
.add(const Duration(hours: 6))
.isAfter(DateTime.now());
return showDialog(
context: context,
builder: (dialogCtx) => SimpleDialog(
children: [
if (canReact)
_ReactionsRow(
chatToken: chatData.token,
messageId: bubbleData.id,
onRefetch: onRefetch,
dialogContext: dialogCtx,
),
if (bubbleData.isReplyable)
ListTile(
leading: const Icon(Icons.reply_outlined),
title: const Text('Antworten'),
onTap: () {
dialogCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
Navigator.of(dialogCtx).pop();
},
),
if (canReact)
ListTile(
leading: const Icon(Icons.emoji_emotions_outlined),
title: const Text('Reaktionen'),
onTap: () {
Navigator.of(dialogCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
},
),
if (bubbleData.message != '{file}')
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Nachricht kopieren'),
onTap: () {
copyToClipboard(parentContext, bubbleData.message);
Navigator.of(dialogCtx).pop();
},
),
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
ListTile(
leading: const Icon(Icons.sms_outlined),
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
onTap: () => Navigator.of(dialogCtx).pop(),
),
if (canDelete)
AsyncListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run();
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
},
),
DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
],
),
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
if (canReact)
_ReactionsRow(
chatToken: chatData.token,
messageId: bubbleData.id,
onRefetch: onRefetch,
sheetContext: sheetCtx,
),
if (bubbleData.isReplyable)
ListTile(
leading: const Icon(Icons.reply_outlined),
title: const Text('Antworten'),
onTap: () {
sheetCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
Navigator.of(sheetCtx).pop();
},
),
if (canReact)
ListTile(
leading: const Icon(Icons.emoji_emotions_outlined),
title: const Text('Reaktionen'),
onTap: () {
Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
},
),
if (bubbleData.message != '{file}')
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Nachricht kopieren'),
onTap: () {
copyToClipboard(parentContext, bubbleData.message);
Navigator.of(sheetCtx).pop();
},
),
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
ListTile(
leading: const Icon(Icons.sms_outlined),
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
onTap: () => Navigator.of(sheetCtx).pop(),
),
if (canDelete)
AsyncListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run();
if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh();
},
),
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
],
);
}
@@ -98,13 +97,13 @@ class _ReactionsRow extends StatefulWidget {
final String chatToken;
final int messageId;
final void Function({bool renew}) onRefetch;
final BuildContext dialogContext;
final BuildContext sheetContext;
const _ReactionsRow({
required this.chatToken,
required this.messageId,
required this.onRefetch,
required this.dialogContext,
required this.sheetContext,
});
@override
@@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
if (!mounted) return;
if (ok) {
widget.onRefetch(renew: true);
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop();
if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop();
}
}
+7 -5
View File
@@ -15,6 +15,7 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../chat_view.dart';
import '../talk_navigator.dart';
@@ -124,8 +125,9 @@ class _ChatTileState extends State<ChatTile> {
},
onLongPress: () {
if (widget.disableContextActions) return;
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
children: [
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
if (widget.data.unreadMessages > 0)
AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined),
@@ -163,7 +165,7 @@ class _ChatTileState extends State<ChatTile> {
leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'),
onTap: () {
Navigator.of(dialogCtx).pop();
Navigator.of(sheetCtx).pop();
ConfirmDialog(
title: 'Chat verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
@@ -175,9 +177,9 @@ class _ChatTileState extends State<ChatTile> {
).asDialog(context);
},
),
DebugTile(dialogCtx).jsonData(widget.data.toJson()),
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
],
));
);
},
);
}
@@ -29,7 +29,6 @@ class _PollOptionsListState extends State<PollOptionsList> {
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
return ListTile(
// enabled: false,
isThreeLine: portionsVisible,
dense: true,
title: Text(
@@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25;
const Duration kCalendarTimeInterval = Duration(minutes: 30);
const double kCalendarViewHeaderHeight = 60;
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
/// than compressing further.
/// Below this, the grid scrolls vertically rather than compressing further.
const double kCalendarMinPxPerHour = 56;
/// Minimum height of a lesson block in the period-based layout. The grid
/// scrolls vertically once lessons would otherwise be smaller than this.
/// The grid scrolls vertically once lessons would otherwise be smaller.
const double kLessonBlockMinHeight = 50;
/// Fixed height of a break block in the period-based layout. Independent of
/// the actual break duration; breaks are rendered as a compact indicator.
/// Fixed (independent of actual break duration); breaks render as a compact
/// indicator.
const double kBreakBlockHeight = 28;
const int kOutsideChipsMaxVisible = 2;
const double kOutsideChipHeight = 22;
const double kOutsideChipSpacing = 3;
const double kOutsideStripVerticalPadding = 3;
const double kAppointmentTitleFontSize = 15;
const double kAppointmentTitleMinFontSize = 11;
const double kAppointmentBodyFontSize = 10;
const double kAppointmentBodyLineHeight = 1.15;
@@ -2,14 +2,11 @@ import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/arbitrary_appointment.dart';
import '../data/calendar_layout.dart';
import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7));
static const _titleFontSize = 15.0;
static const _titleMinFontSize = 11.0;
static const _bodyFontSize = 10.0;
static const _bodyLineHeight = 1.15;
final Appointment appointment;
final bool crossedOut;
@@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget {
children: [
_AdaptiveTitle(
text: appointment.subject,
fontSize: _titleFontSize,
minFontSize: _titleMinFontSize,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
),
if (isCustom) ...[
@@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget {
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: _bodyFontSize,
lineHeight: _bodyLineHeight,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
@@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget {
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(text: line, fontSize: _bodyFontSize),
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
],
],
),
@@ -1,11 +1,6 @@
part of '../custom_workweek_calendar.dart';
class _OutsideHoursStrip extends StatelessWidget {
static const int _maxVisibleChips = 2;
static const double _chipHeight = 22;
static const double _chipSpacing = 3;
static const double _verticalPadding = 3;
final DateTime weekStart;
final List<Appointment> appointments;
final double rulerWidth;
@@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget {
final theme = Theme.of(context);
final maxChipsPerDay = outside
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
.map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length)
.fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = _verticalPadding * 2 +
maxChipsPerDay * _chipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
final stripHeight = kOutsideStripVerticalPadding * 2 +
maxChipsPerDay * kOutsideChipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
child: SizedBox(
height: stripHeight - _verticalPadding * 2,
height: stripHeight - kOutsideStripVerticalPadding * 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget {
Expanded(
child: _OutsideDayColumn(
appointments: outside[d],
maxVisible: _maxVisibleChips,
chipHeight: _chipHeight,
chipSpacing: _chipSpacing,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
),
@@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget {
class _OutsideDayColumn extends StatelessWidget {
final List<Appointment> appointments;
final int maxVisible;
final double chipHeight;
final double chipSpacing;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideDayColumn({
required this.appointments,
required this.maxVisible,
required this.chipHeight,
required this.chipSpacing,
required this.onAppointmentTap,
required this.isCrossedOut,
});
@@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget {
if (!aLike && bLike) return 1;
return a.startTime.compareTo(b.startTime);
});
final visible = sorted.length <= maxVisible
final visible = sorted.length <= kOutsideChipsMaxVisible
? sorted
: sorted.take(maxVisible - 1).toList();
final overflow =
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
: sorted.take(kOutsideChipsMaxVisible - 1).toList();
final overflow = sorted.length <= kOutsideChipsMaxVisible
? const <Appointment>[]
: sorted.skip(kOutsideChipsMaxVisible - 1).toList();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
@@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < visible.length; i++) ...[
if (i > 0) SizedBox(height: chipSpacing),
if (i > 0) const SizedBox(height: kOutsideChipSpacing),
SizedBox(
height: chipHeight,
height: kOutsideChipHeight,
child: _OutsideChip(
appointment: visible[i],
onTap: () => onAppointmentTap(visible[i]),
@@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget {
),
],
if (overflow.isNotEmpty) ...[
SizedBox(height: chipSpacing),
const SizedBox(height: kOutsideChipSpacing),
SizedBox(
height: chipHeight,
height: kOutsideChipHeight,
child: _OutsideOverflowChip(
count: overflow.length,
onTap: () => _showOverflow(context, overflow),
@@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget {
padding: const EdgeInsets.all(1),
child: Stack(
children: [
// Card peeking out at the bottom — visual hint that more cards lie
// underneath the visible one.
// Stacked-cards effect: a darker layer peeks out below the front card.
Positioned(
top: 4,
left: 2,
@@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget {
),
),
),
// Front card with the "+N" indicator.
Positioned(
top: 0,
left: 0,