added guardian letters with chat and multiple answer functionalities

This commit is contained in:
2026-09-20 16:12:45 +02:00
parent 67c935c05b
commit 2423c1a75e
68 changed files with 8348 additions and 226 deletions
+27 -29
View File
@@ -1,4 +1,3 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
@@ -16,14 +15,18 @@ import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/parent_letters/parent_letters_view.dart';
import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.dart';
import 'chat_list/bloc/chat_list_state.dart';
import 'parent_letters/bloc/parent_letters_bloc.dart';
import 'parent_letters/bloc/parent_letters_state.dart';
import 'settings/bloc/settings_cubit.dart';
class AppModule {
@@ -46,6 +49,7 @@ class AppModule {
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
Modules.parentLetters: {AccessRequirement.guardian},
};
static bool isAvailableFor(Modules module, Session? session) =>
@@ -64,6 +68,18 @@ class AppModule {
breakerArea: BreakerArea.timetable,
create: Timetable.new,
),
Modules.parentLetters: AppModule(
Modules.parentLetters,
name: 'Elternbriefe',
icon: () =>
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.mail_outline,
count: state.data?.unreadCount ?? 0,
),
),
create: ParentLettersView.new,
),
Modules.ticker: AppModule(
Modules.ticker,
name: 'Ticker',
@@ -77,34 +93,15 @@ class AppModule {
Modules.talk,
name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) {
final rooms = state.data?.rooms;
if (rooms == null || rooms.data.isEmpty) {
return const Icon(Icons.chat);
}
final messages = rooms.data
.map((e) => e.unreadMessages)
.reduce((a, b) => a + b);
return badges.Badge(
showBadge: messages > 0,
position: badges.BadgePosition.topEnd(top: -3, end: -3),
stackFit: StackFit.loose,
badgeStyle: badges.BadgeStyle(
padding: const EdgeInsets.all(3),
badgeColor: Theme.of(context).primaryColor,
elevation: 1,
),
badgeContent: Text(
'$messages',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
child: const Icon(Icons.chat),
);
},
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.chat,
count:
state.data?.rooms?.data.fold<int>(
0,
(sum, room) => sum + room.unreadMessages,
) ??
0,
),
),
breakerArea: BreakerArea.talk,
create: ChatList.new,
@@ -299,6 +296,7 @@ class AppModule {
enum Modules {
timetable,
parentLetters,
ticker,
talk,
files,
@@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:typed_data';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_exception.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../../utils/random_id.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/parent_letter_repository.dart';
import 'parent_letter_event.dart';
import 'parent_letter_state.dart';
import 'parent_letters_bloc.dart';
/// One letter; [id] is the letter id so each letter keeps its own hydrated
/// cache entry. Changes are mirrored into the app-wide [inbox].
class ParentLetterBloc
extends
LoadableHydratedBloc<
ParentLetterEvent,
ParentLetterState,
ParentLetterRepository
> {
final String letterId;
final ParentLettersBloc inbox;
ParentLetterBloc(this.letterId, {required this.inbox});
@override
String get id => letterId;
@override
Future<void> gatherData() async {
final ParentLetterDetail letter;
try {
letter = await repo.getLetter(letterId);
} on ParentLetterException catch (e) {
if (e.error == ParentLetterError.letterNotFound) {
// Withdrawn: a hydrated copy must not stay readable.
add(Emit((state) => state.copyWith(letter: null)));
unawaited(inbox.refresh(silent: true));
}
rethrow;
}
add(DataGathered((state) => state.copyWith(letter: letter)));
if (!letter.summary.read) inbox.markRead(letterId);
}
Future<void> submitResponse({
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) async {
final letter = await repo.submitResponse(
letterId: letterId,
childId: childId,
answers: answers,
signaturePng: signaturePng,
);
if (isClosed) return;
add(Emit((state) => state.copyWith(letter: letter)));
inbox.applyDetail(letter);
}
Future<void> sendThreadMessage(String body) async {
final message = await repo.sendThreadMessage(
letterId: letterId,
body: body,
clientMessageId: randomUuidV4(),
);
if (isClosed) return;
add(
Emit((state) {
final letter = state.letter;
if (letter == null) return state;
final thread = letter.content.thread;
return state.copyWith(
letter: ParentLetterDetail(
summary: letter.summary,
content: letter.content.copyWith(
thread: thread.copyWith(messages: [...thread.messages, message]),
),
),
);
}),
);
}
Future<Uint8List> loadAttachment(String attachmentId) =>
repo.getAttachment(letterId: letterId, attachmentId: attachmentId);
@override
ParentLetterRepository repository() => ParentLetterRepository();
@override
ParentLetterState fromNothing() => const ParentLetterState();
@override
ParentLetterState fromStorage(Map<String, dynamic> json) =>
ParentLetterState.fromJson(json);
@override
Map<String, dynamic>? toStorage(ParentLetterState state) => state.toJson();
}
@@ -0,0 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'parent_letter_state.dart';
sealed class ParentLetterEvent
extends LoadableHydratedBlocEvent<ParentLetterState> {}
@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
part 'parent_letter_state.freezed.dart';
part 'parent_letter_state.g.dart';
@freezed
abstract class ParentLetterState with _$ParentLetterState {
const factory ParentLetterState({ParentLetterDetail? letter}) =
_ParentLetterState;
factory ParentLetterState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterStateFromJson(json);
}
@@ -0,0 +1,285 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'parent_letter_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ParentLetterState {
ParentLetterDetail? get letter;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ParentLetterStateCopyWith<ParentLetterState> get copyWith => _$ParentLetterStateCopyWithImpl<ParentLetterState>(this as ParentLetterState, _$identity);
/// Serializes this ParentLetterState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as ParentLetterState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLetterState&&(identical(other.letter, _this.letter) || other.letter == _this.letter));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ParentLetterState;
return Object.hash(runtimeType,_this.letter);
}
@override
String toString() {
final _this = this as ParentLetterState;
return 'ParentLetterState(letter: ${_this.letter})';
}
}
/// @nodoc
abstract mixin class $ParentLetterStateCopyWith<$Res> {
factory $ParentLetterStateCopyWith(ParentLetterState value, $Res Function(ParentLetterState) _then) = _$ParentLetterStateCopyWithImpl;
@useResult
$Res call({
ParentLetterDetail? letter
});
}
/// @nodoc
class _$ParentLetterStateCopyWithImpl<$Res>
implements $ParentLetterStateCopyWith<$Res> {
_$ParentLetterStateCopyWithImpl(this._self, this._then);
final ParentLetterState _self;
final $Res Function(ParentLetterState) _then;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? letter = freezed,}) {
return _then(ParentLetterState(
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
as ParentLetterDetail?,
));
}
}
/// Adds pattern-matching-related methods to [ParentLetterState].
extension ParentLetterStatePatterns on ParentLetterState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLetterState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLetterState value) $default,){
final _that = this;
switch (_that) {
case _ParentLetterState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLetterState value)? $default,){
final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( ParentLetterDetail? letter)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that.letter);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( ParentLetterDetail? letter) $default,) {final _that = this;
switch (_that) {
case _ParentLetterState():
return $default(_that.letter);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( ParentLetterDetail? letter)? $default,) {final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that.letter);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ParentLetterState implements ParentLetterState {
const _ParentLetterState({this.letter});
factory _ParentLetterState.fromJson(Map<String, dynamic> json) => _$ParentLetterStateFromJson(json);
@override final ParentLetterDetail? letter;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ParentLetterStateCopyWith<_ParentLetterState> get copyWith => __$ParentLetterStateCopyWithImpl<_ParentLetterState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ParentLetterStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLetterState&&(identical(other.letter, letter) || other.letter == letter));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,letter);
}
@override
String toString() {
return 'ParentLetterState(letter: $letter)';
}
}
/// @nodoc
abstract mixin class _$ParentLetterStateCopyWith<$Res> implements $ParentLetterStateCopyWith<$Res> {
factory _$ParentLetterStateCopyWith(_ParentLetterState value, $Res Function(_ParentLetterState) _then) = __$ParentLetterStateCopyWithImpl;
@override @useResult
$Res call({
ParentLetterDetail? letter
});
}
/// @nodoc
class __$ParentLetterStateCopyWithImpl<$Res>
implements _$ParentLetterStateCopyWith<$Res> {
__$ParentLetterStateCopyWithImpl(this._self, this._then);
final _ParentLetterState _self;
final $Res Function(_ParentLetterState) _then;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? letter = freezed,}) {
return _then(_ParentLetterState(
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
as ParentLetterDetail?,
));
}
}
// dart format on
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterState _$ParentLetterStateFromJson(Map<String, dynamic> json) =>
_ParentLetterState(
letter: json['letter'] == null
? null
: ParentLetterDetail.fromJson(json['letter'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterStateToJson(_ParentLetterState instance) =>
<String, dynamic>{'letter': instance.letter};
@@ -0,0 +1,107 @@
import 'dart:developer';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../parent_letters_logic.dart';
import '../repository/parent_letters_repository.dart';
import 'parent_letters_event.dart';
import 'parent_letters_state.dart';
/// The guardian's letter inbox. Lives app-wide because the module badge and
/// push handling need it outside the page.
class ParentLettersBloc
extends
LoadableHydratedBloc<
ParentLettersEvent,
ParentLettersState,
ParentLettersRepository
> {
@override
Set<AccessRequirement> get requirements => const {AccessRequirement.guardian};
Future<void>? _loading;
/// App start, prefetch, page visit and push handling all ask for the first
/// page within moments of each other; they share one request.
@override
Future<void> gatherData() =>
_loading ??= _loadFirstPage().whenComplete(() => _loading = null);
Future<void> _loadFirstPage() async {
final page = await repo.getLetters();
if (isClosed) return;
add(DataGathered((_) => firstPageState(page)));
}
/// Reloads the first page; older pages loaded via [loadOlder] are dropped.
Future<void> refresh({bool silent = false}) async {
if (!requirementsMet) return;
if (!silent) add(RefetchStarted<ParentLettersState>());
try {
await gatherData();
} catch (e) {
if (isClosed) return;
if (silent) {
log('Silent parent letters refresh failed: $e');
} else {
addLoadingError(e);
}
}
}
Future<void> loadOlder() async {
final letters = innerState?.letters;
if (letters == null || letters.isEmpty) return;
final page = await repo.getLetters(before: letters.last.id);
if (isClosed) return;
add(Emit((state) => appendOlderPage(state, page)));
}
/// Optimistic locally. When nothing could be applied locally (a letter
/// opened from a push is not in the inbox yet), the follow-up refresh picks
/// it up.
void markRead(String letterId) {
final current = innerState;
final updated = current == null ? null : withLetterRead(current, letterId);
final appliedLocally = updated != null && !identical(updated, current);
if (appliedLocally) add(Emit((_) => updated));
repo
.markRead(letterId)
.then((_) {
if (!appliedLocally) _reloadAfterWrite();
})
.catchError((Object e) {
log('Marking parent letter $letterId as read failed: $e');
_reloadAfterWrite();
});
}
/// A request already in flight may predate the write, so it is not joined.
Future<void> _reloadAfterWrite() async {
try {
await _loading;
} on Object {
// Its own caller reports the failure.
}
await refresh(silent: true);
}
/// Takes over a freshly loaded letter.
void applyDetail(ParentLetterDetail detail) =>
add(Emit((state) => withSummary(state, detail.summary)));
@override
ParentLettersRepository repository() => ParentLettersRepository();
@override
ParentLettersState fromNothing() => const ParentLettersState();
@override
ParentLettersState fromStorage(Map<String, dynamic> json) =>
ParentLettersState.fromJson(json);
@override
Map<String, dynamic>? toStorage(ParentLettersState state) => state.toJson();
}
@@ -0,0 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'parent_letters_state.dart';
sealed class ParentLettersEvent
extends LoadableHydratedBlocEvent<ParentLettersState> {}
@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
part 'parent_letters_state.freezed.dart';
part 'parent_letters_state.g.dart';
/// Hydrated inbox. [unreadCount] and [openCount] cover all letters on the
/// server, not just the loaded [letters].
@freezed
abstract class ParentLettersState with _$ParentLettersState {
const factory ParentLettersState({
@Default([]) List<ParentLetterSummary> letters,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLettersState;
factory ParentLettersState.fromJson(Map<String, dynamic> json) =>
_$ParentLettersStateFromJson(json);
}
@@ -0,0 +1,300 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'parent_letters_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ParentLettersState {
List<ParentLetterSummary> get letters; bool get hasMore; int get unreadCount; int get openCount;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ParentLettersStateCopyWith<ParentLettersState> get copyWith => _$ParentLettersStateCopyWithImpl<ParentLettersState>(this as ParentLettersState, _$identity);
/// Serializes this ParentLettersState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as ParentLettersState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _this.letters)&&(identical(other.hasMore, _this.hasMore) || other.hasMore == _this.hasMore)&&(identical(other.unreadCount, _this.unreadCount) || other.unreadCount == _this.unreadCount)&&(identical(other.openCount, _this.openCount) || other.openCount == _this.openCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ParentLettersState;
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.letters),_this.hasMore,_this.unreadCount,_this.openCount);
}
@override
String toString() {
final _this = this as ParentLettersState;
return 'ParentLettersState(letters: ${_this.letters}, hasMore: ${_this.hasMore}, unreadCount: ${_this.unreadCount}, openCount: ${_this.openCount})';
}
}
/// @nodoc
abstract mixin class $ParentLettersStateCopyWith<$Res> {
factory $ParentLettersStateCopyWith(ParentLettersState value, $Res Function(ParentLettersState) _then) = _$ParentLettersStateCopyWithImpl;
@useResult
$Res call({
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
});
}
/// @nodoc
class _$ParentLettersStateCopyWithImpl<$Res>
implements $ParentLettersStateCopyWith<$Res> {
_$ParentLettersStateCopyWithImpl(this._self, this._then);
final ParentLettersState _self;
final $Res Function(ParentLettersState) _then;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
return _then(ParentLettersState(
letters: null == letters ? _self.letters : letters // ignore: cast_nullable_to_non_nullable
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ParentLettersState].
extension ParentLettersStatePatterns on ParentLettersState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLettersState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLettersState value) $default,){
final _that = this;
switch (_that) {
case _ParentLettersState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLettersState value)? $default,){
final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount) $default,) {final _that = this;
switch (_that) {
case _ParentLettersState():
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,) {final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ParentLettersState implements ParentLettersState {
const _ParentLettersState({ List<ParentLetterSummary> letters = const [], this.hasMore = false, this.unreadCount = 0, this.openCount = 0}): _letters = letters;
factory _ParentLettersState.fromJson(Map<String, dynamic> json) => _$ParentLettersStateFromJson(json);
final List<ParentLetterSummary> _letters;
@override@JsonKey() List<ParentLetterSummary> get letters {
if (_letters is EqualUnmodifiableListView) return _letters;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_letters);
}
@override@JsonKey() final bool hasMore;
@override@JsonKey() final int unreadCount;
@override@JsonKey() final int openCount;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ParentLettersStateCopyWith<_ParentLettersState> get copyWith => __$ParentLettersStateCopyWithImpl<_ParentLettersState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ParentLettersStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _letters)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.unreadCount, unreadCount) || other.unreadCount == unreadCount)&&(identical(other.openCount, openCount) || other.openCount == openCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_letters),hasMore,unreadCount,openCount);
}
@override
String toString() {
return 'ParentLettersState(letters: $letters, hasMore: $hasMore, unreadCount: $unreadCount, openCount: $openCount)';
}
}
/// @nodoc
abstract mixin class _$ParentLettersStateCopyWith<$Res> implements $ParentLettersStateCopyWith<$Res> {
factory _$ParentLettersStateCopyWith(_ParentLettersState value, $Res Function(_ParentLettersState) _then) = __$ParentLettersStateCopyWithImpl;
@override @useResult
$Res call({
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
});
}
/// @nodoc
class __$ParentLettersStateCopyWithImpl<$Res>
implements _$ParentLettersStateCopyWith<$Res> {
__$ParentLettersStateCopyWithImpl(this._self, this._then);
final _ParentLettersState _self;
final $Res Function(_ParentLettersState) _then;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
return _then(_ParentLettersState(
letters: null == letters ? _self._letters : letters // ignore: cast_nullable_to_non_nullable
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letters_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLettersState _$ParentLettersStateFromJson(Map<String, dynamic> json) =>
_ParentLettersState(
letters:
(json['letters'] as List<dynamic>?)
?.map(
(e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLettersStateToJson(_ParentLettersState instance) =>
<String, dynamic>{
'letters': instance.letters,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
@@ -0,0 +1,67 @@
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'bloc/parent_letters_state.dart';
ParentLettersState firstPageState(ParentLetterListResponse page) =>
ParentLettersState(
letters: page.items,
hasMore: page.hasMore,
unreadCount: page.unreadCount,
openCount: page.openCount,
);
ParentLettersState appendOlderPage(
ParentLettersState state,
ParentLetterListResponse page,
) {
final known = {for (final letter in state.letters) letter.id};
return state.copyWith(
letters: [
...state.letters,
...page.items.where((letter) => !known.contains(letter.id)),
],
hasMore: page.hasMore,
unreadCount: page.unreadCount,
openCount: page.openCount,
);
}
/// Marks [letterId] as read and lowers the unread counter. Returns [state]
/// itself when the letter is unknown or already read.
ParentLettersState withLetterRead(ParentLettersState state, String letterId) {
final index = state.letters.indexWhere((letter) => letter.id == letterId);
if (index < 0 || state.letters[index].read) return state;
final letters = [...state.letters];
letters[index] = letters[index].copyWith(read: true);
return state.copyWith(
letters: letters,
unreadCount: state.unreadCount > 0 ? state.unreadCount - 1 : 0,
);
}
/// Replaces the letter and moves [ParentLettersState.openCount] along with
/// its status, so answering does not need a reload to fix the counter.
ParentLettersState withSummary(
ParentLettersState state,
ParentLetterSummary summary,
) {
final index = state.letters.indexWhere((letter) => letter.id == summary.id);
if (index < 0) return state;
int open(ParentLetterSummary letter) =>
letter.status == ParentLetterStatus.open ? 1 : 0;
final openCount =
state.openCount - open(state.letters[index]) + open(summary);
final letters = [...state.letters];
letters[index] = summary;
return state.copyWith(
letters: letters,
openCount: openCount < 0 ? 0 : openCount,
);
}
/// [childId] null means all children.
List<ParentLetterSummary> filterLettersByChild(
List<ParentLetterSummary> letters,
String? childId,
) => childId == null
? letters
: letters.where((letter) => letter.childIds.contains(childId)).toList();
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import '../../../../../api/demo/data/demo_parent_letters.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter_attachment.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/post_parent_letter_thread_message.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/submit_parent_letter_response.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/parent_letter_state.dart';
/// Split from [ParentLettersRepository] because the loadable base binds a
/// repository to its state type. Writes never reach this class in demo mode
/// (the views guard them).
class ParentLetterRepository extends Repository<ParentLetterState> {
Future<ParentLetterDetail> getLetter(String letterId) {
if (DemoMode.active) {
return Future.value(DemoParentLetters.detail(letterId));
}
return GetParentLetter().run(letterId);
}
Future<ParentLetterDetail> submitResponse({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => SubmitParentLetterResponse().run(
letterId: letterId,
childId: childId,
answers: answers,
signaturePng: signaturePng,
);
Future<ParentLetterThreadMessage> sendThreadMessage({
required String letterId,
required String body,
required String clientMessageId,
}) => PostParentLetterThreadMessage().run(
letterId: letterId,
body: body,
clientMessageId: clientMessageId,
);
Future<Uint8List> getAttachment({
required String letterId,
required String attachmentId,
}) => GetParentLetterAttachment().run(
letterId: letterId,
attachmentId: attachmentId,
);
}
@@ -0,0 +1,22 @@
import '../../../../../api/demo/data/demo_parent_letters.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letters.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/mark_parent_letter_read.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/parent_letters_state.dart';
class ParentLettersRepository extends Repository<ParentLettersState> {
Future<ParentLetterListResponse> getLetters({String? before}) {
if (DemoMode.active) return Future.value(DemoParentLetters.list());
return GetParentLetters().run(before: before);
}
Future<void> markRead(String letterId) {
if (DemoMode.active) {
DemoParentLetters.markRead(letterId);
return Future.value();
}
return MarkParentLetterRead().run(letterId);
}
}