added guardian login with views for their assigned childs
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||
import '../../../session/session.dart';
|
||||
import '../../../state/app/modules/children/child_selection_cubit.dart';
|
||||
|
||||
/// Who an absence report is filed for, and what the form lets the user edit.
|
||||
class AbsenceFormPolicy {
|
||||
/// The child the report is for; null when users report for themselves.
|
||||
final GuardianChild? child;
|
||||
|
||||
const AbsenceFormPolicy._(this.child);
|
||||
|
||||
/// Guardians report for a linked child whose identity the server knows,
|
||||
/// so name and class are fixed.
|
||||
bool get identityEditable => child == null;
|
||||
|
||||
/// Null when the session cannot file a report (guardian without children).
|
||||
static AbsenceFormPolicy? resolve({
|
||||
required Session? session,
|
||||
required List<GuardianChild> children,
|
||||
required String? selectedChildId,
|
||||
}) => switch (session) {
|
||||
GuardianSession() => switch (effectiveChild(children, selectedChildId)) {
|
||||
null => null,
|
||||
final child => AbsenceFormPolicy._(child),
|
||||
},
|
||||
_ => const AbsenceFormPolicy._(null),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_classes.dart';
|
||||
@@ -6,24 +7,55 @@ import '../../../api/marianumconnect/queries/absence/absence_prefill.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_submit.dart';
|
||||
import '../../../extensions/date_time.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../state/app/modules/children/child_selection_cubit.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/child_switcher.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import '../../../widget/focus_behaviour.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'absence_form_policy.dart';
|
||||
|
||||
/// Mobile mirror of the public absence-report form: submit-only (no history —
|
||||
/// that lives on the web). Identity/class/phone are prefilled from the backend
|
||||
/// but stay editable; the class list matches the submit validation source.
|
||||
/// Guardians report for the selected child, whose identity is fixed.
|
||||
/// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`).
|
||||
class AbsenceReportView extends StatefulWidget {
|
||||
class AbsenceReportView extends StatelessWidget {
|
||||
const AbsenceReportView({super.key});
|
||||
|
||||
@override
|
||||
State<AbsenceReportView> createState() => _AbsenceReportViewState();
|
||||
Widget build(BuildContext context) {
|
||||
final policy = AbsenceFormPolicy.resolve(
|
||||
session: SessionManager().current,
|
||||
children: context.watch<CapabilitiesCubit>().state.children,
|
||||
selectedChildId: context.watch<ChildSelectionCubit>().state,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Abwesenheitsmeldung'),
|
||||
actions: const [ChildSwitcher()],
|
||||
),
|
||||
body: policy == null
|
||||
? const NoChildrenPlaceholder()
|
||||
// Re-created per child so no input leaks into another child's report.
|
||||
: _AbsenceForm(key: ValueKey(policy.child?.id), policy: policy),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
class _AbsenceForm extends StatefulWidget {
|
||||
final AbsenceFormPolicy policy;
|
||||
|
||||
const _AbsenceForm({required this.policy, super.key});
|
||||
|
||||
@override
|
||||
State<_AbsenceForm> createState() => _AbsenceFormState();
|
||||
}
|
||||
|
||||
class _AbsenceFormState extends State<_AbsenceForm> {
|
||||
static const String _required = 'Dieses Feld ist erforderlich.';
|
||||
|
||||
final TextEditingController _firstName = TextEditingController();
|
||||
@@ -67,7 +99,17 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
if (_submitted) setState(() {});
|
||||
}
|
||||
|
||||
String? get _childId => widget.policy.child?.id;
|
||||
|
||||
Future<void> _load() async {
|
||||
if (!widget.policy.identityEditable) {
|
||||
// The child's identity is the whole point of the form, so a failed
|
||||
// prefill is an error here, not a degraded start.
|
||||
final prefill = await AbsencePrefill().run(childId: _childId);
|
||||
_classes = [prefill.className];
|
||||
_applyPrefill(prefill, _classes);
|
||||
return;
|
||||
}
|
||||
// Both GETs are independent — fire them together. Prefill is best-effort
|
||||
// (mapped to null on failure), so a classes error still propagates while a
|
||||
// prefill failure never surfaces as an unhandled async error.
|
||||
@@ -148,6 +190,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
absentUntil: _absentUntil,
|
||||
phone: _phone.text.trim(),
|
||||
note: _note.text.trim(),
|
||||
childId: _childId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
// Replace the whole form with a terminal success screen. There is
|
||||
@@ -157,10 +200,8 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Abwesenheitsmeldung')),
|
||||
body: _done ? const _SubmittedView() : _buildBody(context),
|
||||
);
|
||||
Widget build(BuildContext context) =>
|
||||
_done ? const _SubmittedView() : _buildBody(context);
|
||||
|
||||
Widget _buildBody(BuildContext context) => FutureBuilder<void>(
|
||||
future: _init,
|
||||
@@ -205,6 +246,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _firstName,
|
||||
readOnly: !widget.policy.identityEditable,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Vorname',
|
||||
@@ -215,6 +257,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _lastName,
|
||||
readOnly: !widget.policy.identityEditable,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Nachname',
|
||||
@@ -234,7 +277,9 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
items: _classes
|
||||
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _selectedClass = value),
|
||||
onChanged: widget.policy.identityEditable
|
||||
? (value) => setState(() => _selectedClass = value)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DateField(
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../data/file_type_icon.dart';
|
||||
|
||||
/// Leading slot for a file row: shows the Nextcloud thumbnail when the
|
||||
@@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget {
|
||||
'https://${EndpointData().nextcloud().full()}'
|
||||
'/index.php/core/preview'
|
||||
'?fileId=$fileId&x=128&y=128&a=0',
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
|
||||
import '../data/event_formatter.dart';
|
||||
import 'event_details_sheet.dart';
|
||||
@@ -89,24 +91,31 @@ class MarianumDateRow extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: _CalendarPlusIcon(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
tooltip: 'In Stundenplan übernehmen',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(
|
||||
initialTitle: event.title,
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
// Custom events are private to the own plan; a guardian's plan
|
||||
// belongs to the child.
|
||||
if (context
|
||||
.watch<TimetableBloc>()
|
||||
.subject
|
||||
.supportsCustomEvents) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: _CalendarPlusIcon(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
tooltip: 'In Stundenplan übernehmen',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(
|
||||
initialTitle: event.title,
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,15 +4,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
||||
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../session/session.dart';
|
||||
import '../../../../session/session_lifecycle.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/avatar_actions_sheet.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/child_switcher.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
@@ -21,14 +25,53 @@ import '../../../../widget/user_avatar.dart';
|
||||
// every Settings rebuild doesn't re-issue the OCS request.
|
||||
String? _cachedDisplayName;
|
||||
|
||||
class AccountSection extends StatefulWidget {
|
||||
class AccountSection extends StatelessWidget {
|
||||
const AccountSection({super.key});
|
||||
|
||||
@override
|
||||
State<AccountSection> createState() => _AccountSectionState();
|
||||
Widget build(BuildContext context) => switch (SessionManager().current) {
|
||||
GuardianSession(:final email) => _GuardianAccount(email: email),
|
||||
_ => const _SchoolAccount(),
|
||||
};
|
||||
}
|
||||
|
||||
class _AccountSectionState extends State<AccountSection> {
|
||||
/// Guardians have no Nextcloud profile: show the e-mail and linked children.
|
||||
class _GuardianAccount extends StatelessWidget {
|
||||
final String email;
|
||||
|
||||
const _GuardianAccount({required this.email});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = context.watch<CapabilitiesCubit>().state.children;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
leading: const CenteredLeading(Icon(Icons.family_restroom_outlined)),
|
||||
title: const Text('Elternkonto'),
|
||||
subtitle: Text(email),
|
||||
trailing: TextButton.icon(
|
||||
icon: const Icon(Icons.logout_outlined, size: 18),
|
||||
label: const Text('Abmelden'),
|
||||
onPressed: () => _confirmLogout(context),
|
||||
),
|
||||
),
|
||||
for (final child in children) ChildTile(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SchoolAccount extends StatefulWidget {
|
||||
const _SchoolAccount();
|
||||
|
||||
@override
|
||||
State<_SchoolAccount> createState() => _SchoolAccountState();
|
||||
}
|
||||
|
||||
class _SchoolAccountState extends State<_SchoolAccount> {
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
String? _displayName = _cachedDisplayName;
|
||||
@@ -42,9 +85,7 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
Future<void> _loadDisplayName() async {
|
||||
try {
|
||||
final info = await GetUserInfo().run();
|
||||
_cachedDisplayName = info.displayName.isEmpty
|
||||
? null
|
||||
: info.displayName;
|
||||
_cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
|
||||
if (!mounted) return;
|
||||
setState(() => _displayName = _cachedDisplayName);
|
||||
} catch (_) {
|
||||
@@ -84,13 +125,17 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
setState(() => _avatarBusy = false);
|
||||
if (!ok) return;
|
||||
|
||||
invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false);
|
||||
invalidateAvatarCache(
|
||||
id: SessionManager().requireNextcloud().username,
|
||||
isGroup: false,
|
||||
);
|
||||
setState(() => _avatarVersion++);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final username = AccountData().getUsername();
|
||||
final nextcloud = SessionManager().requireNextcloud();
|
||||
final username = nextcloud.username;
|
||||
final displayName = _displayName;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -109,8 +154,10 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
AppRoutes.openLargeProfilePicture(context, username),
|
||||
onTap: () => AppRoutes.openLargeProfilePicture(
|
||||
context,
|
||||
username,
|
||||
),
|
||||
child: UserAvatar(
|
||||
key: ValueKey(_avatarVersion),
|
||||
id: username,
|
||||
@@ -164,7 +211,7 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.logout_outlined, size: 18),
|
||||
label: const Text('Abmelden'),
|
||||
onPressed: () => _showLogoutDialog(context),
|
||||
onPressed: () => _confirmLogout(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -172,13 +219,11 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
||||
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
||||
// Flow-Mechanismus nichts mitbekommen.
|
||||
if (!AccountData().isDemo && AccountData().usesLoginFlow)
|
||||
if (!SessionManager().isDemo && nextcloud.usesLoginFlow)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.cloud_sync_outlined),
|
||||
title: const Text('Nextcloud neu verbinden'),
|
||||
subtitle: const Text(
|
||||
'Bei Anmeldeproblemen in Talk oder Dateien',
|
||||
),
|
||||
subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
|
||||
closeOnSuccess: false,
|
||||
onPressed: _reconnectNextcloud,
|
||||
),
|
||||
@@ -197,35 +242,29 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
||||
// navigator in an inconsistent state.
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content: 'Möchtest du dich wirklich abmelden?',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirmAsync: _performLogout,
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||
}
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside the sign-out (the previous approach) raced AsyncDialogAction's
|
||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
||||
// navigator in an inconsistent state.
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content: 'Möchtest du dich wirklich abmelden?',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirmAsync: _performLogout,
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||
}
|
||||
|
||||
// Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
|
||||
// password (while Nextcloud credentials are still available), THEN revoke the
|
||||
// MC bearer token, and finally wipe local credentials. Each step is
|
||||
// best-effort so an offline logout still reaches a clean local state.
|
||||
Future<void> _performLogout() async {
|
||||
await PushRegistration().logoutCleanup();
|
||||
await AuthLogout().run();
|
||||
await AccountData().removeData();
|
||||
_cachedDisplayName = null;
|
||||
}
|
||||
Future<void> _performLogout() async {
|
||||
await SessionLifecycle.signOut();
|
||||
_cachedDisplayName = null;
|
||||
}
|
||||
|
||||
class _AvatarEditBadge extends StatelessWidget {
|
||||
@@ -253,11 +292,7 @@ class _AvatarEditBadge extends StatelessWidget {
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.edit,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
: Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../widgets/push_status_sheet.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
|
||||
class NotificationsSection extends StatelessWidget {
|
||||
const NotificationsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
return _PushSettings(
|
||||
settings: settings,
|
||||
capabilities: context.read<CapabilitiesCubit>(),
|
||||
enabled: settings.val().notificationSettings.enabled,
|
||||
devMode: settings.val().devToolsEnabled,
|
||||
// The status checklist describes the Nextcloud chain (app passwords,
|
||||
// keypair, general/talk registrations); a direct registration has none
|
||||
// of these links.
|
||||
showChainStatus: SessionManager().hasNextcloud,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The push area: the enable switch carries an at-a-glance health icon (green
|
||||
/// check / red X) right before the checkbox, and the detailed status checklist
|
||||
/// is hidden — it only surfaces when the chain is broken or the developer mode
|
||||
/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail
|
||||
/// entry share one source of truth.
|
||||
class _PushSettings extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
final CapabilitiesCubit capabilities;
|
||||
final bool enabled;
|
||||
final bool devMode;
|
||||
final bool showChainStatus;
|
||||
|
||||
const _PushSettings({
|
||||
required this.settings,
|
||||
required this.capabilities,
|
||||
required this.enabled,
|
||||
required this.devMode,
|
||||
required this.showChainStatus,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PushSettings> createState() => _PushSettingsState();
|
||||
}
|
||||
|
||||
class _PushSettingsState extends State<_PushSettings>
|
||||
with WidgetsBindingObserver {
|
||||
PushStatusReport? _report;
|
||||
|
||||
/// True while a (de)registration triggered by the switch is in flight. The
|
||||
/// report collected in that window still reflects the pre-registration state,
|
||||
/// so the status is shown as "loading" instead of briefly flashing red.
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PushSettings oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Toggling the setting changes several links at once — re-collect.
|
||||
if (oldWidget.enabled != widget.enabled) unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// The OS permission can change while the app is backgrounded.
|
||||
if (state == AppLifecycleState.resumed) unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (!widget.showChainStatus) return;
|
||||
final caps = widget.capabilities.state;
|
||||
final report = await collectPushStatus(
|
||||
settingEnabled: widget.settings.val().notificationSettings.enabled,
|
||||
capabilityPush: caps.pushNotifications,
|
||||
capabilitiesLoaded: caps.loaded,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _report = report);
|
||||
}
|
||||
|
||||
void _onToggle(bool enabled) {
|
||||
widget.settings.val(write: true).notificationSettings.enabled = enabled;
|
||||
// Turning off does NOT unregister: the device stays subscribed so silent
|
||||
// sync pushes keep arriving; the message handler and iOS NSE suppress only
|
||||
// the visible notification (via the mirrored flag). Enabling (re-)registers
|
||||
// and ensures the OS permission.
|
||||
if (!enabled) return;
|
||||
setState(() => _busy = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
unawaited(() async {
|
||||
try {
|
||||
// Only register when the OS permission isn't explicitly denied —
|
||||
// otherwise NC + proxy would push into the void.
|
||||
if (await PushRegistration.requestOsPermission()) {
|
||||
await PushRegistration().register();
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Die Benachrichtigungsberechtigung wurde in den '
|
||||
'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um '
|
||||
'Push-Benachrichtigungen zu erhalten.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) await _load();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = _report;
|
||||
final broken =
|
||||
widget.enabled && !_busy && report != null && !report.chainHealthy;
|
||||
return Column(
|
||||
children: [
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
title: 'Push-Benachrichtigungen',
|
||||
subtitle: widget.showChainStatus
|
||||
? 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten'
|
||||
: 'Benachrichtigungen der Schule erhalten',
|
||||
value: widget.enabled,
|
||||
beforeCheckbox: _inlineStatusIcon(report),
|
||||
onChanged: _onToggle,
|
||||
),
|
||||
// Detail entry only when there is a problem to fix or for developers.
|
||||
if (widget.showChainStatus && (broken || widget.devMode))
|
||||
_detailTile(error: broken),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Health icon shown before the checkbox — a spinner while a registration is
|
||||
/// in flight, otherwise the green/red verdict once the report has loaded.
|
||||
Widget? _inlineStatusIcon(PushStatusReport? report) {
|
||||
if (_busy) {
|
||||
return const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
if (!widget.enabled || report == null) return null;
|
||||
final healthy = report.chainHealthy;
|
||||
return Icon(
|
||||
healthy ? Icons.check_circle : Icons.cancel,
|
||||
color: healthy ? Colors.green : Theme.of(context).colorScheme.error,
|
||||
semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht',
|
||||
);
|
||||
}
|
||||
|
||||
/// The full status checklist entry — same list-tile footprint whether broken
|
||||
/// or not; a problem is signalled only through the error-colored icon/text.
|
||||
Widget _detailTile({required bool error}) {
|
||||
final color = error ? Theme.of(context).colorScheme.error : null;
|
||||
final textStyle = color == null ? null : TextStyle(color: color);
|
||||
return ListTile(
|
||||
leading: CenteredLeading(
|
||||
Icon(Icons.monitor_heart_outlined, color: color),
|
||||
),
|
||||
title: Text('Push-Status', style: textStyle),
|
||||
subtitle: Text(
|
||||
error
|
||||
? 'Ein Schritt in der Zustellkette ist unterbrochen'
|
||||
: 'Registrierung und Zustellung im Detail',
|
||||
style: textStyle,
|
||||
),
|
||||
trailing: Icon(Icons.arrow_right, color: color),
|
||||
// The sheet can re-register; re-collect on close so the dot reflects it.
|
||||
onTap: () async {
|
||||
await showPushStatusSheet(context);
|
||||
if (mounted) await _load();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../widgets/push_status_sheet.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
|
||||
class TalkSection extends StatelessWidget {
|
||||
@@ -42,180 +35,7 @@ class TalkSection extends StatelessWidget {
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => AppRoutes.openChatBackgroundSettings(context),
|
||||
),
|
||||
_PushSettings(
|
||||
settings: settings,
|
||||
capabilities: context.read<CapabilitiesCubit>(),
|
||||
enabled: settings.val().notificationSettings.enabled,
|
||||
devMode: settings.val().devToolsEnabled,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The push area: the enable switch carries an at-a-glance health icon (green
|
||||
/// check / red X) right before the checkbox, and the detailed status checklist
|
||||
/// is hidden — it only surfaces when the chain is broken or the developer mode
|
||||
/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail
|
||||
/// entry share one source of truth.
|
||||
class _PushSettings extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
final CapabilitiesCubit capabilities;
|
||||
final bool enabled;
|
||||
final bool devMode;
|
||||
|
||||
const _PushSettings({
|
||||
required this.settings,
|
||||
required this.capabilities,
|
||||
required this.enabled,
|
||||
required this.devMode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PushSettings> createState() => _PushSettingsState();
|
||||
}
|
||||
|
||||
class _PushSettingsState extends State<_PushSettings>
|
||||
with WidgetsBindingObserver {
|
||||
PushStatusReport? _report;
|
||||
|
||||
/// True while a (de)registration triggered by the switch is in flight. The
|
||||
/// report collected in that window still reflects the pre-registration state,
|
||||
/// so the status is shown as "loading" instead of briefly flashing red.
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PushSettings oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Toggling the setting changes several links at once — re-collect.
|
||||
if (oldWidget.enabled != widget.enabled) unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// The OS permission can change while the app is backgrounded.
|
||||
if (state == AppLifecycleState.resumed) unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final caps = widget.capabilities.state;
|
||||
final report = await collectPushStatus(
|
||||
settingEnabled: widget.settings.val().notificationSettings.enabled,
|
||||
capabilityPush: caps.pushNotifications,
|
||||
capabilitiesLoaded: caps.loaded,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _report = report);
|
||||
}
|
||||
|
||||
void _onToggle(bool enabled) {
|
||||
widget.settings.val(write: true).notificationSettings.enabled = enabled;
|
||||
// Turning off does NOT unregister: the device stays subscribed so silent
|
||||
// sync pushes keep arriving; the message handler and iOS NSE suppress only
|
||||
// the visible notification (via the mirrored flag). Enabling (re-)registers
|
||||
// and ensures the OS permission.
|
||||
if (!enabled) return;
|
||||
setState(() => _busy = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
unawaited(() async {
|
||||
try {
|
||||
// Only register when the OS permission isn't explicitly denied —
|
||||
// otherwise NC + proxy would push into the void.
|
||||
if (await PushRegistration.requestOsPermission()) {
|
||||
await PushRegistration().register();
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Die Benachrichtigungsberechtigung wurde in den '
|
||||
'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um '
|
||||
'Push-Benachrichtigungen zu erhalten.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) await _load();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = _report;
|
||||
final broken =
|
||||
widget.enabled && !_busy && report != null && !report.chainHealthy;
|
||||
return Column(
|
||||
children: [
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
title: 'Push-Benachrichtigungen',
|
||||
subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
|
||||
value: widget.enabled,
|
||||
beforeCheckbox: _inlineStatusIcon(report),
|
||||
onChanged: _onToggle,
|
||||
),
|
||||
// Detail entry only when there is a problem to fix or for developers.
|
||||
if (broken || widget.devMode) _detailTile(error: broken),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Health icon shown before the checkbox — a spinner while a registration is
|
||||
/// in flight, otherwise the green/red verdict once the report has loaded.
|
||||
Widget? _inlineStatusIcon(PushStatusReport? report) {
|
||||
if (_busy) {
|
||||
return const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
if (!widget.enabled || report == null) return null;
|
||||
final healthy = report.chainHealthy;
|
||||
return Icon(
|
||||
healthy ? Icons.check_circle : Icons.cancel,
|
||||
color: healthy ? Colors.green : Theme.of(context).colorScheme.error,
|
||||
semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht',
|
||||
);
|
||||
}
|
||||
|
||||
/// The full status checklist entry — same list-tile footprint whether broken
|
||||
/// or not; a problem is signalled only through the error-colored icon/text.
|
||||
Widget _detailTile({required bool error}) {
|
||||
final color = error ? Theme.of(context).colorScheme.error : null;
|
||||
final textStyle = color == null ? null : TextStyle(color: color);
|
||||
return ListTile(
|
||||
leading: CenteredLeading(
|
||||
Icon(Icons.monitor_heart_outlined, color: color),
|
||||
),
|
||||
title: Text('Push-Status', style: textStyle),
|
||||
subtitle: Text(
|
||||
error
|
||||
? 'Ein Schritt in der Zustellkette ist unterbrochen'
|
||||
: 'Registrierung und Zustellung im Detail',
|
||||
style: textStyle,
|
||||
),
|
||||
trailing: Icon(Icons.arrow_right, color: color),
|
||||
// The sheet can re-register; re-collect on close so the dot reflects it.
|
||||
onTap: () async {
|
||||
await showPushStatusSheet(context);
|
||||
if (mounted) await _load();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../access/access_requirement.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import 'sections/about_section.dart';
|
||||
import 'sections/account_section.dart';
|
||||
import 'sections/appearance_section.dart';
|
||||
import 'sections/files_section.dart';
|
||||
import 'sections/modules_section.dart';
|
||||
import 'sections/notifications_section.dart';
|
||||
import 'sections/talk_section.dart';
|
||||
import 'sections/timetable_section.dart';
|
||||
|
||||
class Settings extends StatelessWidget {
|
||||
const Settings({super.key});
|
||||
|
||||
/// Sections in display order with the backend identities they need;
|
||||
/// sections the session cannot use are left out.
|
||||
static const List<(Widget, Set<AccessRequirement>)> _sections = [
|
||||
(AccountSection(), {}),
|
||||
(AppearanceSection(), {}),
|
||||
(ModulesSection(), {}),
|
||||
(TimetableSection(), {}),
|
||||
(NotificationsSection(), {}),
|
||||
(TalkSection(), {AccessRequirement.nextcloud}),
|
||||
(FilesSection(), {AccessRequirement.nextcloud}),
|
||||
(AboutSection(), {}),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Einstellungen')),
|
||||
body: ListView(
|
||||
children: const [
|
||||
AccountSection(),
|
||||
Divider(),
|
||||
AppearanceSection(),
|
||||
Divider(),
|
||||
ModulesSection(),
|
||||
Divider(),
|
||||
TimetableSection(),
|
||||
Divider(),
|
||||
TalkSection(),
|
||||
Divider(),
|
||||
FilesSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionManager().current;
|
||||
final visible = [
|
||||
for (final (section, requirements) in _sections)
|
||||
if (requirements.areMetBy(session)) section,
|
||||
];
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Einstellungen')),
|
||||
body: ListView(
|
||||
children: [
|
||||
for (final (i, section) in visible.indexed) ...[
|
||||
if (i > 0) const Divider(),
|
||||
section,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../utils/emoji_detection.dart';
|
||||
import '../../../../utils/url_opener.dart';
|
||||
import '../widgets/highlighted_linkify.dart';
|
||||
@@ -105,7 +105,7 @@ class ChatMessage {
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorListener: (value) {},
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
imageUrl:
|
||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/emoji_text.dart';
|
||||
import '../../../../widget/loading_spinner.dart';
|
||||
@@ -63,10 +63,10 @@ class _MessageReactionsState extends State<MessageReactions> {
|
||||
leading: CenteredLeading(EmojiText(entry.key)),
|
||||
title: Text('${entry.value.length} mal reagiert'),
|
||||
children: entry.value.map((e) {
|
||||
final isSelf = AccountData().getUsername() == e.actorId;
|
||||
final isSelf =
|
||||
SessionManager().requireNextcloud().username == e.actorId;
|
||||
final isGuest =
|
||||
e.actorType ==
|
||||
GetReactionsResponseObjectActorType.guests;
|
||||
e.actorType == GetReactionsResponseObjectActorType.guests;
|
||||
return ListTile(
|
||||
leading: UserAvatar(id: e.actorId, isGroup: false),
|
||||
title: Text(e.actorDisplayName),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../data/open_direct_chat.dart';
|
||||
|
||||
@@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget {
|
||||
(participant) => participant.participantType,
|
||||
);
|
||||
|
||||
final selfId = AccountData().getUsername();
|
||||
final selfId = SessionManager().requireNextcloud().username;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mitglieder')),
|
||||
body: ListView(
|
||||
|
||||
@@ -9,8 +9,8 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_ove
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../utils/downloads/download_job.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
@@ -54,9 +54,10 @@ class SharedItemsPage {
|
||||
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
||||
}
|
||||
|
||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items
|
||||
.where((item) => item.messageParameters?['file']?.path != null)
|
||||
.toList();
|
||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) =>
|
||||
items
|
||||
.where((item) => item.messageParameters?['file']?.path != null)
|
||||
.toList();
|
||||
|
||||
SharedItemsPage buildSharedItemsPage(
|
||||
GetSharedItemsResponse response,
|
||||
@@ -140,7 +141,9 @@ class _SharedItemsViewState extends State<SharedItemsView>
|
||||
Future<void> _load() async {
|
||||
setState(() => _error = null);
|
||||
try {
|
||||
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
||||
final overview = await SharedItemsView.prefetchOverview(
|
||||
widget.room.token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_overview = overview;
|
||||
_prepareTabs();
|
||||
@@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
||||
if (isDownloading) {
|
||||
confirmCancelDownload();
|
||||
} else {
|
||||
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
||||
startDownload(
|
||||
name: _file.name,
|
||||
remoteFile: RemoteFileRef.fromTalk(_file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +539,7 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: _previewUrl,
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
|
||||
@@ -8,9 +8,9 @@ import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dar
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../notification/notification_tasks.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
@@ -51,13 +51,9 @@ class _ChatTileState extends State<ChatTile> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AccountData().waitForPopulation().then((_) {
|
||||
SessionManager().waitForLoad().then((session) {
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => selfUsername = AccountData().isPopulated()
|
||||
? AccountData().getUsername()
|
||||
: null,
|
||||
);
|
||||
setState(() => selfUsername = session?.nextcloud?.username);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart';
|
||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
@@ -186,7 +186,7 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
|
||||
Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) {
|
||||
final canClose = poll.canClose(
|
||||
selfId: AccountData().getUsername(),
|
||||
selfId: SessionManager().requireNextcloud().username,
|
||||
participantType: widget.room.participantType,
|
||||
);
|
||||
if (!_isInteractive && !canClose) return const SizedBox.shrink();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../access/user_role.dart';
|
||||
import '../../../../api/marianumconnect/queries/user_search/user_search_response.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
|
||||
@@ -18,14 +19,14 @@ class UserSearchTile extends StatelessWidget {
|
||||
leading: UserAvatar(id: user.username, isGroup: false),
|
||||
title: Text('${user.firstName} ${user.lastName}'),
|
||||
subtitle: Text(_subtitle),
|
||||
trailing: RoleBadge(userType: user.userType),
|
||||
trailing: RoleBadge(role: UserRole.parse(user.userType)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
String get _subtitle {
|
||||
final className = user.className;
|
||||
if (user.userType == 'STUDENT' &&
|
||||
if (UserRole.parse(user.userType) == UserRole.student &&
|
||||
className != null &&
|
||||
className.isNotEmpty) {
|
||||
return '${user.username} · $className';
|
||||
@@ -36,16 +37,17 @@ class UserSearchTile extends StatelessWidget {
|
||||
|
||||
/// Compact colour-coded badge distinguishing teachers, students and staff.
|
||||
class RoleBadge extends StatelessWidget {
|
||||
final String userType;
|
||||
final UserRole role;
|
||||
|
||||
const RoleBadge({super.key, required this.userType});
|
||||
const RoleBadge({super.key, required this.role});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (userType) {
|
||||
'TEACHER' => ('Lehrkraft', Colors.blue),
|
||||
'STUDENT' => ('Schüler:in', Colors.green),
|
||||
_ => ('Personal', Colors.orange),
|
||||
final (label, color) = switch (role) {
|
||||
UserRole.teacher => ('Lehrkraft', Colors.blue),
|
||||
UserRole.student => ('Schüler:in', Colors.green),
|
||||
UserRole.parent => ('Elternteil', Colors.purple),
|
||||
UserRole.staff || UserRole.unknown => ('Personal', Colors.orange),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
|
||||
@@ -6,11 +6,14 @@ import '../../../extensions/date_time.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../state/app/modules/timetable/policy/timetable_policy.dart';
|
||||
import '../../../state/app/modules/timetable/subject/timetable_subject.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/child_switcher.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
@@ -26,8 +29,9 @@ class Timetable extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TimetableState extends State<Timetable> {
|
||||
final GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState>();
|
||||
TimetableSubject? _calendarSubject;
|
||||
|
||||
/// When non-null the view shows this element's plan inline instead of the
|
||||
/// user's own. Cleared (back to own plan) via the viewing banner.
|
||||
@@ -78,31 +82,39 @@ class _TimetableState extends State<Timetable> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _selected;
|
||||
if (selected == null) return _buildOwnPlan(context);
|
||||
if (selected == null) {
|
||||
final primary = context.watch<TimetableBloc>().subject;
|
||||
if (primary is NoTimetable) return const _NoTimetableView();
|
||||
return _buildPlan<TimetableBloc>(context);
|
||||
}
|
||||
// Scope the foreign bloc to the current selection so switching elements
|
||||
// (or back to the own plan) tears it down and builds a fresh one.
|
||||
return BlocProvider<ForeignTimetableBloc>(
|
||||
return BlocProvider<ScopedTimetableBloc>(
|
||||
key: ValueKey('${selected.type.name}-${selected.id}'),
|
||||
create: (_) => ForeignTimetableBloc(
|
||||
type: selected.type,
|
||||
elementId: selected.id,
|
||||
title: selected.label,
|
||||
),
|
||||
// Builder gives us a context *below* the provider so the foreign bloc is
|
||||
// resolvable inside _buildForeignPlan.
|
||||
create: (_) => ScopedTimetableBloc(subject: ElementTimetable(selected)),
|
||||
// Builder gives us a context *below* the provider so the scoped bloc is
|
||||
// resolvable inside _buildPlan.
|
||||
child: Builder(
|
||||
builder: (context) => _buildForeignPlan(context, selected),
|
||||
builder: (context) => _buildPlan<ScopedTimetableBloc>(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOwnPlan(BuildContext context) {
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
Widget _buildPlan<B extends TimetableBloc>(BuildContext context) {
|
||||
final bloc = context.read<B>();
|
||||
final subject = bloc.subject;
|
||||
// A new subject (child switch) must not inherit the displayed week of the
|
||||
// previous calendar state.
|
||||
if (subject != _calendarSubject) {
|
||||
_calendarSubject = subject;
|
||||
_calendarKey = GlobalKey<TimetableCalendarViewState>();
|
||||
}
|
||||
final innerState = context.watch<B>().state.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||
final policy = TimetablePolicy.resolve(
|
||||
subject: subject,
|
||||
capabilities: context.watch<CapabilitiesCubit>().state,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
@@ -111,87 +123,36 @@ class _TimetableState extends State<Timetable> {
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
// Hides itself unless a guardian has more than one child.
|
||||
const ChildSwitcher(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
if (policy.canManageCustomEvents)
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-initialised
|
||||
// empty TimetableState as "has content" and only shows the error bar
|
||||
// on top — but the calendar view collapses to `SizedBox.shrink()`
|
||||
// while the reference data is missing, leaving the user with a blank
|
||||
// screen. Telling the consumer that "ready" means having reference
|
||||
// data flips it into the proper error-screen path instead.
|
||||
isReady: (state) => state.hasReferenceData,
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||
context,
|
||||
state,
|
||||
apt,
|
||||
canEditSubjectColor: true,
|
||||
),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: capabilities.isTeacher,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
|
||||
final bloc = context.read<ForeignTimetableBloc>();
|
||||
final loadableState = context.watch<ForeignTimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
|
||||
// der Kalender nicht scrollt, aber ScrollNotifications feuert.
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
if (canViewForeign)
|
||||
if (policy.canOpenForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
@@ -201,24 +162,33 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
|
||||
if (subject case ElementTimetable(:final element))
|
||||
_ViewingBanner(element: element, onClose: _backToOwnPlan),
|
||||
Expanded(
|
||||
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
|
||||
// Foreign plans never carry custom events, so unlike the own-plan
|
||||
// view we must not require `customEvents` here.
|
||||
isReady: (state) =>
|
||||
state.rooms != null &&
|
||||
state.subjects != null &&
|
||||
state.schoolHolidays != null,
|
||||
child: LoadableStateConsumer<B, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-
|
||||
// initialised empty TimetableState as "has content" and only
|
||||
// shows the error bar on top — but the calendar view collapses
|
||||
// to `SizedBox.shrink()` while the reference data is missing,
|
||||
// leaving the user with a blank screen.
|
||||
isReady: (state) => state.isReady(
|
||||
needsCustomEvents: subject.supportsCustomEvents,
|
||||
),
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
customEvents: const [],
|
||||
showClassInsteadOfTeacher:
|
||||
selected.type == TimetableElementType.teacher,
|
||||
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||
context,
|
||||
state,
|
||||
apt,
|
||||
canEditSubjectColor: policy.canEditSubjectColors,
|
||||
),
|
||||
onCreateEvent: policy.canManageCustomEvents
|
||||
? _onCreateEventAt
|
||||
: null,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: policy.showClassInsteadOfTeacher,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -233,6 +203,23 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown instead of a plan when the session has none, i.e. a guardian whose
|
||||
/// children are not known (yet).
|
||||
class _NoTimetableView extends StatelessWidget {
|
||||
const _NoTimetableView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final capabilities = context.watch<CapabilitiesCubit>().state;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Stunden & Vertretungsplan')),
|
||||
body: capabilities.loaded
|
||||
? const NoChildrenPlaceholder()
|
||||
: const Center(child: AppProgressIndicator.large()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||
/// is being viewed. Displays which element is shown, lets the user star it, and
|
||||
/// offers a one-tap return to the own plan.
|
||||
|
||||
Reference in New Issue
Block a user