From f6439e9412264a10a35d5df78a953a9dab4c6e14 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Mon, 5 May 2025 23:30:42 +0200 Subject: [PATCH] Add notification settings and platform management Introduced `MemberNotificationSettingsDto` to handle user notification preferences, including notification types and platforms. Implemented notification settings in `MemberDto` and integrated it into controllers for seamless user customization in the application. Added related views for category-based settings management. --- lib/controllers/navigation_controller.dart | 2 +- lib/controllers/notifications_controller.dart | 78 ++++++++- lib/controllers/platform_controller.dart | 20 +++ lib/dtos/member_dto.dart | 2 + lib/dtos/member_dto.freezed.dart | 63 +++++-- lib/dtos/member_dto.g.dart | 7 + .../member_notification_settings_dto.dart | 16 ++ ...ber_notification_settings_dto.freezed.dart | 151 +++++++++++++++++ .../member_notification_settings_dto.g.dart | 21 +++ lib/main.dart | 5 + lib/utils/extensions.dart | 16 ++ .../categories/account_category.dart | 97 +++++++++++ .../categories/notifications_category.dart | 158 ++++++++++++++++++ .../categories/sort_category.dart | 42 +++++ .../account_settings/settings_category.dart | 50 ++++++ .../account_settings/settings_option.dart | 41 +++++ 16 files changed, 746 insertions(+), 23 deletions(-) create mode 100644 lib/controllers/platform_controller.dart create mode 100644 lib/dtos/member_notification_settings_dto.dart create mode 100644 lib/dtos/member_notification_settings_dto.freezed.dart create mode 100644 lib/dtos/member_notification_settings_dto.g.dart create mode 100644 lib/views/account_settings/categories/account_category.dart create mode 100644 lib/views/account_settings/categories/notifications_category.dart create mode 100644 lib/views/account_settings/categories/sort_category.dart create mode 100644 lib/views/account_settings/settings_category.dart create mode 100644 lib/views/account_settings/settings_option.dart diff --git a/lib/controllers/navigation_controller.dart b/lib/controllers/navigation_controller.dart index c020e4e..9c7d2a2 100644 --- a/lib/controllers/navigation_controller.dart +++ b/lib/controllers/navigation_controller.dart @@ -16,7 +16,7 @@ import 'package:application/l10n/app_localizations.dart'; import 'package:application/utils/analytics.dart'; import 'package:application/utils/constant.dart'; import 'package:application/utils/extensions.dart'; -import 'package:application/views/account_settings_view.dart'; +import 'package:application/views/account_settings/account_settings_view.dart'; import 'package:application/views/search_view.dart'; import 'package:flutter/material.dart'; diff --git a/lib/controllers/notifications_controller.dart b/lib/controllers/notifications_controller.dart index 77ef4fe..84e146d 100644 --- a/lib/controllers/notifications_controller.dart +++ b/lib/controllers/notifications_controller.dart @@ -1,10 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'package:application/controllers/member_controller.dart'; import 'package:application/controllers/shared_preferences_controller.dart'; import 'package:application/dtos/enums/config_property_key.dart'; import 'package:application/dtos/member_dto.dart'; +import 'package:application/dtos/member_notification_settings_dto.dart'; +import 'package:application/dtos/platform_dto.dart'; import 'package:application/utils/constant.dart'; +import 'package:application/utils/http_request.dart'; import 'package:application/views/request_notification_view.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; @@ -17,8 +21,10 @@ class NotificationsController { static const String _topicGlobal = 'global'; - final StreamController streamController = + final StreamController typeStreamController = StreamController.broadcast(); + final StreamController> platformStreamController = + StreamController>.broadcast(); FirebaseMessaging? _messaging; NotificationsType? tmpNotificationsType; @@ -32,7 +38,12 @@ class NotificationsController { Future init(final BuildContext context) async { if (!isSupported) { - await _setAndIgnore(NotificationsType.none); + if (!SharedPreferencesController.instance.containsKey( + ConfigPropertyKey.notificationsType, + )) { + await _setAndIgnore(NotificationsType.none); + } + return; } @@ -43,6 +54,13 @@ class NotificationsController { ConfigPropertyKey.notificationsType, )) { debugPrint('Notifications type already set'); + + if (MemberController.instance.member!.notificationSettings == null) { + debugPrint('Member notification settings not set, sending request'); + await _setAndIgnore(NotificationsType.none); + return; + } + return; } @@ -88,6 +106,7 @@ class NotificationsController { } final MemberDto? member = MemberController.instance.member; + if (member == null) { debugPrint('Member not connected, impossible to configure notifications'); return false; @@ -98,6 +117,8 @@ class NotificationsController { type.index, ); + await _post(type: type); + switch (type) { case NotificationsType.all: await _messaging!.subscribeToTopic(_topicGlobal); @@ -110,7 +131,11 @@ class NotificationsController { await _messaging!.unsubscribeFromTopic(member.uuid); } - streamController.add(type); + typeStreamController.add(type); + platformStreamController.add( + MemberController.instance.member!.notificationSettings?.platforms ?? + [], + ); return true; } @@ -137,6 +162,51 @@ class NotificationsController { ConfigPropertyKey.notificationsType, type.index, ); - streamController.add(type); + + await _post(type: type); + + typeStreamController.add(type); + platformStreamController.add( + MemberController.instance.member!.notificationSettings?.platforms ?? + [], + ); + } + + Future _post({ + final NotificationsType? type, + final List platforms = const [], + }) async { + await HttpRequest.instance.post( + '/v1/members/notification-settings', + token: MemberController.instance.member!.token, + body: jsonEncode( + MemberNotificationSettingsDto( + type: (type ?? notificationsType).name.toUpperCase(), + platforms: platforms, + ).toJson(), + ), + ); + } + + Future togglePlatform(final PlatformDto platform) async { + final MemberDto? member = MemberController.instance.member; + + if (member == null) { + debugPrint('Member not connected, impossible to configure notifications'); + return; + } + + final List platforms = + member.notificationSettings?.platforms ?? []; + + if (platforms.any((final PlatformDto p) => p.id == platform.id)) { + platforms.removeWhere((final PlatformDto p) => p.id == platform.id); + } else { + platforms.add(platform); + } + + await _post(platforms: platforms); + + platformStreamController.add(platforms); } } diff --git a/lib/controllers/platform_controller.dart b/lib/controllers/platform_controller.dart new file mode 100644 index 0000000..dc69476 --- /dev/null +++ b/lib/controllers/platform_controller.dart @@ -0,0 +1,20 @@ +import 'package:application/controllers/generic_controller.dart'; +import 'package:application/dtos/platform_dto.dart'; +import 'package:application/utils/http_request.dart'; + +class PlatformController extends GenericController { + static final PlatformController instance = PlatformController(); + + @override + Future> fetchItems() async { + final List json = await HttpRequest.instance.get>( + '/v1/platforms', + ); + + final Iterable list = json.map( + (final dynamic e) => PlatformDto.fromJson(e as Map), + ); + + return list; + } +} diff --git a/lib/dtos/member_dto.dart b/lib/dtos/member_dto.dart index 128ea7a..5a00966 100644 --- a/lib/dtos/member_dto.dart +++ b/lib/dtos/member_dto.dart @@ -1,3 +1,4 @@ +import 'package:application/dtos/member_notification_settings_dto.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'member_dto.freezed.dart'; @@ -15,6 +16,7 @@ sealed class MemberDto with _$MemberDto { required final int totalDuration, required final int totalUnseenDuration, required final String? attachmentLastUpdateDateTime, + required final MemberNotificationSettingsDto? notificationSettings, }) = _MemberDto; factory MemberDto.fromJson(final Map json) => diff --git a/lib/dtos/member_dto.freezed.dart b/lib/dtos/member_dto.freezed.dart index 9e76ca1..c891dcc 100644 --- a/lib/dtos/member_dto.freezed.dart +++ b/lib/dtos/member_dto.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$MemberDto { - String get uuid; String get token; String get creationDateTime; String? get email; List get followedAnimes; List get followedEpisodes; int get totalDuration; int get totalUnseenDuration; String? get attachmentLastUpdateDateTime; + String get uuid; String get token; String get creationDateTime; String? get email; List get followedAnimes; List get followedEpisodes; int get totalDuration; int get totalUnseenDuration; String? get attachmentLastUpdateDateTime; MemberNotificationSettingsDto? get notificationSettings; /// Create a copy of MemberDto /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -29,16 +29,16 @@ $MemberDtoCopyWith get copyWith => _$MemberDtoCopyWithImpl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MemberDto&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.token, token) || other.token == token)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.email, email) || other.email == email)&&const DeepCollectionEquality().equals(other.followedAnimes, followedAnimes)&&const DeepCollectionEquality().equals(other.followedEpisodes, followedEpisodes)&&(identical(other.totalDuration, totalDuration) || other.totalDuration == totalDuration)&&(identical(other.totalUnseenDuration, totalUnseenDuration) || other.totalUnseenDuration == totalUnseenDuration)&&(identical(other.attachmentLastUpdateDateTime, attachmentLastUpdateDateTime) || other.attachmentLastUpdateDateTime == attachmentLastUpdateDateTime)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MemberDto&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.token, token) || other.token == token)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.email, email) || other.email == email)&&const DeepCollectionEquality().equals(other.followedAnimes, followedAnimes)&&const DeepCollectionEquality().equals(other.followedEpisodes, followedEpisodes)&&(identical(other.totalDuration, totalDuration) || other.totalDuration == totalDuration)&&(identical(other.totalUnseenDuration, totalUnseenDuration) || other.totalUnseenDuration == totalUnseenDuration)&&(identical(other.attachmentLastUpdateDateTime, attachmentLastUpdateDateTime) || other.attachmentLastUpdateDateTime == attachmentLastUpdateDateTime)&&(identical(other.notificationSettings, notificationSettings) || other.notificationSettings == notificationSettings)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,uuid,token,creationDateTime,email,const DeepCollectionEquality().hash(followedAnimes),const DeepCollectionEquality().hash(followedEpisodes),totalDuration,totalUnseenDuration,attachmentLastUpdateDateTime); +int get hashCode => Object.hash(runtimeType,uuid,token,creationDateTime,email,const DeepCollectionEquality().hash(followedAnimes),const DeepCollectionEquality().hash(followedEpisodes),totalDuration,totalUnseenDuration,attachmentLastUpdateDateTime,notificationSettings); @override String toString() { - return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, email: $email, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration, totalUnseenDuration: $totalUnseenDuration, attachmentLastUpdateDateTime: $attachmentLastUpdateDateTime)'; + return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, email: $email, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration, totalUnseenDuration: $totalUnseenDuration, attachmentLastUpdateDateTime: $attachmentLastUpdateDateTime, notificationSettings: $notificationSettings)'; } @@ -49,11 +49,11 @@ abstract mixin class $MemberDtoCopyWith<$Res> { factory $MemberDtoCopyWith(MemberDto value, $Res Function(MemberDto) _then) = _$MemberDtoCopyWithImpl; @useResult $Res call({ - String uuid, String token, String creationDateTime, String? email, List followedAnimes, List followedEpisodes, int totalDuration, int totalUnseenDuration, String? attachmentLastUpdateDateTime + String uuid, String token, String creationDateTime, String? email, List followedAnimes, List followedEpisodes, int totalDuration, int totalUnseenDuration, String? attachmentLastUpdateDateTime, MemberNotificationSettingsDto? notificationSettings }); - +$MemberNotificationSettingsDtoCopyWith<$Res>? get notificationSettings; } /// @nodoc @@ -66,7 +66,7 @@ class _$MemberDtoCopyWithImpl<$Res> /// Create a copy of MemberDto /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? uuid = null,Object? token = null,Object? creationDateTime = null,Object? email = freezed,Object? followedAnimes = null,Object? followedEpisodes = null,Object? totalDuration = null,Object? totalUnseenDuration = null,Object? attachmentLastUpdateDateTime = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? uuid = null,Object? token = null,Object? creationDateTime = null,Object? email = freezed,Object? followedAnimes = null,Object? followedEpisodes = null,Object? totalDuration = null,Object? totalUnseenDuration = null,Object? attachmentLastUpdateDateTime = freezed,Object? notificationSettings = freezed,}) { return _then(_self.copyWith( uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable @@ -77,10 +77,23 @@ as List,followedEpisodes: null == followedEpisodes ? _self.followedEpiso as List,totalDuration: null == totalDuration ? _self.totalDuration : totalDuration // ignore: cast_nullable_to_non_nullable as int,totalUnseenDuration: null == totalUnseenDuration ? _self.totalUnseenDuration : totalUnseenDuration // ignore: cast_nullable_to_non_nullable as int,attachmentLastUpdateDateTime: freezed == attachmentLastUpdateDateTime ? _self.attachmentLastUpdateDateTime : attachmentLastUpdateDateTime // ignore: cast_nullable_to_non_nullable -as String?, +as String?,notificationSettings: freezed == notificationSettings ? _self.notificationSettings : notificationSettings // ignore: cast_nullable_to_non_nullable +as MemberNotificationSettingsDto?, )); } - +/// Create a copy of MemberDto +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MemberNotificationSettingsDtoCopyWith<$Res>? get notificationSettings { + if (_self.notificationSettings == null) { + return null; + } + + return $MemberNotificationSettingsDtoCopyWith<$Res>(_self.notificationSettings!, (value) { + return _then(_self.copyWith(notificationSettings: value)); + }); +} } @@ -88,7 +101,7 @@ as String?, @JsonSerializable() class _MemberDto implements MemberDto { - const _MemberDto({required this.uuid, required this.token, required this.creationDateTime, required this.email, required this.followedAnimes, required this.followedEpisodes, required this.totalDuration, required this.totalUnseenDuration, required this.attachmentLastUpdateDateTime}); + const _MemberDto({required this.uuid, required this.token, required this.creationDateTime, required this.email, required this.followedAnimes, required this.followedEpisodes, required this.totalDuration, required this.totalUnseenDuration, required this.attachmentLastUpdateDateTime, required this.notificationSettings}); factory _MemberDto.fromJson(Map json) => _$MemberDtoFromJson(json); @override final String uuid; @@ -100,6 +113,7 @@ class _MemberDto implements MemberDto { @override final int totalDuration; @override final int totalUnseenDuration; @override final String? attachmentLastUpdateDateTime; +@override final MemberNotificationSettingsDto? notificationSettings; /// Create a copy of MemberDto /// with the given fields replaced by the non-null parameter values. @@ -114,16 +128,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _MemberDto&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.token, token) || other.token == token)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.email, email) || other.email == email)&&const DeepCollectionEquality().equals(other.followedAnimes, followedAnimes)&&const DeepCollectionEquality().equals(other.followedEpisodes, followedEpisodes)&&(identical(other.totalDuration, totalDuration) || other.totalDuration == totalDuration)&&(identical(other.totalUnseenDuration, totalUnseenDuration) || other.totalUnseenDuration == totalUnseenDuration)&&(identical(other.attachmentLastUpdateDateTime, attachmentLastUpdateDateTime) || other.attachmentLastUpdateDateTime == attachmentLastUpdateDateTime)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MemberDto&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.token, token) || other.token == token)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.email, email) || other.email == email)&&const DeepCollectionEquality().equals(other.followedAnimes, followedAnimes)&&const DeepCollectionEquality().equals(other.followedEpisodes, followedEpisodes)&&(identical(other.totalDuration, totalDuration) || other.totalDuration == totalDuration)&&(identical(other.totalUnseenDuration, totalUnseenDuration) || other.totalUnseenDuration == totalUnseenDuration)&&(identical(other.attachmentLastUpdateDateTime, attachmentLastUpdateDateTime) || other.attachmentLastUpdateDateTime == attachmentLastUpdateDateTime)&&(identical(other.notificationSettings, notificationSettings) || other.notificationSettings == notificationSettings)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,uuid,token,creationDateTime,email,const DeepCollectionEquality().hash(followedAnimes),const DeepCollectionEquality().hash(followedEpisodes),totalDuration,totalUnseenDuration,attachmentLastUpdateDateTime); +int get hashCode => Object.hash(runtimeType,uuid,token,creationDateTime,email,const DeepCollectionEquality().hash(followedAnimes),const DeepCollectionEquality().hash(followedEpisodes),totalDuration,totalUnseenDuration,attachmentLastUpdateDateTime,notificationSettings); @override String toString() { - return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, email: $email, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration, totalUnseenDuration: $totalUnseenDuration, attachmentLastUpdateDateTime: $attachmentLastUpdateDateTime)'; + return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, email: $email, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration, totalUnseenDuration: $totalUnseenDuration, attachmentLastUpdateDateTime: $attachmentLastUpdateDateTime, notificationSettings: $notificationSettings)'; } @@ -134,11 +148,11 @@ abstract mixin class _$MemberDtoCopyWith<$Res> implements $MemberDtoCopyWith<$Re factory _$MemberDtoCopyWith(_MemberDto value, $Res Function(_MemberDto) _then) = __$MemberDtoCopyWithImpl; @override @useResult $Res call({ - String uuid, String token, String creationDateTime, String? email, List followedAnimes, List followedEpisodes, int totalDuration, int totalUnseenDuration, String? attachmentLastUpdateDateTime + String uuid, String token, String creationDateTime, String? email, List followedAnimes, List followedEpisodes, int totalDuration, int totalUnseenDuration, String? attachmentLastUpdateDateTime, MemberNotificationSettingsDto? notificationSettings }); - +@override $MemberNotificationSettingsDtoCopyWith<$Res>? get notificationSettings; } /// @nodoc @@ -151,7 +165,7 @@ class __$MemberDtoCopyWithImpl<$Res> /// Create a copy of MemberDto /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? uuid = null,Object? token = null,Object? creationDateTime = null,Object? email = freezed,Object? followedAnimes = null,Object? followedEpisodes = null,Object? totalDuration = null,Object? totalUnseenDuration = null,Object? attachmentLastUpdateDateTime = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? uuid = null,Object? token = null,Object? creationDateTime = null,Object? email = freezed,Object? followedAnimes = null,Object? followedEpisodes = null,Object? totalDuration = null,Object? totalUnseenDuration = null,Object? attachmentLastUpdateDateTime = freezed,Object? notificationSettings = freezed,}) { return _then(_MemberDto( uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable @@ -162,11 +176,24 @@ as List,followedEpisodes: null == followedEpisodes ? _self.followedEpiso as List,totalDuration: null == totalDuration ? _self.totalDuration : totalDuration // ignore: cast_nullable_to_non_nullable as int,totalUnseenDuration: null == totalUnseenDuration ? _self.totalUnseenDuration : totalUnseenDuration // ignore: cast_nullable_to_non_nullable as int,attachmentLastUpdateDateTime: freezed == attachmentLastUpdateDateTime ? _self.attachmentLastUpdateDateTime : attachmentLastUpdateDateTime // ignore: cast_nullable_to_non_nullable -as String?, +as String?,notificationSettings: freezed == notificationSettings ? _self.notificationSettings : notificationSettings // ignore: cast_nullable_to_non_nullable +as MemberNotificationSettingsDto?, )); } - +/// Create a copy of MemberDto +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MemberNotificationSettingsDtoCopyWith<$Res>? get notificationSettings { + if (_self.notificationSettings == null) { + return null; + } + + return $MemberNotificationSettingsDtoCopyWith<$Res>(_self.notificationSettings!, (value) { + return _then(_self.copyWith(notificationSettings: value)); + }); +} } // dart format on diff --git a/lib/dtos/member_dto.g.dart b/lib/dtos/member_dto.g.dart index 6593178..d233c99 100644 --- a/lib/dtos/member_dto.g.dart +++ b/lib/dtos/member_dto.g.dart @@ -22,6 +22,12 @@ _MemberDto _$MemberDtoFromJson(Map json) => _MemberDto( totalDuration: (json['totalDuration'] as num).toInt(), totalUnseenDuration: (json['totalUnseenDuration'] as num).toInt(), attachmentLastUpdateDateTime: json['attachmentLastUpdateDateTime'] as String?, + notificationSettings: + json['notificationSettings'] == null + ? null + : MemberNotificationSettingsDto.fromJson( + json['notificationSettings'] as Map, + ), ); Map _$MemberDtoToJson(_MemberDto instance) => @@ -35,4 +41,5 @@ Map _$MemberDtoToJson(_MemberDto instance) => 'totalDuration': instance.totalDuration, 'totalUnseenDuration': instance.totalUnseenDuration, 'attachmentLastUpdateDateTime': instance.attachmentLastUpdateDateTime, + 'notificationSettings': instance.notificationSettings, }; diff --git a/lib/dtos/member_notification_settings_dto.dart b/lib/dtos/member_notification_settings_dto.dart new file mode 100644 index 0000000..74d4c56 --- /dev/null +++ b/lib/dtos/member_notification_settings_dto.dart @@ -0,0 +1,16 @@ +import 'package:application/dtos/platform_dto.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'member_notification_settings_dto.freezed.dart'; +part 'member_notification_settings_dto.g.dart'; + +@Freezed(makeCollectionsUnmodifiable: false) +sealed class MemberNotificationSettingsDto with _$MemberNotificationSettingsDto { + const factory MemberNotificationSettingsDto({ + required final String type, + required final List platforms, + }) = _MemberNotificationSettingsDto; + + factory MemberNotificationSettingsDto.fromJson(final Map json) => + _$MemberNotificationSettingsDtoFromJson(json); +} diff --git a/lib/dtos/member_notification_settings_dto.freezed.dart b/lib/dtos/member_notification_settings_dto.freezed.dart new file mode 100644 index 0000000..cb2506d --- /dev/null +++ b/lib/dtos/member_notification_settings_dto.freezed.dart @@ -0,0 +1,151 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// 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 'member_notification_settings_dto.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$MemberNotificationSettingsDto { + + String get type; List get platforms; +/// Create a copy of MemberNotificationSettingsDto +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MemberNotificationSettingsDtoCopyWith get copyWith => _$MemberNotificationSettingsDtoCopyWithImpl(this as MemberNotificationSettingsDto, _$identity); + + /// Serializes this MemberNotificationSettingsDto to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MemberNotificationSettingsDto&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.platforms, platforms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(platforms)); + +@override +String toString() { + return 'MemberNotificationSettingsDto(type: $type, platforms: $platforms)'; +} + + +} + +/// @nodoc +abstract mixin class $MemberNotificationSettingsDtoCopyWith<$Res> { + factory $MemberNotificationSettingsDtoCopyWith(MemberNotificationSettingsDto value, $Res Function(MemberNotificationSettingsDto) _then) = _$MemberNotificationSettingsDtoCopyWithImpl; +@useResult +$Res call({ + String type, List platforms +}); + + + + +} +/// @nodoc +class _$MemberNotificationSettingsDtoCopyWithImpl<$Res> + implements $MemberNotificationSettingsDtoCopyWith<$Res> { + _$MemberNotificationSettingsDtoCopyWithImpl(this._self, this._then); + + final MemberNotificationSettingsDto _self; + final $Res Function(MemberNotificationSettingsDto) _then; + +/// Create a copy of MemberNotificationSettingsDto +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? platforms = null,}) { + return _then(_self.copyWith( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,platforms: null == platforms ? _self.platforms : platforms // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _MemberNotificationSettingsDto implements MemberNotificationSettingsDto { + const _MemberNotificationSettingsDto({required this.type, required this.platforms}); + factory _MemberNotificationSettingsDto.fromJson(Map json) => _$MemberNotificationSettingsDtoFromJson(json); + +@override final String type; +@override final List platforms; + +/// Create a copy of MemberNotificationSettingsDto +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MemberNotificationSettingsDtoCopyWith<_MemberNotificationSettingsDto> get copyWith => __$MemberNotificationSettingsDtoCopyWithImpl<_MemberNotificationSettingsDto>(this, _$identity); + +@override +Map toJson() { + return _$MemberNotificationSettingsDtoToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MemberNotificationSettingsDto&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.platforms, platforms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(platforms)); + +@override +String toString() { + return 'MemberNotificationSettingsDto(type: $type, platforms: $platforms)'; +} + + +} + +/// @nodoc +abstract mixin class _$MemberNotificationSettingsDtoCopyWith<$Res> implements $MemberNotificationSettingsDtoCopyWith<$Res> { + factory _$MemberNotificationSettingsDtoCopyWith(_MemberNotificationSettingsDto value, $Res Function(_MemberNotificationSettingsDto) _then) = __$MemberNotificationSettingsDtoCopyWithImpl; +@override @useResult +$Res call({ + String type, List platforms +}); + + + + +} +/// @nodoc +class __$MemberNotificationSettingsDtoCopyWithImpl<$Res> + implements _$MemberNotificationSettingsDtoCopyWith<$Res> { + __$MemberNotificationSettingsDtoCopyWithImpl(this._self, this._then); + + final _MemberNotificationSettingsDto _self; + final $Res Function(_MemberNotificationSettingsDto) _then; + +/// Create a copy of MemberNotificationSettingsDto +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? platforms = null,}) { + return _then(_MemberNotificationSettingsDto( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,platforms: null == platforms ? _self.platforms : platforms // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/dtos/member_notification_settings_dto.g.dart b/lib/dtos/member_notification_settings_dto.g.dart new file mode 100644 index 0000000..911e35d --- /dev/null +++ b/lib/dtos/member_notification_settings_dto.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member_notification_settings_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_MemberNotificationSettingsDto _$MemberNotificationSettingsDtoFromJson( + Map json, +) => _MemberNotificationSettingsDto( + type: json['type'] as String, + platforms: + (json['platforms'] as List) + .map((e) => PlatformDto.fromJson(e as Map)) + .toList(), +); + +Map _$MemberNotificationSettingsDtoToJson( + _MemberNotificationSettingsDto instance, +) => {'type': instance.type, 'platforms': instance.platforms}; diff --git a/lib/main.dart b/lib/main.dart index 01ea2a1..22b8dc1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -169,6 +169,11 @@ class MyApp extends StatelessWidget { style: IconButton.styleFrom(foregroundColor: primary), ), dialogTheme: DialogTheme(backgroundColor: canvasColor), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.all(primary), + trackColor: WidgetStateProperty.all(scaffoldBackground), + trackOutlineColor: WidgetStateProperty.all(primary), + ), ) ..addInputDecorationTheme( ElevatedButton.styleFrom( diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 4b47bf3..ab11ca7 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -12,6 +12,22 @@ extension ExtensionsIterable on Iterable { } } +extension ExtensionsWidgetIterable on Iterable { + List joinTo(final Widget separator) { + final List result = []; + + for (int i = 0; i < length; i++) { + result.add(elementAt(i)); + + if (i != length - 1) { + result.add(separator); + } + } + + return result; + } +} + extension ExtensionsThemeData on ThemeData { static final Map _map = {}; static final Map _mapImage = diff --git a/lib/views/account_settings/categories/account_category.dart b/lib/views/account_settings/categories/account_category.dart new file mode 100644 index 0000000..180ecfc --- /dev/null +++ b/lib/views/account_settings/categories/account_category.dart @@ -0,0 +1,97 @@ +import 'package:application/controllers/member_controller.dart'; +import 'package:application/controllers/vibration_controller.dart'; +import 'package:application/dtos/member_dto.dart'; +import 'package:application/l10n/app_localizations.dart'; +import 'package:application/views/account_settings/settings_category.dart'; +import 'package:application/views/account_settings/settings_option.dart'; +import 'package:application/views/associate_email.dart'; +import 'package:application/views/edit_identifier.dart'; +import 'package:application/views/forgot_identifier.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AccountCategory extends StatelessWidget { + const AccountCategory({super.key, this.member}); + + final MemberDto? member; + + @override + Widget build(final BuildContext context) => SettingsCategory( + icon: Icons.person, + title: AppLocalizations.of(context)!.account, + options: [ + SettingsOption( + title: AppLocalizations.of(context)!.identifier, + subtitle: + MemberController.instance.identifier ?? + AppLocalizations.of(context)!.noIdentifier, + tooltip: AppLocalizations.of(context)!.identifierSubtitle, + trailing: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (final BuildContext context) => const EditIdentifier(), + ), + ); + }, + child: const Icon(Icons.edit), + ), + onTap: () { + if (MemberController.instance.identifier == null) { + return; + } + + // Copy to clipboard + Clipboard.setData( + ClipboardData(text: MemberController.instance.identifier!), + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.copy), + Text(AppLocalizations.of(context)!.identifierCopied), + ], + ), + ), + ); + + VibrationController.instance.vibrate(duration: 200, amplitude: 255); + }, + ), + SettingsOption( + title: AppLocalizations.of(context)!.email, + subtitle: member?.email ?? AppLocalizations.of(context)!.noEmail, + trailing: + member?.email == null + ? GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: + (final BuildContext context) => + const AssociateEmail(), + ), + ); + }, + child: const Icon(Icons.edit), + ) + : null, + ), + SettingsOption( + title: AppLocalizations.of(context)!.forgotIdentifier, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (final BuildContext context) => const ForgotIdentifier(), + ), + ); + }, + ), + ], + ); +} diff --git a/lib/views/account_settings/categories/notifications_category.dart b/lib/views/account_settings/categories/notifications_category.dart new file mode 100644 index 0000000..8d9a940 --- /dev/null +++ b/lib/views/account_settings/categories/notifications_category.dart @@ -0,0 +1,158 @@ +import 'package:application/components/platforms/platform_component.dart'; +import 'package:application/controllers/notifications_controller.dart'; +import 'package:application/controllers/platform_controller.dart'; +import 'package:application/dtos/member_dto.dart'; +import 'package:application/dtos/platform_dto.dart'; +import 'package:application/l10n/app_localizations.dart'; +import 'package:application/views/account_settings/settings_category.dart'; +import 'package:application/views/account_settings/settings_option.dart'; +import 'package:flutter/material.dart'; + +class NotificationsCategory extends StatefulWidget { + const NotificationsCategory({super.key, this.member}); + + final MemberDto? member; + + @override + State createState() => _NotificationsCategoryState(); +} + +class _NotificationsCategoryState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((final _) { + PlatformController.instance.init(); + }); + } + + @override + Widget build(final BuildContext context) => SettingsCategory( + icon: Icons.notifications, + title: AppLocalizations.of(context)!.notifications, + separator: true, + options: [ + StreamBuilder( + stream: NotificationsController.instance.typeStreamController.stream, + initialData: NotificationsController.instance.notificationsType, + builder: + ( + final BuildContext context, + final AsyncSnapshot snapshot, + ) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final NotificationsType type in NotificationsType.values) + SettingsOption( + title: AppLocalizations.of( + context, + )!.notificationsType(type.name), + subtitle: AppLocalizations.of( + context, + )!.notificationsSubtitles(type.name), + trailing: + snapshot.data == type ? const Icon(Icons.check) : null, + onTap: () async { + final bool response = await NotificationsController + .instance + .setNotificationsType(type); + + if (response || !context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error), + Expanded( + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( + context, + )!.notificationNotAuthorized, + ), + Text( + AppLocalizations.of( + context, + )!.notificationNotAuthorizedSubtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ), + StreamBuilder>( + stream: PlatformController.instance.streamController.stream, + initialData: PlatformController.instance.items, + builder: ( + final BuildContext context, + final AsyncSnapshot> snapshotPlatforms, + ) { + if (snapshotPlatforms.connectionState != ConnectionState.active) { + return const SizedBox.shrink(); + } + + return StreamBuilder>( + stream: + NotificationsController + .instance + .platformStreamController + .stream, + initialData: widget.member?.notificationSettings?.platforms ?? snapshotPlatforms.data, + builder: + ( + final BuildContext context, + final AsyncSnapshot> snapshot, + ) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final PlatformDto platform in snapshotPlatforms.data!) + SettingsOption( + leading: PlatformComponent(platform: platform), + title: platform.name, + trailing: Switch( + value: + true == + snapshot.data?.any( + (final PlatformDto mp) => mp.id == platform.id, + ), + onChanged: (final bool value) { + NotificationsController.instance.togglePlatform( + platform, + ); + }, + ), + onTap: () async { + await NotificationsController.instance.togglePlatform( + platform, + ); + }, + ), + ], + ), + ); + }, + ), + ], + ); +} diff --git a/lib/views/account_settings/categories/sort_category.dart b/lib/views/account_settings/categories/sort_category.dart new file mode 100644 index 0000000..4b1600a --- /dev/null +++ b/lib/views/account_settings/categories/sort_category.dart @@ -0,0 +1,42 @@ +import 'package:application/controllers/sort_controller.dart'; +import 'package:application/l10n/app_localizations.dart'; +import 'package:application/views/account_settings/settings_category.dart'; +import 'package:application/views/account_settings/settings_option.dart'; +import 'package:flutter/material.dart'; + +class SortCategory extends StatelessWidget { + const SortCategory({super.key}); + + @override + Widget build(final BuildContext context) => SettingsCategory( + icon: Icons.sort, + title: AppLocalizations.of(context)!.sort, + options: [ + StreamBuilder( + stream: SortController.instance.streamController.stream, + initialData: SortController.instance.sortType, + builder: + ( + final BuildContext context, + final AsyncSnapshot snapshot, + ) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final SortType type in SortType.values) + SettingsOption( + title: AppLocalizations.of(context)!.sortType(type.name), + trailing: + snapshot.data == type + ? const Icon(Icons.check) + : null, + onTap: () { + SortController.instance.setSortType(type); + }, + ), + ], + ), + ), + ], + ); +} diff --git a/lib/views/account_settings/settings_category.dart b/lib/views/account_settings/settings_category.dart new file mode 100644 index 0000000..9a9ff02 --- /dev/null +++ b/lib/views/account_settings/settings_category.dart @@ -0,0 +1,50 @@ +import 'package:application/components/card_component.dart'; +import 'package:application/utils/extensions.dart'; +import 'package:flutter/material.dart'; + +class SettingsCategory extends StatelessWidget { + const SettingsCategory({ + required this.icon, + required this.title, + required this.options, + this.separator = false, + super.key, + }); + + final IconData icon; + final String title; + final List options; + final bool separator; + + @override + Widget build(final BuildContext context) => Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Flex( + spacing: 8, + direction: Axis.horizontal, + children: [ + Icon(icon, color: Colors.grey), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ], + ), + ), + CustomCard( + child: Column( + children: + separator ? options.joinTo(const Divider()) : options, + ), + ), + ], + ); +} diff --git a/lib/views/account_settings/settings_option.dart b/lib/views/account_settings/settings_option.dart new file mode 100644 index 0000000..cc4917a --- /dev/null +++ b/lib/views/account_settings/settings_option.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class SettingsOption extends StatelessWidget { + const SettingsOption({ + required this.title, + super.key, + this.subtitle, + this.tooltip, + this.trailing, + this.onTap, + this.leading, + }); + + final String title; + final String? subtitle; + final String? tooltip; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + + @override + Widget build(final BuildContext context) => ListTile( + title: Flex( + spacing: 8, + direction: Axis.horizontal, + children: [ + Text(title), + if (tooltip != null) + Tooltip( + message: tooltip, + child: const Icon(Icons.info_outline, size: 20, color: Colors.grey), + ), + ], + ), + subtitle: + (subtitle != null && subtitle!.isNotEmpty) ? Text(subtitle!) : null, + leading: leading, + trailing: trailing, + onTap: onTap, + ); +}