From 2014ca634af614c959991ea01c01fa2a064d8409 Mon Sep 17 00:00:00 2001 From: eum108 Date: Mon, 15 Jun 2026 20:43:13 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor=20:=20"=EC=95=8C=EB=A6=BC=20type?= =?UTF-8?q?=EC=9D=84=20String=EC=97=90=EC=84=9C=20NotificationType=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 알림 타입을 매직 스트링 대신 NotificationType enum으로 관리. NotificationModel.type, NotificationEntity.type을 String에서 NotificationType으로 변경하고, fromJson/toJson에서 enum value로 매핑. 기존 presentation 레이어의 NotificationState enum은 제거하고 core/constants의 NotificationType으로 대체. --- lib/core/constants/notification_type.dart | 17 +++++++++++++++++ .../response/notification_model.dart | 11 ++++++++--- .../notification/notification_entity.dart | 3 ++- .../notification/states/notification_state.dart | 13 ------------- 4 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 lib/core/constants/notification_type.dart delete mode 100644 lib/presentation/notification/states/notification_state.dart diff --git a/lib/core/constants/notification_type.dart b/lib/core/constants/notification_type.dart new file mode 100644 index 00000000..1d4d82c0 --- /dev/null +++ b/lib/core/constants/notification_type.dart @@ -0,0 +1,17 @@ +enum NotificationType { + chatMessage('CHAT_MESSAGE'), + matchCreated('MATCH_CREATED'), + postComment('POST_COMMENT'), + rateRequest('RATE_REQUEST'), + postLike('POST_LIKE'), + chatMemberLeft('CHAT_MEMBER_LEFT'), + reportReceived('REPORT_RECEIVED'), + reportResolved('REPORT_RESOLVED'), + reportContentDeleted('REPORT_CONTENT_DELETED'), + userSuspended('USER_SUSPENDED') + ; + + final String value; + + const NotificationType(this.value); +} diff --git a/lib/data/models/notification/response/notification_model.dart b/lib/data/models/notification/response/notification_model.dart index f5f40f92..614ac6b8 100644 --- a/lib/data/models/notification/response/notification_model.dart +++ b/lib/data/models/notification/response/notification_model.dart @@ -1,3 +1,4 @@ +import 'package:ondo/core/constants/notification_type.dart'; import 'package:ondo/data/models/base/response/base_model.dart'; import '../../base/response/base_data_model.dart'; @@ -30,7 +31,7 @@ class NotificationDataModel extends BaseDataModel { class NotificationModel extends BaseModel { final int id; - final String type; + final NotificationType type; final String title; final String body; final String target; @@ -49,7 +50,11 @@ class NotificationModel extends BaseModel { factory NotificationModel.fromJson(Map json) => NotificationModel( id: json["id"], - type: json["type"], + type: NotificationType.values + .where( + (t) => t.value == (json["type"] as String), + ) + .first, title: json["title"], body: json["body"], target: json["target"], @@ -60,7 +65,7 @@ class NotificationModel extends BaseModel { @override Map toJson() => { "id": id, - "type": type, + "type": type.value, "title": title, "body": body, "target": target, diff --git a/lib/domain/entities/notification/notification_entity.dart b/lib/domain/entities/notification/notification_entity.dart index 18bb02fb..944832a1 100644 --- a/lib/domain/entities/notification/notification_entity.dart +++ b/lib/domain/entities/notification/notification_entity.dart @@ -1,9 +1,10 @@ +import 'package:ondo/core/constants/notification_type.dart'; import 'package:ondo/core/utils/app_date_utils.dart'; import 'package:ondo/data/models/notification/response/notification_model.dart'; class NotificationEntity { final int id; - final String type; + final NotificationType type; final String title; final String body; final String target; diff --git a/lib/presentation/notification/states/notification_state.dart b/lib/presentation/notification/states/notification_state.dart deleted file mode 100644 index 09759229..00000000 --- a/lib/presentation/notification/states/notification_state.dart +++ /dev/null @@ -1,13 +0,0 @@ -//TODO : 서버의 알림 상태 통일시키기 -enum NotificationState { - newComment("누군가가 댓글을 남겼어요"), - requestChat("누군가가 커피챗을 신청했어요"), - reported("신고가 누적되어 커피챗 및 커뮤니티 활동이 제한되었습니다."), - newReview("누군가로부터 리뷰가 왔어요"), - overHeart("게시물의 좋아요 수가 50개를 넘었어요") - ; - - final String title; - - const NotificationState(this.title); -} From c272a2cdf9d155cb4faafda59d4bad1ac818d186 Mon Sep 17 00:00:00 2001 From: eum108 Date: Mon, 15 Jun 2026 20:43:25 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor=20:=20"=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20ListableWrap?= =?UTF-8?q?per=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationRepository.loadMyNotificationModel, LoadMyNotificationListUseCase의 반환 타입을 List에서 ListableWrapper로 변경. post/chat에서 사용 중인 페이지네이션 패턴과 동일하게 totalElements, totalPages, last 등 서버 페이징 정보를 함께 전달. --- .../notification_repository_impl.dart | 22 +++++++++++++------ .../notification/notification_repository.dart | 6 ++++- .../load_my_notification_list_use_case.dart | 7 +++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/data/repositories/notification/notification_repository_impl.dart b/lib/data/repositories/notification/notification_repository_impl.dart index a039effb..801a265f 100644 --- a/lib/data/repositories/notification/notification_repository_impl.dart +++ b/lib/data/repositories/notification/notification_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:ondo/data/datasource/notification/notification_local_datasource. import 'package:ondo/data/datasource/notification/notification_remote_datasource.dart'; import 'package:ondo/data/models/base/request/base_list_request_model.dart'; import 'package:ondo/data/models/notification/response/notification_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/entities/notification/notification_entity.dart'; import 'package:ondo/domain/repositories/notification/notification_repository.dart'; @@ -17,7 +18,7 @@ class NotificationRepositoryImpl extends NotificationRepository { // ─── 서버 알림 ───────────────────────────────────────────────── @override - Future> loadMyNotificationModel( + Future> loadMyNotificationModel( int size, int page, ) async { @@ -28,15 +29,22 @@ class NotificationRepositoryImpl extends NotificationRepository { final json = await remoteDatasource.loadMyNotificationList(model); - if (json == null) return []; + if (json == null) return ListableWrapper.none(); final res = NotificationDataModel.fromJson(json); - return res.content - .map( - (e) => NotificationEntity.fromNotificationModel(e), - ) - .toList(); + return ListableWrapper( + page: res.page, + size: res.size, + totalElements: res.totalElements, + totalPages: res.totalPages, + last: res.last, + content: res.content + .map( + (e) => NotificationEntity.fromNotificationModel(e), + ) + .toList(), + ); } @override diff --git a/lib/domain/repositories/notification/notification_repository.dart b/lib/domain/repositories/notification/notification_repository.dart index 122ee9d6..1a0dd627 100644 --- a/lib/domain/repositories/notification/notification_repository.dart +++ b/lib/domain/repositories/notification/notification_repository.dart @@ -1,8 +1,12 @@ +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/entities/notification/notification_entity.dart'; abstract class NotificationRepository { // ─── 서버 알림 ───────────────────────────────────────────────── - Future> loadMyNotificationModel(int size, int page); + Future> loadMyNotificationModel( + int size, + int page, + ); Future loadUnreadNotificationCount(); diff --git a/lib/domain/usecases/notification/load_my_notification_list_use_case.dart b/lib/domain/usecases/notification/load_my_notification_list_use_case.dart index 4c190443..1de28089 100644 --- a/lib/domain/usecases/notification/load_my_notification_list_use_case.dart +++ b/lib/domain/usecases/notification/load_my_notification_list_use_case.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/entities/notification/notification_entity.dart'; import 'package:ondo/domain/repositories/notification/notification_repository.dart'; @@ -6,10 +7,10 @@ class LoadMyNotificationListUseCase { LoadMyNotificationListUseCase({required this.repository}); - Future> call({ + Future> call({ required int size, required int page, - }) async { - return (await repository.loadMyNotificationModel(size, page)); + }) { + return repository.loadMyNotificationModel(size, page); } } From 2a517a1c086d6d70d325eea4a68bf1ed34ebe26b Mon Sep 17 00:00:00 2001 From: eum108 Date: Mon, 15 Jun 2026 20:43:44 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat=20:=20"=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=81=EC=9A=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ListableWrapper의 totalPages로 PageView/인디케이터 페이지 수를 결정하고, loadedPages로 로드된 페이지만 렌더링하며 미로드 페이지는 loadMore로 지연 로딩 - 알림 카드 탭 시 read 호출로 읽음 처리, 이미 읽은 알림은 재요청 생략 - 알림 개수 표시를 totalElements 기준으로 변경, 읽은 알림 삭제 시 totalElements 감소 처리 - readAll에서 RxList.assignAll에 lazy map을 직접 전달해 목록이 전부 사라지던 버그 수정 (toList()로 즉시 평가) - 알림 화면 진입을 Get.to 대신 context.push로 변경하고, 재진입 시 컨트롤러를 refresh하도록 라우팅 정리 --- lib/core/router/app_router.dart | 7 +- .../controllers/notification_controller.dart | 87 +++++++++++--- .../screens/notification_screen.dart | 109 +++++++++++------- .../widgets/notification_button.dart | 7 +- .../widgets/notification_card.dart | 57 +++++---- .../widgets/report_notification_card.dart | 77 +++++++------ 6 files changed, 217 insertions(+), 127 deletions(-) diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 0569cb6d..e75301fe 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -45,6 +45,7 @@ import 'package:ondo/presentation/post/screens/post_detail_screen.dart'; // notification import 'package:ondo/core/router/bindings/notification_binding.dart'; +import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; import 'package:ondo/presentation/notification/screens/notification_screen.dart'; // profile @@ -196,7 +197,11 @@ final GoRouter appRouter = GoRouter( path: RoutePaths.notification, name: 'notification', builder: (context, state) { - NotificationBinding().dependencies(); + if (Get.isRegistered()) { + Get.find().refresh(); + } else { + NotificationBinding().dependencies(); + } return const NotificationScreen(); }, ), diff --git a/lib/presentation/notification/controllers/notification_controller.dart b/lib/presentation/notification/controllers/notification_controller.dart index a3773eb4..f3dec2bf 100644 --- a/lib/presentation/notification/controllers/notification_controller.dart +++ b/lib/presentation/notification/controllers/notification_controller.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; import 'package:ondo/core/design_system/components/custom_alert_dialog.dart'; import 'package:ondo/domain/entities/notification/notification_entity.dart'; import 'package:ondo/domain/usecases/notification/delete_all_read_notifications_use_case.dart'; @@ -8,10 +10,18 @@ import 'package:ondo/domain/usecases/notification/read_all_notification_use_case import 'package:ondo/domain/usecases/notification/read_notification_use_case.dart'; class NotificationController extends GetxController { + static const pageSize = 11; + final RxList viewNotificationList = [].obs; final RxInt newNotificationCount = 0.obs; + final RxBool isLoading = false.obs; + final RxBool isLoadingMore = false.obs; + final RxInt currentPageIndex = 0.obs; + final RxInt totalPages = 1.obs; + final RxInt loadedPages = 0.obs; + final RxInt totalElements = 0.obs; final LoadMyNotificationListUseCase loadMyNotificationListUseCase; final LoadUnreadNotificationCountUseCase loadUnreadNotificationCountUseCase; @@ -29,36 +39,74 @@ class NotificationController extends GetxController { @override void onInit() { - _loadMyNotificationList(); - _loadUnreadNotificationCount(); + refresh(); super.onInit(); } + @override + Future refresh() async { + await _loadMyNotificationList(); + await _loadUnreadNotificationCount(); + } + Future _loadMyNotificationList() async { - //TODO : 화면 연동 과정에서 범위 맞추기 - viewNotificationList.assignAll( - await loadMyNotificationListUseCase.call(size: 20, page: 0), - ); + isLoading.value = true; + currentPageIndex.value = 0; + + try { + final result = await loadMyNotificationListUseCase.call( + size: pageSize, + page: 0, + ); + + viewNotificationList.assignAll(result.content); + totalPages.value = result.totalPages > 0 ? result.totalPages : 1; + loadedPages.value = 1; + totalElements.value = result.totalElements; + } finally { + isLoading.value = false; + } + } + + Future loadMore() async { + final page = currentPageIndex.value; + + if (isLoadingMore.value || page >= totalPages.value) return; + + isLoadingMore.value = true; + + try { + final result = await loadMyNotificationListUseCase.call( + size: pageSize, + page: page, + ); + + viewNotificationList.addAll(result.content); + loadedPages.value++; + } finally { + isLoadingMore.value = false; + } } Future _loadUnreadNotificationCount() async { - newNotificationCount.value = await loadUnreadNotificationCountUseCase - .call(); + newNotificationCount.value = await loadUnreadNotificationCountUseCase(); } Future _readAllNotification() async { - await readAllNotificationUseCase.call(); + await readAllNotificationUseCase(); } Future _readNotification(int id) async { - return await readNotificationUseCase.call(id); + return await readNotificationUseCase(id); } Future _deleteAllReadNotification() async { - return deleteAllReadNotificationsUseCase.call(); + return deleteAllReadNotificationsUseCase(); } Future read(NotificationEntity notification) async { + if (notification.read) return; + if (await _readNotification(notification.id)) { final index = viewNotificationList.indexWhere( (e) => e.id == notification.id, @@ -72,23 +120,30 @@ class NotificationController extends GetxController { Future readAll() async { await _readAllNotification(); viewNotificationList.assignAll( - viewNotificationList.map((e) => e.copyWith(read: true)), + viewNotificationList.map((e) => e.copyWith(read: true)).toList(), ); newNotificationCount.value = 0; } - Future deleteAllNotification() async => Get.dialog( - CustomAlertDialog( + Future deleteAllNotification(BuildContext context) async => showDialog( + context: context, + builder: (context) => CustomAlertDialog( title: "알림", comment: "정말 모든 알림을 삭제하시겠어요?", - actionLeft: () => Get.back(), + actionLeft: () => context.pop(), actionRight: () async { if (await _deleteAllReadNotification()) { + final removedCount = viewNotificationList + .where((notification) => notification.read == true) + .length; + viewNotificationList.removeWhere( (notification) => notification.read == true, ); + + totalElements.value -= removedCount; } - Get.back(); + context.pop(); }, rightActionText: "삭제", ), diff --git a/lib/presentation/notification/screens/notification_screen.dart b/lib/presentation/notification/screens/notification_screen.dart index 6eff5f70..a9b6332d 100644 --- a/lib/presentation/notification/screens/notification_screen.dart +++ b/lib/presentation/notification/screens/notification_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/constants/notification_type.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; @@ -9,7 +10,6 @@ import 'package:ondo/core/design_system/app_text_styles.dart'; import 'package:ondo/core/design_system/components/custom_back_button.dart'; import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; -import 'package:ondo/presentation/notification/states/notification_state.dart'; import '../widgets/notification_card.dart'; import '../widgets/report_notification_card.dart'; @@ -35,7 +35,7 @@ class NotificationScreen extends GetView { itemBuilder: (context) => [ PopupMenuItem( padding: AppPadding.popupManuButton, - onTap: controller.deleteAllNotification, + onTap: () => controller.deleteAllNotification(context), height: double.minPositive, child: Align( alignment: Alignment.center, @@ -45,6 +45,18 @@ class NotificationScreen extends GetView { ), ), ), + PopupMenuItem( + padding: AppPadding.popupManuButton, + onTap: controller.readAll, + height: double.minPositive, + child: Align( + alignment: Alignment.center, + child: Text( + "전체 알림 모두 읽기", + style: AppTextStyles.caption(textColor: AppColors.gray90), + ), + ), + ), ], ); @@ -57,7 +69,11 @@ class NotificationScreen extends GetView { AppGap.v16, Obx( () => Expanded( - child: controller.viewNotificationList.isNotEmpty + child: + controller.isLoading.value && + controller.viewNotificationList.isEmpty + ? const Center(child: CircularProgressIndicator()) + : controller.viewNotificationList.isNotEmpty ? _NotificationPageList() : _noMessageIcon(), ), @@ -100,7 +116,7 @@ class _Title extends GetView { AppGap.h12, Obx( () => Text( - "${controller.viewNotificationList.length}", + "${controller.totalElements.value}", style: AppTextStyles.textMedium(textColor: AppColors.gray60), ), ), @@ -113,64 +129,69 @@ class _Title extends GetView { @immutable class _NotificationPageList extends GetView { - final ValueNotifier curIndex = ValueNotifier(0); - @override Widget build(BuildContext context) { final list = controller.viewNotificationList; - final pageCount = (list.length / 11).ceil(); + final pageTotal = controller.totalPages.value; return Column( children: [ Expanded( child: PageView.builder( - itemCount: pageCount, - onPageChanged: (value) => curIndex.value = value, - itemBuilder: (context, pageIndex) { + itemCount: pageTotal, + onPageChanged: (value) { + controller.currentPageIndex.value = value; + }, + itemBuilder: (context, pi) { + if (pi >= controller.loadedPages.value) { + controller.loadMore(); + return const Center(child: CircularProgressIndicator()); + } + + final start = pi * NotificationController.pageSize; final slice = list.sublist( - pageIndex * 11, - min((pageIndex + 1) * 11, list.length), + start, + min( + start + NotificationController.pageSize, + list.length, + ), ); - return ListView.separated( - physics: NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: slice.length, - separatorBuilder: (context, index) => AppGap.v16, - itemBuilder: (context, index) { - final notification = slice[index]; - if (notification.type == NotificationState.reported.title) { - return ReportNotificationCard( - notificationInfo: notification, - ); - } - return NotificationCard( - notificationInfo: notification, - ); - }, + return Column( + children: [ + for (var index = 0; index < slice.length; index++) ...[ + if (index > 0) AppGap.v16, + slice[index].type == NotificationType.reportReceived + ? ReportNotificationCard( + notification: slice[index], + onTap: () => controller.read(slice[index]), + ) + : NotificationCard( + notification: slice[index], + onTap: () => controller.read(slice[index]), + ), + ], + ], ); }, ), ), AppGap.v16, - ValueListenableBuilder( - valueListenable: curIndex, - builder: (_, _, _) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - pageCount, - (index) => Padding( - padding: AppPadding.indicatorSpacing, - child: Text( - "${index + 1}", - style: AppTextStyles.pageIndicator( - isCurrent: curIndex.value == index, - ), + Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + controller.totalPages.value, + (index) => Padding( + padding: AppPadding.indicatorSpacing, + child: Text( + "${index + 1}", + style: AppTextStyles.pageIndicator( + isCurrent: controller.currentPageIndex.value == index, ), ), ), - ); - }, + ), + ), ), AppGap.v16, ], diff --git a/lib/presentation/notification/widgets/notification_button.dart b/lib/presentation/notification/widgets/notification_button.dart index 2b4af53f..fb32e49c 100644 --- a/lib/presentation/notification/widgets/notification_button.dart +++ b/lib/presentation/notification/widgets/notification_button.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/router/app_router.dart'; import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; -import 'package:ondo/presentation/notification/screens/notification_screen.dart'; @immutable class NotificationButton extends GetView { @@ -27,9 +28,7 @@ class NotificationButton extends GetView { minimumSize: Size.square(AppSpacing.s44), ), onPressed: controller.newNotificationCount.value > 0 - ? () => Get.to( - NotificationScreen(), - ) + ? () => context.push(RoutePaths.notification) : null, icon: SvgPicture.asset( controller.newNotificationCount.value > 0 diff --git a/lib/presentation/notification/widgets/notification_card.dart b/lib/presentation/notification/widgets/notification_card.dart index 434e41bd..72ce422d 100644 --- a/lib/presentation/notification/widgets/notification_card.dart +++ b/lib/presentation/notification/widgets/notification_card.dart @@ -8,42 +8,49 @@ import '../../../core/design_system/app_layout.dart'; import '../../../core/design_system/app_text_styles.dart'; class NotificationCard extends StatelessWidget { - final NotificationEntity notificationInfo; + final NotificationEntity notification; final VoidCallback? onTap; const NotificationCard({ super.key, this.onTap, - required this.notificationInfo, + required this.notification, }); @override Widget build(BuildContext context) { return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - child: Row( - children: [ - CustomProfileCircle( - radius: AppSpacing.s24, - //TODO : 서버 프로필 이미지 api 개발 이후에 수정 - imageUrl: null, - ), - AppGap.h12, - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _title(), - Text( - notificationInfo.body, - style: AppTextStyles.caption(textColor: AppColors.gray60), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Opacity( + opacity: notification.read ? .5 : 1.0, + child: Container( + decoration: BoxDecoration(color: AppColors.white), + child: Row( + children: [ + CustomProfileCircle( + radius: AppSpacing.s24, + //TODO : 서버 프로필 이미지 api 개발 이후에 수정 + imageUrl: null, + ), + AppGap.h12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _title(), + Text( + notification.body, + style: AppTextStyles.caption(textColor: AppColors.gray60), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), + ), + ], ), - ], + ), ), ); } @@ -51,14 +58,14 @@ class NotificationCard extends StatelessWidget { Widget _title() => Row( children: [ Text( - notificationInfo.title, + notification.title, style: AppTextStyles.caption(textColor: AppColors.gray90), maxLines: 1, overflow: TextOverflow.ellipsis, ), Spacer(), Text( - AppDateUtils.timeAgo(notificationInfo.createdAt), + AppDateUtils.timeAgo(notification.createdAt), style: AppTextStyles.caption(textColor: AppColors.gray60), ), ], diff --git a/lib/presentation/notification/widgets/report_notification_card.dart b/lib/presentation/notification/widgets/report_notification_card.dart index 23467b49..1c48b601 100644 --- a/lib/presentation/notification/widgets/report_notification_card.dart +++ b/lib/presentation/notification/widgets/report_notification_card.dart @@ -7,55 +7,58 @@ import '../../../core/design_system/app_layout.dart'; import '../../../core/design_system/app_text_styles.dart'; class ReportNotificationCard extends StatelessWidget { - final NotificationEntity notificationInfo; + final NotificationEntity notification; final VoidCallback? onTap; const ReportNotificationCard({ super.key, this.onTap, - required this.notificationInfo, + required this.notification, }); - @override Widget build(BuildContext context) { return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - padding: AppPadding.card, - decoration: BoxDecoration( - borderRadius: AppRadius.baseRadius, - color: AppColors.redLight, - ), - child: Row( - children: [ - CustomProfileCircle( - radius: AppSpacing.s24, - //TODO : 프로필 이미지 api 개발 이후에 수정 - imageUrl: null, - ), - AppGap.h12, - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - notificationInfo.title, - style: AppTextStyles.caption(textColor: AppColors.gray90), - overflow: TextOverflow.ellipsis, - ), - Text( - notificationInfo.body, - style: AppTextStyles.caption(textColor: AppColors.gray70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + child: Opacity( + opacity: notification.read ? .5 : 1.0, + child: Container( + padding: AppPadding.card, + decoration: BoxDecoration( + borderRadius: AppRadius.baseRadius, + color: AppColors.redLight, + ), + child: Row( + children: [ + CustomProfileCircle( + radius: AppSpacing.s24, + //TODO : 프로필 이미지 api 개발 이후에 수정 + imageUrl: null, + ), + AppGap.h12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + notification.title, + style: AppTextStyles.caption(textColor: AppColors.gray90), + overflow: TextOverflow.ellipsis, + ), + Text( + notification.body, + style: AppTextStyles.caption(textColor: AppColors.gray70), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - ], + ], + ), ), ), ); From 86827b0a4cb3c7f0f713e284ca27319414d32532 Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 11:45:17 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[BUG]=20:=20"PopupMenuItem=20height=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20onTap=20=EB=B0=9C=ED=99=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit height: double.minPositive로 선언된 PopupMenuItem의 히트 테스트 오동작으로 전체 읽기 탭 시 읽은 알림 삭제 핸들러가 실행되는 버그 수정 알림 버튼 비활성화 조건 제거 (읽지 않은 알림 없어도 화면 진입 가능) Co-Authored-By: Claude Sonnet 4.6 --- .../notification/screens/notification_screen.dart | 2 -- .../notification/widgets/notification_button.dart | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/presentation/notification/screens/notification_screen.dart b/lib/presentation/notification/screens/notification_screen.dart index a9b6332d..1c21069d 100644 --- a/lib/presentation/notification/screens/notification_screen.dart +++ b/lib/presentation/notification/screens/notification_screen.dart @@ -36,7 +36,6 @@ class NotificationScreen extends GetView { PopupMenuItem( padding: AppPadding.popupManuButton, onTap: () => controller.deleteAllNotification(context), - height: double.minPositive, child: Align( alignment: Alignment.center, child: Text( @@ -48,7 +47,6 @@ class NotificationScreen extends GetView { PopupMenuItem( padding: AppPadding.popupManuButton, onTap: controller.readAll, - height: double.minPositive, child: Align( alignment: Alignment.center, child: Text( diff --git a/lib/presentation/notification/widgets/notification_button.dart b/lib/presentation/notification/widgets/notification_button.dart index fb32e49c..5654a64d 100644 --- a/lib/presentation/notification/widgets/notification_button.dart +++ b/lib/presentation/notification/widgets/notification_button.dart @@ -27,9 +27,7 @@ class NotificationButton extends GetView { shape: RoundedRectangleBorder(borderRadius: AppRadius.baseRadius), minimumSize: Size.square(AppSpacing.s44), ), - onPressed: controller.newNotificationCount.value > 0 - ? () => context.push(RoutePaths.notification) - : null, + onPressed: () => context.push(RoutePaths.notification), icon: SvgPicture.asset( controller.newNotificationCount.value > 0 ? AppIcon.alarmBrown.path From dafbfeec55c1a187078c81b2a782b0a092da8d8e Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 11:59:25 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[BUG]=20:=20"async=20gap=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20BuildContext=20=EC=82=AC=EC=9A=A9=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit await 이후 context.pop() 호출 시 context.mounted 체크 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../notification/controllers/notification_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/presentation/notification/controllers/notification_controller.dart b/lib/presentation/notification/controllers/notification_controller.dart index f3dec2bf..17819e83 100644 --- a/lib/presentation/notification/controllers/notification_controller.dart +++ b/lib/presentation/notification/controllers/notification_controller.dart @@ -143,7 +143,7 @@ class NotificationController extends GetxController { totalElements.value -= removedCount; } - context.pop(); + if (context.mounted) context.pop(); }, rightActionText: "삭제", ), From 2118920b664fb27afc2b7c680fee5489b769ec63 Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 12:09:23 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor=20:=20"=EC=A0=9C=EB=8B=88?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20NotificationController"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadMore 페이지 기준을 currentPageIndex에서 loadedPages로 수정 삭제 후 totalPages/loadedPages/currentPageIndex 재계산 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/notification_controller.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/presentation/notification/controllers/notification_controller.dart b/lib/presentation/notification/controllers/notification_controller.dart index 17819e83..03b681a6 100644 --- a/lib/presentation/notification/controllers/notification_controller.dart +++ b/lib/presentation/notification/controllers/notification_controller.dart @@ -69,18 +69,14 @@ class NotificationController extends GetxController { } Future loadMore() async { - final page = currentPageIndex.value; - - if (isLoadingMore.value || page >= totalPages.value) return; - + final nextPage = loadedPages.value; + if (isLoadingMore.value || nextPage >= totalPages.value) return; isLoadingMore.value = true; - try { final result = await loadMyNotificationListUseCase.call( size: pageSize, - page: page, + page: nextPage, ); - viewNotificationList.addAll(result.content); loadedPages.value++; } finally { @@ -140,8 +136,15 @@ class NotificationController extends GetxController { viewNotificationList.removeWhere( (notification) => notification.read == true, ); - totalElements.value -= removedCount; + totalPages.value = (totalElements.value / pageSize).ceil(); + if (totalPages.value < 1) totalPages.value = 1; + if (loadedPages.value > totalPages.value) { + loadedPages.value = totalPages.value; + } + if (currentPageIndex.value >= totalPages.value) { + currentPageIndex.value = totalPages.value - 1; + } } if (context.mounted) context.pop(); }, From 80414bc5a176279925da5d0578ee54456932518a Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 12:09:27 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor=20:=20"=EC=A0=9C=EB=8B=88?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20NotificationScreen"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sublist start 범위 초과 방지를 위해 min() 처리 Co-Authored-By: Claude Sonnet 4.6 --- .../notification/screens/notification_screen.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/presentation/notification/screens/notification_screen.dart b/lib/presentation/notification/screens/notification_screen.dart index 1c21069d..83cb11e2 100644 --- a/lib/presentation/notification/screens/notification_screen.dart +++ b/lib/presentation/notification/screens/notification_screen.dart @@ -141,11 +141,16 @@ class _NotificationPageList extends GetView { }, itemBuilder: (context, pi) { if (pi >= controller.loadedPages.value) { - controller.loadMore(); + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.loadMore(); + }); return const Center(child: CircularProgressIndicator()); } - final start = pi * NotificationController.pageSize; + final start = min( + pi * NotificationController.pageSize, + list.length, + ); final slice = list.sublist( start, min( From 1de51a3f9f66d9594c3b7c90afb32ddeae599b10 Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 12:10:42 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor=20:=20"=EC=A0=9C=EB=8B=88?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20NotificationType"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unknown 타입 추가 Co-Authored-By: Claude Sonnet 4.6 --- lib/core/constants/notification_type.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/core/constants/notification_type.dart b/lib/core/constants/notification_type.dart index 1d4d82c0..cc598b38 100644 --- a/lib/core/constants/notification_type.dart +++ b/lib/core/constants/notification_type.dart @@ -8,7 +8,8 @@ enum NotificationType { reportReceived('REPORT_RECEIVED'), reportResolved('REPORT_RESOLVED'), reportContentDeleted('REPORT_CONTENT_DELETED'), - userSuspended('USER_SUSPENDED') + userSuspended('USER_SUSPENDED'), + unknown('UNKNOWN') ; final String value; From 69ca166a8129f9962319ff1b63d3b4af6caf6509 Mon Sep 17 00:00:00 2001 From: eum108 Date: Tue, 16 Jun 2026 12:10:51 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor=20:=20"=EC=A0=9C=EB=8B=88?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20NotificationModel"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알 수 없는 타입 수신 시 unknown으로 fallback 처리 Co-Authored-By: Claude Sonnet 4.6 --- .../models/notification/response/notification_model.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/data/models/notification/response/notification_model.dart b/lib/data/models/notification/response/notification_model.dart index 614ac6b8..3150ab8e 100644 --- a/lib/data/models/notification/response/notification_model.dart +++ b/lib/data/models/notification/response/notification_model.dart @@ -50,11 +50,10 @@ class NotificationModel extends BaseModel { factory NotificationModel.fromJson(Map json) => NotificationModel( id: json["id"], - type: NotificationType.values - .where( - (t) => t.value == (json["type"] as String), - ) - .first, + type: NotificationType.values.firstWhere( + (t) => t.value == json["type"], + orElse: () => NotificationType.unknown, + ), title: json["title"], body: json["body"], target: json["target"], From e766da84350ef7df032016f0baeaf446d5c486ef Mon Sep 17 00:00:00 2001 From: user Date: Mon, 22 Jun 2026 01:35:10 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[BUG]=20:=20"HomePostRankItem=20GestureDe?= =?UTF-8?q?tector=20=ED=84=B0=EC=B9=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomePostRankItem의 GestureDetector에 HitTestBehavior.opaque 추가하여 투명 영역에서 탭 이벤트가 발화되지 않던 문제 수정 Resolve: #260 --- lib/presentation/home/widgets/home_post_rank_item.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/presentation/home/widgets/home_post_rank_item.dart b/lib/presentation/home/widgets/home_post_rank_item.dart index 5c6e19c4..fd1ec89f 100644 --- a/lib/presentation/home/widgets/home_post_rank_item.dart +++ b/lib/presentation/home/widgets/home_post_rank_item.dart @@ -23,6 +23,7 @@ class HomePostRankItem extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, child: Row( crossAxisAlignment: CrossAxisAlignment.center, From da367a4a78dd83a5c36d88b50049ff1013a91b60 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 22 Jun 2026 01:35:16 +0900 Subject: [PATCH 11/12] =?UTF-8?q?chore=20:=20"Android=20gradle=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter migrator 자동 추가 builtInKotlin, newDsl 플래그 반영 meta 1.17.0→1.18.0, test_api 0.7.10→0.7.11 버전 업데이트 Resolve: #260 --- android/gradle.properties | 4 ++++ pubspec.lock | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8c..d5da7278 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/pubspec.lock b/pubspec.lock index 7fffb99f..0a5eff06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -492,10 +492,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: transitive description: From 808a8de9eeb5b0751fd48c7f052f564735adb354 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 22 Jun 2026 13:35:50 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[feat]=20:=20"=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20UI=20=EC=A0=81=EC=9A=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/notification_controller.dart | 148 +++++----- .../screens/notification_screen.dart | 258 +++++++++--------- 2 files changed, 210 insertions(+), 196 deletions(-) diff --git a/lib/presentation/notification/controllers/notification_controller.dart b/lib/presentation/notification/controllers/notification_controller.dart index 03b681a6..dfed1153 100644 --- a/lib/presentation/notification/controllers/notification_controller.dart +++ b/lib/presentation/notification/controllers/notification_controller.dart @@ -10,7 +10,7 @@ import 'package:ondo/domain/usecases/notification/read_all_notification_use_case import 'package:ondo/domain/usecases/notification/read_notification_use_case.dart'; class NotificationController extends GetxController { - static const pageSize = 11; + static const _pageSize = 20; final RxList viewNotificationList = [].obs; @@ -18,11 +18,12 @@ class NotificationController extends GetxController { final RxInt newNotificationCount = 0.obs; final RxBool isLoading = false.obs; final RxBool isLoadingMore = false.obs; - final RxInt currentPageIndex = 0.obs; - final RxInt totalPages = 1.obs; - final RxInt loadedPages = 0.obs; + final RxString errorMessage = ''.obs; final RxInt totalElements = 0.obs; + int _currentPage = 0; + bool _isLast = false; + final LoadMyNotificationListUseCase loadMyNotificationListUseCase; final LoadUnreadNotificationCountUseCase loadUnreadNotificationCountUseCase; final ReadAllNotificationUseCase readAllNotificationUseCase; @@ -45,110 +46,119 @@ class NotificationController extends GetxController { @override Future refresh() async { - await _loadMyNotificationList(); - await _loadUnreadNotificationCount(); + await Future.wait([ + _loadMyNotificationList(), + _loadUnreadNotificationCount(), + ]); } Future _loadMyNotificationList() async { + _currentPage = 0; + _isLast = false; + errorMessage.value = ''; isLoading.value = true; - currentPageIndex.value = 0; try { final result = await loadMyNotificationListUseCase.call( - size: pageSize, + size: _pageSize, page: 0, ); - viewNotificationList.assignAll(result.content); - totalPages.value = result.totalPages > 0 ? result.totalPages : 1; - loadedPages.value = 1; + _isLast = result.last ?? true; totalElements.value = result.totalElements; + _currentPage = 1; + } catch (e) { + debugPrint('[NotificationController] 알림 목록 조회 실패 - error: $e'); + errorMessage.value = '알림을 불러오지 못했습니다.'; } finally { isLoading.value = false; } } Future loadMore() async { - final nextPage = loadedPages.value; - if (isLoadingMore.value || nextPage >= totalPages.value) return; + if (_isLast || isLoadingMore.value) return; + isLoadingMore.value = true; try { final result = await loadMyNotificationListUseCase.call( - size: pageSize, - page: nextPage, + size: _pageSize, + page: _currentPage, ); viewNotificationList.addAll(result.content); - loadedPages.value++; + _isLast = result.last ?? true; + _currentPage++; + } catch (e) { + debugPrint('[NotificationController] 알림 추가 로드 실패 - error: $e'); } finally { isLoadingMore.value = false; } } Future _loadUnreadNotificationCount() async { - newNotificationCount.value = await loadUnreadNotificationCountUseCase(); - } - - Future _readAllNotification() async { - await readAllNotificationUseCase(); - } - - Future _readNotification(int id) async { - return await readNotificationUseCase(id); - } - - Future _deleteAllReadNotification() async { - return deleteAllReadNotificationsUseCase(); + try { + newNotificationCount.value = await loadUnreadNotificationCountUseCase(); + } catch (e) { + debugPrint('[NotificationController] 읽지 않은 알림 수 조회 실패 - error: $e'); + } } Future read(NotificationEntity notification) async { if (notification.read) return; - if (await _readNotification(notification.id)) { - final index = viewNotificationList.indexWhere( - (e) => e.id == notification.id, - ); - if (index >= 0) { - viewNotificationList[index] = notification.copyWith(read: true); + try { + if (await _readNotification(notification.id)) { + final index = viewNotificationList.indexWhere( + (e) => e.id == notification.id, + ); + if (index >= 0) { + viewNotificationList[index] = notification.copyWith(read: true); + } } + } catch (e) { + debugPrint('[NotificationController] 알림 읽음 처리 실패 - error: $e'); } } Future readAll() async { - await _readAllNotification(); - viewNotificationList.assignAll( - viewNotificationList.map((e) => e.copyWith(read: true)).toList(), - ); - newNotificationCount.value = 0; + try { + await readAllNotificationUseCase(); + viewNotificationList.assignAll( + viewNotificationList.map((e) => e.copyWith(read: true)).toList(), + ); + newNotificationCount.value = 0; + } catch (e) { + debugPrint('[NotificationController] 전체 읽음 처리 실패 - error: $e'); + } } Future deleteAllNotification(BuildContext context) async => showDialog( - context: context, - builder: (context) => CustomAlertDialog( - title: "알림", - comment: "정말 모든 알림을 삭제하시겠어요?", - actionLeft: () => context.pop(), - actionRight: () async { - if (await _deleteAllReadNotification()) { - final removedCount = viewNotificationList - .where((notification) => notification.read == true) - .length; - - viewNotificationList.removeWhere( - (notification) => notification.read == true, - ); - totalElements.value -= removedCount; - totalPages.value = (totalElements.value / pageSize).ceil(); - if (totalPages.value < 1) totalPages.value = 1; - if (loadedPages.value > totalPages.value) { - loadedPages.value = totalPages.value; - } - if (currentPageIndex.value >= totalPages.value) { - currentPageIndex.value = totalPages.value - 1; - } - } - if (context.mounted) context.pop(); - }, - rightActionText: "삭제", - ), - ); + context: context, + builder: (context) => CustomAlertDialog( + title: "알림", + comment: "정말 모든 알림을 삭제하시겠어요?", + actionLeft: () => context.pop(), + actionRight: () async { + try { + if (await _deleteAllReadNotification()) { + final removedCount = viewNotificationList + .where((n) => n.read == true) + .length; + viewNotificationList.removeWhere((n) => n.read == true); + totalElements.value -= removedCount; + } + } catch (e) { + debugPrint('[NotificationController] 알림 삭제 실패 - error: $e'); + } finally { + if (context.mounted) context.pop(); + } + }, + rightActionText: "삭제", + ), + ); + + Future _readNotification(int id) async => + readNotificationUseCase(id); + + Future _deleteAllReadNotification() async => + deleteAllReadNotificationsUseCase(); } diff --git a/lib/presentation/notification/screens/notification_screen.dart b/lib/presentation/notification/screens/notification_screen.dart index 83cb11e2..83fbdfee 100644 --- a/lib/presentation/notification/screens/notification_screen.dart +++ b/lib/presentation/notification/screens/notification_screen.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/constants/notification_type.dart'; @@ -8,8 +6,8 @@ import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; import 'package:ondo/core/design_system/components/custom_back_button.dart'; -import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; +import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; import '../widgets/notification_card.dart'; import '../widgets/report_notification_card.dart'; @@ -31,71 +29,66 @@ class NotificationScreen extends GetView { } Widget _topBar() => CustomBackButton( - moreOptions: true, - itemBuilder: (context) => [ - PopupMenuItem( - padding: AppPadding.popupManuButton, - onTap: () => controller.deleteAllNotification(context), - child: Align( - alignment: Alignment.center, - child: Text( - "읽은 알림 모두 삭제", - style: AppTextStyles.caption(textColor: AppColors.gray90), + moreOptions: true, + itemBuilder: (context) => [ + PopupMenuItem( + padding: AppPadding.popupManuButton, + onTap: () => controller.deleteAllNotification(context), + child: Align( + alignment: Alignment.center, + child: Text( + "읽은 알림 모두 삭제", + style: AppTextStyles.caption(textColor: AppColors.gray90), + ), + ), ), - ), - ), - PopupMenuItem( - padding: AppPadding.popupManuButton, - onTap: controller.readAll, - child: Align( - alignment: Alignment.center, - child: Text( - "전체 알림 모두 읽기", - style: AppTextStyles.caption(textColor: AppColors.gray90), + PopupMenuItem( + padding: AppPadding.popupManuButton, + onTap: controller.readAll, + child: Align( + alignment: Alignment.center, + child: Text( + "전체 알림 모두 읽기", + style: AppTextStyles.caption(textColor: AppColors.gray90), + ), + ), ), - ), - ), - ], - ); + ], + ); Widget _body() => Container( - color: AppColors.white, - padding: AppPadding.screenHorizontal, - child: Column( - children: [ - _Title(), - AppGap.v16, - Obx( - () => Expanded( - child: - controller.isLoading.value && - controller.viewNotificationList.isEmpty - ? const Center(child: CircularProgressIndicator()) - : controller.viewNotificationList.isNotEmpty - ? _NotificationPageList() - : _noMessageIcon(), - ), - ), - AppGap.v16, - ], - ), - ); + color: AppColors.white, + padding: AppPadding.screenHorizontal, + child: Column( + children: [ + _Title(), + AppGap.v16, + Expanded( + child: Obx(() { + if (controller.isLoading.value && + controller.viewNotificationList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } - Widget _noMessageIcon() => Column( - children: [ - Spacer( - flex: 153, - ), - Image.asset(AppIcon.message.path), - Text( - "지금은 알려드릴 게 없어요", - style: AppTextStyles.textMedium(textColor: AppColors.gray60), - ), - Spacer( - flex: 275, - ), - ], - ); + if (controller.errorMessage.value.isNotEmpty && + controller.viewNotificationList.isEmpty) { + return _ErrorView( + message: controller.errorMessage.value, + onRetry: controller.refresh, + ); + } + + if (controller.viewNotificationList.isEmpty) { + return _EmptyView(); + } + + return _NotificationList(); + }), + ), + AppGap.v16, + ], + ), + ); } class _Title extends GetView { @@ -125,79 +118,90 @@ class _Title extends GetView { } } -@immutable -class _NotificationPageList extends GetView { +class _NotificationList extends GetView { + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + controller.loadMore(); + } + return false; + }, + child: Obx( + () => ListView.separated( + itemCount: controller.viewNotificationList.length + + (controller.isLoadingMore.value ? 1 : 0), + separatorBuilder: (_, _) => AppGap.v16, + itemBuilder: (context, index) { + if (index == controller.viewNotificationList.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + final notification = controller.viewNotificationList[index]; + return notification.type == NotificationType.reportReceived + ? ReportNotificationCard( + notification: notification, + onTap: () => controller.read(notification), + ) + : NotificationCard( + notification: notification, + onTap: () => controller.read(notification), + ); + }, + ), + ), + ); + } +} + +class _EmptyView extends StatelessWidget { @override Widget build(BuildContext context) { - final list = controller.viewNotificationList; - final pageTotal = controller.totalPages.value; return Column( children: [ - Expanded( - child: PageView.builder( - itemCount: pageTotal, - onPageChanged: (value) { - controller.currentPageIndex.value = value; - }, - itemBuilder: (context, pi) { - if (pi >= controller.loadedPages.value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - controller.loadMore(); - }); - return const Center(child: CircularProgressIndicator()); - } + const Spacer(flex: 153), + Image.asset(AppIcon.message.path), + Text( + "지금은 알려드릴 게 없어요", + style: AppTextStyles.textMedium(textColor: AppColors.gray60), + ), + const Spacer(flex: 275), + ], + ); + } +} - final start = min( - pi * NotificationController.pageSize, - list.length, - ); - final slice = list.sublist( - start, - min( - start + NotificationController.pageSize, - list.length, - ), - ); +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; - return Column( - children: [ - for (var index = 0; index < slice.length; index++) ...[ - if (index > 0) AppGap.v16, - slice[index].type == NotificationType.reportReceived - ? ReportNotificationCard( - notification: slice[index], - onTap: () => controller.read(slice[index]), - ) - : NotificationCard( - notification: slice[index], - onTap: () => controller.read(slice[index]), - ), - ], - ], - ); - }, + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + style: AppTextStyles.textMedium(textColor: AppColors.gray60), ), - ), - AppGap.v16, - Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - controller.totalPages.value, - (index) => Padding( - padding: AppPadding.indicatorSpacing, - child: Text( - "${index + 1}", - style: AppTextStyles.pageIndicator( - isCurrent: controller.currentPageIndex.value == index, - ), - ), - ), + AppGap.v16, + TextButton( + onPressed: onRetry, + child: Text( + '다시 시도', + style: AppTextStyles.textMedium(textColor: AppColors.primary), ), ), - ), - AppGap.v16, - ], + ], + ), ); } }