feat(flutter): 메일 목록 화면을 만들고 앱 틀이 화면마다 탭 막대를 숨길 수 있게 한다 (App1MailList)

앱 틀(core/router):
- 틀을 Scaffold 가 아니라 Column + 탭 막대로 — 탭 안 화면마다 자기 Scaffold 를 두어 전역 알림이
  [메일 쓰기] 위에 뜬다. 탭 막대는 위 안전 영역을 빼고(실기기에서 상태 표시줄만큼 두꺼워지지 않게),
  화면이 ShellBarHidden 으로 숨길 수 있다(모듈마다 셈 · 보이는지는 TickerMode, 자판이 열리면 뺌)

메일 목록(features/mail):
- 메일함 서랍(바꿔 끼움 · 안 읽은 수는 회색 글), 줄(사진 · 보낸 사람 · 시각 / 제목 · 첨부 /
  미리 보기 한 줄 · 안 읽음 굵게 · 중요 별), 커서로 이어 받기 · 당겨서 새로고침 · 빈 메일함 · 30일 줄
- 고르기 막대(읽음 · 중요 · 이동 · 삭제) — 먼저 바꾸고 failedIds 만 되돌리기, 이동 · 삭제는 응답
  id 만, 100통씩, 휴지통으로 옮기고 [되돌리기], 휴지통에서는 확인 뒤 영구 삭제, 붙잡힌 메일은 고를 수 없음
- 새 메일 확인: 보이고 앱이 앞에 있을 때만 1분마다, 다시 받은 쪽이 끝까지면 지운 옛 메일을 뺀다
- [되돌리기] 알림은 [메일 쓰기]와 한 자리(화면 낭독기를 켠 동안은 닫지 않음), 단추 없는 알림은 전역 스낵바
- 시험 346건(새로 119)

리뷰 반영: 탭 막대 높이, 고르는 중 알림 줄 위치, 이어 받기 중 새로고침 실패, 붙잡힌 메일 다시 보기,
메일함 역할을 알기 전 그리지 않음, 선택 풀기 · 되살리기를 계약에 맞춤, 맞추기 한 번에 하나.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 19:36:31 +09:00
parent 4e779dedd2
commit ad8b126abb
49 changed files with 8684 additions and 59 deletions
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
/// 메일 주소 하나 — 계약 2절 `Address { name: string | null; email: string }`.
///
/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null 이다 — 저장소가 `SYS_001` 로 던진다.
@immutable
class MailAddress {
const MailAddress({required this.email, this.name});
/// 보이는 이름 — 없으면 null
final String? name;
final String email;
/// 목록에 보이는 이름 — 이름이 없거나 비었으면 주소 (웹 `displayName`)
String get displayName {
final String? trimmed = name?.trim();
return trimmed == null || trimmed.isEmpty ? email : trimmed;
}
static MailAddress? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? email = json['email'];
final Object? name = json['name'];
if (email is! String || (name != null && name is! String)) return null;
return MailAddress(email: email, name: name as String?);
}
/// 주소 배열 — 하나라도 모양이 틀리면 null
static List<MailAddress>? tryParseList(Object? json) {
if (json is! List<Object?>) return null;
final List<MailAddress> addresses = <MailAddress>[];
for (final Object? item in json) {
final MailAddress? address = tryParse(item);
if (address == null) return null;
addresses.add(address);
}
return List<MailAddress>.unmodifiable(addresses);
}
@override
bool operator ==(Object other) =>
other is MailAddress && other.email == email && other.name == name;
@override
int get hashCode => Object.hash(email, name);
}
@@ -0,0 +1,74 @@
import 'package:flutter/foundation.dart';
/// 메일 한 통 · 여러 통에 바꿀 것 — `PATCH /api/messages{/id}` 의 본문(계약 2절). 하나 이상 넣는다
@immutable
class MessageChanges {
const MessageChanges({
this.isRead,
this.isFlagged,
this.mailboxId,
this.isJunk,
});
final bool? isRead;
final bool? isFlagged;
/// 이동 — 그 메일함 하나에만 속하게
final String? mailboxId;
/// 스팸 표시 (4단계 "스팸 · 원문")
final bool? isJunk;
/// 보낼 본문 — 정한 칸만 싣는다
Map<String, Object?> toJson() => <String, Object?>{
'isRead': ?isRead,
'isFlagged': ?isFlagged,
'mailboxId': ?mailboxId,
'isJunk': ?isJunk,
};
}
/// `PATCH /api/messages` 의 결과 — 계약 2절 `MessagesPatched`
@immutable
class MessagesPatched {
const MessagesPatched({required this.updatedIds, required this.failedIds});
final List<String> updatedIds;
/// 없음 · 그사이 지워짐 · 엔진이 거절함 · 붙잡힌 메일의 이동 — 나머지는 그대로 처리했다
final List<String> failedIds;
static MessagesPatched? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final List<String>? updated = _idsOf(json['updatedIds']);
final List<String>? failed = _idsOf(json['failedIds']);
if (updated == null || failed == null) return null;
return MessagesPatched(updatedIds: updated, failedIds: failed);
}
}
/// `POST /api/message-deletions` 의 결과 — 계약 2절 `MessageDeletionResult`
@immutable
class MessageDeletions {
const MessageDeletions({required this.deletedIds, required this.skippedIds});
/// 영구 삭제했다 · 휴지통에서만 뺐다
final List<String> deletedIds;
/// 휴지통에 없음 · 없음 · 엔진이 거절 · 붙잡힌 메일 — 지우지 않았다
final List<String> skippedIds;
static MessageDeletions? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final List<String>? deleted = _idsOf(json['deletedIds']);
final List<String>? skipped = _idsOf(json['skippedIds']);
if (deleted == null || skipped == null) return null;
return MessageDeletions(deletedIds: deleted, skippedIds: skipped);
}
}
List<String>? _idsOf(Object? value) {
if (value is! List<Object?>) return null;
if (value.any((Object? id) => id is! String)) return null;
return List<String>.unmodifiable(value.cast<String>());
}
@@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart';
/// 목록 응답의 `pageInfo` — 응답 포맷 규칙 "목록 응답 (페이지네이션)"
@immutable
class MailPageInfo {
const MailPageInfo({required this.hasNext, this.nextCursor, this.total});
final bool hasNext;
/// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다(계약 2절). 마지막 쪽이면 null
final String? nextCursor;
/// 전체 수 — 계산 비용이 크면 null
final int? total;
/// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만
bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false);
MailPageInfo copyWith({int? total}) => MailPageInfo(
hasNext: hasNext,
nextCursor: nextCursor,
total: total ?? this.total,
);
static MailPageInfo? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? hasNext = json['hasNext'];
final Object? nextCursor = json['nextCursor'];
final Object? total = json['total'];
if (hasNext is! bool ||
(nextCursor != null && nextCursor is! String) ||
(total != null && total is! num)) {
return null;
}
return MailPageInfo(
hasNext: hasNext,
nextCursor: nextCursor as String?,
total: (total as num?)?.toInt(),
);
}
@override
bool operator ==(Object other) =>
other is MailPageInfo &&
other.hasNext == hasNext &&
other.nextCursor == nextCursor &&
other.total == total;
@override
int get hashCode => Object.hash(hasNext, nextCursor, total);
}
/// 목록 한 쪽 — `{ items, pageInfo }`
@immutable
class MailPage<T> {
const MailPage({
required this.items,
required this.pageInfo,
this.serverTime,
});
final List<T> items;
final MailPageInfo pageInfo;
/// 응답의 `Date` 머리(서버 시각) — 없으면 null. 붙잡아 둔 발송이 풀릴 때를 서버 시계로 재는 데 쓴다
final DateTime? serverTime;
/// 서버 시각을 붙인 쪽
MailPage<T> withServerTime(DateTime? serverTime) =>
MailPage<T>(items: items, pageInfo: pageInfo, serverTime: serverTime);
/// 모양이 틀리면(항목 하나라도) null
static MailPage<T>? tryParse<T>(
Object? json,
T? Function(Object? item) parseItem,
) {
if (json is! Map<String, Object?>) return null;
final Object? rawItems = json['items'];
final MailPageInfo? pageInfo = MailPageInfo.tryParse(json['pageInfo']);
if (rawItems is! List<Object?> || pageInfo == null) return null;
final List<T> items = <T>[];
for (final Object? raw in rawItems) {
final T? item = parseItem(raw);
if (item == null) return null;
items.add(item);
}
return MailPage<T>(items: List<T>.unmodifiable(items), pageInfo: pageInfo);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/foundation.dart';
/// 메일함 역할 — 계약 2절 `Mailbox.role` (역할이 있으면 서버가 한국어 표준 이름을 준다)
enum MailboxRole {
inbox,
drafts,
sent,
junk,
trash,
archive;
/// 응답 칸을 읽는다 — null · 모르는 이름은 역할 없음(사용자가 만든 메일함)으로 본다
static MailboxRole? parse(Object? value) {
for (final MailboxRole role in MailboxRole.values) {
if (role.name == value) return role;
}
return null;
}
}
/// 메일함 하나 — 계약 2절 `Mailbox`
@immutable
class Mailbox {
const Mailbox({
required this.id,
required this.name,
required this.role,
required this.parentId,
required this.sortOrder,
required this.totalCount,
required this.unreadCount,
});
final String id;
/// 역할이 있으면 한국어 표준 이름(받은편지함 등), 없으면 서버의 이름
final String name;
/// 역할 — null 이면 사용자가 만든 메일함("내 메일함")
final MailboxRole? role;
final String? parentId;
final int sortOrder;
final int totalCount;
final int unreadCount;
Mailbox copyWith({int? unreadCount}) => Mailbox(
id: id,
name: name,
role: role,
parentId: parentId,
sortOrder: sortOrder,
totalCount: totalCount,
unreadCount: unreadCount ?? this.unreadCount,
);
/// 모양이 틀리면 null
static Mailbox? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? name = json['name'];
final Object? parentId = json['parentId'];
final Object? sortOrder = json['sortOrder'];
final Object? totalCount = json['totalCount'];
final Object? unreadCount = json['unreadCount'];
if (id is! String ||
id.isEmpty ||
name is! String ||
(parentId != null && parentId is! String) ||
sortOrder is! num ||
totalCount is! num ||
unreadCount is! num) {
return null;
}
return Mailbox(
id: id,
name: name,
role: MailboxRole.parse(json['role']),
parentId: parentId as String?,
sortOrder: sortOrder.toInt(),
totalCount: totalCount.toInt(),
unreadCount: unreadCount.toInt(),
);
}
@override
bool operator ==(Object other) =>
other is Mailbox &&
other.id == id &&
other.name == name &&
other.role == role &&
other.parentId == parentId &&
other.sortOrder == sortOrder &&
other.totalCount == totalCount &&
other.unreadCount == unreadCount;
@override
int get hashCode =>
Object.hash(id, name, role, parentId, sortOrder, totalCount, unreadCount);
}
@@ -0,0 +1,198 @@
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:flutter/foundation.dart';
/// 붙잡아 둔 내 발송의 종류 — 계약 2절 "보내기 취소 · 예약 발송"(9단계)
enum PendingSendKind {
/// [보내기]를 누른 뒤 보내기 취소를 기다리는 중
undo,
/// 예약 발송
scheduled,
}
/// 엔진이 붙잡아 둔 내 발송 — 아직 보내지 않았다(보내는 중 · 예약)
@immutable
class PendingSend {
const PendingSend({required this.sendAt, required this.kind});
/// 엔진이 보낼 시각 (UTC)
final DateTime sendAt;
final PendingSendKind kind;
/// 응답의 값을 읽는다 — 칸이 없거나(9단계 앞 서버) 모양이 틀리면 없는 것(null)으로 본다(웹 `pendingSendOf` 와 같다)
static PendingSend? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? sendAt = json['sendAt'];
final Object? kind = json['kind'];
final DateTime? at = sendAt is String ? DateTime.tryParse(sendAt) : null;
final PendingSendKind? parsedKind = switch (kind) {
'undo' => PendingSendKind.undo,
'scheduled' => PendingSendKind.scheduled,
_ => null,
};
if (at == null || parsedKind == null) return null;
return PendingSend(sendAt: at.toUtc(), kind: parsedKind);
}
@override
bool operator ==(Object other) =>
other is PendingSend && other.sendAt == sendAt && other.kind == kind;
@override
int get hashCode => Object.hash(sendAt, kind);
}
/// 목록의 메일 한 통 — 계약 2절 `MessageSummary`
@immutable
class MessageSummary {
const MessageSummary({
required this.id,
required this.threadId,
required this.mailboxIds,
required this.from,
required this.to,
required this.subject,
required this.preview,
required this.receivedAt,
required this.isRead,
required this.isFlagged,
required this.hasAttachment,
required this.size,
this.pendingSend,
});
final String id;
final String threadId;
final List<String> mailboxIds;
final MailAddress? from;
final List<MailAddress> to;
/// 제목이 없으면 빈 글
final String subject;
final String preview;
/// 받은 시각 (UTC)
final DateTime receivedAt;
final bool isRead;
final bool isFlagged;
final bool hasAttachment;
/// 바이트
final int size;
/// 아직 보내지 않은 내 메일이면 그 정보 — 그 밖에는 null
final PendingSend? pendingSend;
/// 아직 보내지 않은 메일(보내는 중 · 예약)인가 — 취소해야만 보낸편지함을 떠난다.
/// 옮기거나 지워도 엔진은 그 시각에 보내므로 목록에서 고를 수 없다(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다")
bool get isHeld => pendingSend != null;
MessageSummary copyWith({
bool? isRead,
bool? isFlagged,
List<String>? mailboxIds,
}) => MessageSummary(
id: id,
threadId: threadId,
mailboxIds: mailboxIds ?? this.mailboxIds,
from: from,
to: to,
subject: subject,
preview: preview,
receivedAt: receivedAt,
isRead: isRead ?? this.isRead,
isFlagged: isFlagged ?? this.isFlagged,
hasAttachment: hasAttachment,
size: size,
pendingSend: pendingSend,
);
/// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다
static MessageSummary? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? threadId = json['threadId'];
final Object? mailboxIds = json['mailboxIds'];
final Object? fromJson = json['from'];
final Object? subject = json['subject'];
final Object? preview = json['preview'];
final Object? receivedAt = json['receivedAt'];
final Object? isRead = json['isRead'];
final Object? isFlagged = json['isFlagged'];
final Object? hasAttachment = json['hasAttachment'];
final Object? size = json['size'];
final MailAddress? from = fromJson == null
? null
: MailAddress.tryParse(fromJson);
final List<MailAddress>? to = MailAddress.tryParseList(json['to']);
final DateTime? received = receivedAt is String
? DateTime.tryParse(receivedAt)
: null;
if (id is! String ||
id.isEmpty ||
threadId is! String ||
mailboxIds is! List<Object?> ||
mailboxIds.any((Object? value) => value is! String) ||
(fromJson != null && from == null) ||
to == null ||
subject is! String ||
preview is! String ||
received == null ||
isRead is! bool ||
isFlagged is! bool ||
hasAttachment is! bool ||
size is! num) {
return null;
}
return MessageSummary(
id: id,
threadId: threadId,
mailboxIds: List<String>.unmodifiable(mailboxIds.cast<String>()),
from: from,
to: to,
subject: subject,
preview: preview,
receivedAt: received.toUtc(),
isRead: isRead,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
size: size.toInt(),
pendingSend: PendingSend.tryParse(json['pendingSend']),
);
}
@override
bool operator ==(Object other) =>
other is MessageSummary &&
other.id == id &&
other.threadId == threadId &&
listEquals(other.mailboxIds, mailboxIds) &&
other.from == from &&
listEquals(other.to, to) &&
other.subject == subject &&
other.preview == preview &&
other.receivedAt == receivedAt &&
other.isRead == isRead &&
other.isFlagged == isFlagged &&
other.hasAttachment == hasAttachment &&
other.size == size &&
other.pendingSend == pendingSend;
@override
int get hashCode => Object.hash(
id,
threadId,
Object.hashAll(mailboxIds),
from,
Object.hashAll(to),
subject,
preview,
receivedAt,
isRead,
isFlagged,
hasAttachment,
size,
pendingSend,
);
}
@@ -0,0 +1,132 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:dio/dio.dart';
/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다
abstract final class MailApiPaths {
static const String mailboxes = '/mailboxes';
static const String messages = '/messages';
/// 메일 한 통 — id 는 경로 칸으로 인코딩한다
static String message(String id) => '$messages/${Uri.encodeComponent(id)}';
/// 휴지통에서 영구 삭제 (여러 통)
static const String messageDeletions = '/message-deletions';
/// 새 메일 확인 — 불투명한 상태 글
static const String mailState = '/mail-state';
}
/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다
const int kMailPageSize = 20;
/// 여러 통 요청 하나의 id 한도 — 넘으면 이만큼씩 차례로 보낸다(계약 2절 "여러 통 한꺼번에")
const int kMailBulkMaxIds = 100;
/// 메일 API — 계약 `docs/mail/api-contract.md` 2절. 웹과 같은 경로를 Bearer 로 부른다.
///
/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`.
class MailRepository {
const MailRepository(this._dio);
final Dio _dio;
/// 메일함 목록 — 알림은 띄우지 않는다(목록 · 서랍이 받지 못함을 직접 그린다)
Future<List<Mailbox>> listMailboxes() async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.mailboxes,
options: ApiOptions.silent(),
);
final MailPage<Mailbox>? page = MailPage.tryParse<Mailbox>(
response.data,
Mailbox.tryParse,
);
if (page == null) throw _malformed(response);
return page.items;
}
/// 메일함 하나의 목록 — 받은 시각 내림차순. [cursor] 는 해석하지 않고 그대로 보낸다.
/// [silent] 면 실패를 알리지 않는다(처음 여는 목록은 화면이 받지 못함을 직접 그린다 · 새 메일 맞추기)
Future<MailPage<MessageSummary>> listMessages({
required String mailboxId,
String? cursor,
int limit = kMailPageSize,
bool silent = false,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.messages,
queryParameters: <String, Object?>{
'mailboxId': mailboxId,
'limit': limit,
'cursor': ?cursor,
},
options: silent ? ApiOptions.silent() : null,
);
final MailPage<MessageSummary>? page = MailPage.tryParse<MessageSummary>(
response.data,
MessageSummary.tryParse,
);
if (page == null) throw _malformed(response);
return page.withServerTime(serverTimeOf(response));
}
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다)
Future<MessageSummary> updateMessage(
String id,
MessageChanges changes,
) async {
final Response<Object?> response = await _dio.patch<Object?>(
MailApiPaths.message(id),
data: changes.toJson(),
);
final MessageSummary? summary = MessageSummary.tryParse(response.data);
if (summary == null) throw _malformed(response);
return summary;
}
/// 여러 통 바꾸기 — [ids] 는 100개까지(나눠 보내는 일은 부르는 쪽이 한다)
Future<MessagesPatched> updateMessages(
List<String> ids,
MessageChanges changes,
) async {
final Response<Object?> response = await _dio.patch<Object?>(
MailApiPaths.messages,
data: <String, Object?>{'ids': ids, ...changes.toJson()},
);
final MessagesPatched? result = MessagesPatched.tryParse(response.data);
if (result == null) throw _malformed(response);
return result;
}
/// 휴지통에서 영구 삭제 — 확인을 받은 뒤에만 부른다. [ids] 는 100개까지
Future<MessageDeletions> deleteMessages(List<String> ids) async {
final Response<Object?> response = await _dio.post<Object?>(
MailApiPaths.messageDeletions,
data: <String, Object?>{'ids': ids},
);
final MessageDeletions? result = MessageDeletions.tryParse(response.data);
if (result == null) throw _malformed(response);
return result;
}
/// 새 메일 확인 — 상태 글(해석하지 않고 지난 값과 같은지만 본다). 실패는 알리지 않는다(다음 차례에 다시 묻는다)
Future<String> mailState() async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.mailState,
options: ApiOptions.silent(),
);
final Object? data = response.data;
final Object? state = data is Map<String, Object?> ? data['state'] : null;
if (state is! String) throw _malformed(response);
return state;
}
DioException _malformed(Response<Object?> response) =>
apiFailure(response.requestOptions, kFallbackCode);
}
@@ -0,0 +1,93 @@
/// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 (웹 `shared/utils/mail-bulk.ts` 와 같은 규칙, 순수 함수 · 문구 없음)
library;
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// [size] 개씩 나눈다 — 요청 하나의 ids 는 100개까지다
List<List<T>> chunked<T>(List<T> items, int size) {
final int step = math.max(1, size);
return <List<T>>[
for (int start = 0; start < items.length; start += step)
items.sublist(start, math.min(start + step, items.length)),
];
}
/// 여러 통 처리의 결과
@immutable
class BulkResult {
const BulkResult({
this.doneIds = const <String>[],
this.skippedIds = const <String>[],
this.unsentIds = const <String>[],
});
/// 처리한 메일
final List<String> doneIds;
/// 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds
final List<String> skippedIds;
/// 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다
final List<String> unsentIds;
/// 그대로 둔 메일 · 보내지 못한 메일이 있다 — 목록을 서버 기준으로 다시 맞춘다
bool get hasLeftovers => skippedIds.isNotEmpty || unsentIds.isNotEmpty;
}
/// 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일
typedef BulkChunkSender =
Future<({List<String> done, List<String> skipped})> Function(
List<String> chunkIds,
);
/// [ids] 를 [size] 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다
/// (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다)
Future<BulkResult> sendInChunks(
List<String> ids,
int size,
BulkChunkSender send,
) async {
final List<String> done = <String>[];
final List<String> skipped = <String>[];
final List<String> unsent = <String>[];
final List<List<String>> chunks = chunked<String>(ids, size);
for (int index = 0; index < chunks.length; index++) {
try {
final ({List<String> done, List<String> skipped}) result = await send(
chunks[index],
);
done.addAll(result.done);
skipped.addAll(result.skipped);
} on DioException {
unsent.addAll(chunks.sublist(index).expand((List<String> c) => c));
break;
}
}
return BulkResult(doneIds: done, skippedIds: skipped, unsentIds: unsent);
}
/// 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일
@immutable
class BulkOrigin {
const BulkOrigin(this.mailboxId, this.ids);
final String mailboxId;
final List<String> ids;
}
/// 옮긴 메일을 원래 메일함별로 묶는다(옮긴 차례대로). 원래 메일함을 모르는 메일은 뺀다
List<BulkOrigin> groupByOrigin(List<String> ids, Map<String, String> originOf) {
final Map<String, List<String>> groups = <String, List<String>>{};
for (final String id in ids) {
final String? origin = originOf[id];
if (origin == null) continue;
(groups[origin] ??= <String>[]).add(id);
}
return <BulkOrigin>[
for (final MapEntry<String, List<String>> entry in groups.entries)
BulkOrigin(entry.key, List<String>.unmodifiable(entry.value)),
];
}
@@ -0,0 +1,56 @@
/// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 — 순수 함수 (웹 `shared/utils/mail-list-merge.ts` 와 같다).
///
/// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다(새 메일 · 다른 창의 읽음 · 이동 · 삭제).
/// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일
/// (밀려난 메일을 버리면 보던 줄이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다.
/// - 다시 받은 쪽이 메일함의 끝까지였으면(`hasNext` 거짓) 범위 밖이 없다 — 받지 못한 옛 메일은 다른 곳에서 지워진 것이라 남기지 않는다
/// (웹은 이때도 남긴다 — 앱에서 정함 2026-11-06).
library;
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
/// 병합 결과
typedef MailListMerge = ({List<MessageSummary> items, MailPageInfo pageInfo});
/// 지금 목록([current] · [currentPageInfo])과 다시 받은 첫 범위([fetched] · [fetchedPageInfo])를 합친다
MailListMerge mergeRefreshedList({
required List<MessageSummary> current,
required MailPageInfo? currentPageInfo,
required List<MessageSummary> fetched,
required MailPageInfo fetchedPageInfo,
}) {
final Set<String> fetchedIds = <String>{
for (final MessageSummary message in fetched) message.id,
};
// 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다
int lastOverlap = -1;
for (int index = 0; index < current.length; index++) {
if (fetchedIds.contains(current[index].id)) lastOverlap = index;
}
final List<MessageSummary> tail =
lastOverlap == -1 || !fetchedPageInfo.hasNext
? <MessageSummary>[]
: current
.sublist(lastOverlap + 1)
.where((MessageSummary message) => !fetchedIds.contains(message.id))
.toList();
// 남겨 둔 옛 메일이 있으면 다음 쪽은 그 뒤부터 이어야 한다 — 옛 커서를 쓰고 전체 수는 새 값
final MailPageInfo pageInfo = tail.isNotEmpty && currentPageInfo != null
? MailPageInfo(
hasNext: currentPageInfo.hasNext,
nextCursor: currentPageInfo.nextCursor,
total: fetchedPageInfo.total,
)
: fetchedPageInfo;
return (
items: List<MessageSummary>.unmodifiable(<MessageSummary>[
...fetched,
...tail,
]),
pageInfo: pageInfo,
);
}
@@ -0,0 +1,136 @@
/// 메일함 차례 · 묶음 · 옮길 곳 — 순수 함수 (웹 `shared/utils/mailbox.ts` 를 옮겼다).
///
/// 이름 차례만은 웹과 어긋날 수 있다 — 웹은 `localeCompare(…, 'ko')`(ICU 한국어 정렬)이고, 앱은 대소문자를 가리지 않고
/// 글자 값으로 견준다. 영문 · 한글이 섞인 이름("ARA 프로젝트" · "가나다" · "erp")에서 차례가 다를 수 있다(2026-11-06 리뷰에서 정함).
library;
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:flutter/foundation.dart';
/// 메일함 정렬 차례 — 계약 2절 "메일함 정렬(확정 디자인 기준)". 역할 없는 메일함은 이 뒤에 sortOrder · 이름 순
const List<MailboxRole> kMailboxRoleOrder = <MailboxRole>[
MailboxRole.inbox,
MailboxRole.sent,
MailboxRole.drafts,
MailboxRole.junk,
MailboxRole.trash,
MailboxRole.archive,
];
/// 서랍에 그릴 한 줄 — [depth] 는 하위 메일함 들여쓰기 단계
@immutable
class MailboxNode {
const MailboxNode(this.mailbox, this.depth);
final Mailbox mailbox;
final int depth;
}
int _roleRank(MailboxRole? role) {
if (role == null) return kMailboxRoleOrder.length;
final int index = kMailboxRoleOrder.indexOf(role);
return index == -1 ? kMailboxRoleOrder.length : index;
}
/// 역할 차례 → sortOrder → 이름(대소문자를 가리지 않는다 — 같으면 원래 글자로 한 번 더 견줘 차례를 늘 같게)
int compareMailboxes(Mailbox a, Mailbox b) {
final int byRole = _roleRank(a.role) - _roleRank(b.role);
if (byRole != 0) return byRole;
if (a.sortOrder != b.sortOrder) return a.sortOrder - b.sortOrder;
final int byName = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return byName != 0 ? byName : a.name.compareTo(b.name);
}
/// 부모 · 자식 관계를 지키며 정렬해 한 줄로 편다.
/// 부모를 찾을 수 없거나 서로를 가리키는(순환) 메일함은 맨 위로 올려 빠뜨리지 않는다
List<MailboxNode> orderMailboxes(List<Mailbox> mailboxes) {
final Set<String> ids = <String>{
for (final Mailbox mailbox in mailboxes) mailbox.id,
};
final Map<String?, List<Mailbox>> childrenOf = <String?, List<Mailbox>>{};
for (final Mailbox mailbox in mailboxes) {
final String? parentId = mailbox.parentId;
final String? key = parentId != null && ids.contains(parentId)
? parentId
: null;
(childrenOf[key] ??= <Mailbox>[]).add(mailbox);
}
final List<MailboxNode> result = <MailboxNode>[];
final Set<String> visited = <String>{};
void visit(String? parentId, int depth) {
final List<Mailbox> children = <Mailbox>[...?childrenOf[parentId]]
..sort(compareMailboxes);
for (final Mailbox mailbox in children) {
if (!visited.add(mailbox.id)) continue;
result.add(MailboxNode(mailbox, depth));
visit(mailbox.id, depth + 1);
}
}
visit(null, 0);
// 순환(a→b→a)으로 어느 뿌리에도 닿지 않은 메일함
final List<Mailbox> orphans =
mailboxes
.where((Mailbox mailbox) => !visited.contains(mailbox.id))
.toList()
..sort(compareMailboxes);
for (final Mailbox mailbox in orphans) {
if (!visited.add(mailbox.id)) continue;
result.add(MailboxNode(mailbox, 0));
visit(mailbox.id, 1);
}
return result;
}
/// 서랍의 두 묶음 — 기본 메일함(역할이 있는 뿌리와 그 아래)과 "내 메일함"(역할 없는 뿌리와 그 아래)
({List<MailboxNode> system, List<MailboxNode> mine}) splitMailboxGroups(
List<MailboxNode> nodes,
) {
final List<MailboxNode> system = <MailboxNode>[];
final List<MailboxNode> mine = <MailboxNode>[];
bool rootHasRole = false;
for (final MailboxNode node in nodes) {
if (node.depth == 0) rootHasRole = node.mailbox.role != null;
(rootHasRole ? system : mine).add(node);
}
return (system: system, mine: mine);
}
/// 역할의 메일함 — 없으면 null
Mailbox? findMailboxByRole(List<Mailbox> mailboxes, MailboxRole role) {
for (final Mailbox mailbox in mailboxes) {
if (mailbox.role == role) return mailbox;
}
return null;
}
/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함
Mailbox? defaultMailboxOf(List<Mailbox> mailboxes) {
final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox);
if (inbox != null) return inbox;
final List<MailboxNode> ordered = orderMailboxes(mailboxes);
return ordered.isEmpty ? null : ordered.first.mailbox;
}
/// 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함(서랍 차례). 휴지통으로는 [삭제]로 옮긴다
/// (웹 `useBulkMailActions` 의 `moveTargets` 와 같다)
List<MailboxNode> moveTargetsOf(
List<Mailbox> mailboxes,
String? currentMailboxId,
) {
return orderMailboxes(mailboxes)
.where(
(MailboxNode node) =>
node.mailbox.id != currentMailboxId &&
node.mailbox.role != MailboxRole.drafts &&
node.mailbox.role != MailboxRole.trash,
)
.toList();
}
/// 위 막대 아래에 "안 읽은 메일 N통"을 보이는 메일함 — 보낸편지함 · 임시보관함 · 휴지통은 안 읽음이 뜻이 없어 뺀다
bool showsUnreadCount(MailboxRole? role) =>
role != MailboxRole.sent &&
role != MailboxRole.drafts &&
role != MailboxRole.trash;
+15 -13
View File
@@ -1,6 +1,7 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
@@ -10,7 +11,7 @@ abstract final class MailRoutes {
/// 첫 화면 — 받은편지함 목록 (탭 안)
static final String home = findAppModule(AppModuleId.mail).rootPath;
/// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다)
/// 다른 메일함의 목록 (탭 안 — 받은편지함 위에 쌓인다. 뒤로 가기는 받은편지함, 탭을 바꿔도 이 기록이 남는다)
static String mailbox(String mailboxId) =>
'$home/mailboxes/${Uri.encodeComponent(mailboxId)}';
@@ -20,11 +21,20 @@ abstract final class MailRoutes {
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
static String get compose => '$home/compose';
/// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=<id>`)
static String composeDraft(String draftId) => Uri(
path: compose,
queryParameters: <String, String>{draftQuery: draftId},
).toString();
/// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다)
static const String draftQuery = 'draft';
}
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면
/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
id: AppModuleId.mail,
@@ -33,20 +43,12 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: MailRoutes.home,
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
title: MailTexts.home,
links: <PlaceholderLink>[
PlaceholderLink(
label: MailTexts.compose,
location: MailRoutes.compose,
),
],
),
builder: (BuildContext _, GoRouterState _) => const MailListPage(),
routes: <RouteBase>[
GoRoute(
path: 'mailboxes/:mailboxId',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.mailbox),
builder: (BuildContext _, GoRouterState state) =>
MailListPage(mailboxId: state.pathParameters['mailboxId']),
),
GoRoute(
path: 'messages/:messageId',
@@ -0,0 +1,196 @@
/// 목록 줄 하나의 표시 — 순수 함수 (웹 `shared/utils/mail-list-row.ts` · `format.ts` 의 분할 보기 규칙과 같다).
///
/// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않는다(누르면 쓰기 화면에서 이어 쓴다).
/// - 보낸편지함: "받는 사람: 박준호 외 2명".
/// - 아직 보내지 않은 내 메일(9단계 `pendingSend`)은 시각 자리가 "보내는 중" · "예약 · 11월 7일 오전 9:00"(청록)이고 고를 수 없다.
library;
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter/foundation.dart';
/// 목록 줄 하나에 보일 것
@immutable
class MailRowView {
const MailRowView({
required this.who,
required this.whoPlaceholder,
required this.initial,
required this.subject,
required this.subjectPlaceholder,
required this.preview,
required this.time,
required this.timePending,
required this.unread,
required this.flagged,
required this.hasAttachment,
required this.held,
});
/// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람
final String who;
/// [who] 가 빈 값을 알리는 글이다 — 흐리게
final bool whoPlaceholder;
/// 동그란 사진의 머리글자 — 사람이 없으면 null(사람 아이콘)
final String? initial;
final String subject;
/// 임시 저장본의 빈 제목 — 흐리게
final bool subjectPlaceholder;
final String preview;
/// 받은 시각 · 붙잡아 둔 발송의 표시
final String time;
/// [time] 이 아직 보내지 않은 메일의 표시다 — 청록 글
final bool timePending;
final bool unread;
final bool flagged;
final bool hasAttachment;
/// 아직 보내지 않은 메일 — 고를 수 없다
final bool held;
/// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목"
String get selectLabel => MailListTexts.selectOne(subject);
/// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례
String get semanticsLabel => <String>[
if (unread) MailListTexts.unreadPrefix,
who,
time,
subject,
if (hasAttachment) MailListTexts.attachment,
if (flagged) MailListTexts.flagged,
if (preview.isNotEmpty) preview,
].join(', ');
}
/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다)
MailRowView mailRowViewOf(
MessageSummary message, {
required MailboxRole? role,
required DateTime now,
}) {
final bool draft = role == MailboxRole.drafts;
final ({String text, bool placeholder, String? initial}) who = _whoOf(
message,
role,
);
final PendingSend? pending = message.pendingSend;
return MailRowView(
who: who.text,
whoPlaceholder: who.placeholder,
initial: who.initial,
subject: message.subject.isEmpty
? MailListTexts.noSubject
: message.subject,
subjectPlaceholder: draft && message.subject.isEmpty,
preview: message.preview,
time: pending == null
? formatMailListTime(message.receivedAt, now)
: pendingSendLabel(pending),
timePending: pending != null,
unread: !draft && !message.isRead,
flagged: message.isFlagged,
hasAttachment: message.hasAttachment,
held: message.isHeld,
);
}
({String text, bool placeholder, String? initial}) _whoOf(
MessageSummary message,
MailboxRole? role,
) {
final List<MailAddress> to = message.to;
final MailAddress? firstRecipient = to.isEmpty ? null : to.first;
switch (role) {
case MailboxRole.drafts:
final String names = to
.map((MailAddress address) => address.displayName)
.where((String name) => name.isNotEmpty)
.join(', ');
if (names.isEmpty || firstRecipient == null) {
return (
text: MailListTexts.draftNoRecipients,
placeholder: true,
initial: null,
);
}
return (
text: names,
placeholder: false,
initial: initialOf(firstRecipient.name, fallback: firstRecipient.email),
);
case MailboxRole.sent:
if (firstRecipient == null) {
return (
text: MailListTexts.noRecipients,
placeholder: false,
initial: null,
);
}
return (
text:
'${MailListTexts.recipientPrefix}${MailListTexts.recipientsSummary(firstRecipient.displayName, to.length - 1)}',
placeholder: false,
initial: initialOf(firstRecipient.name, fallback: firstRecipient.email),
);
case MailboxRole.inbox:
case MailboxRole.junk:
case MailboxRole.trash:
case MailboxRole.archive:
case null:
final MailAddress? from = message.from;
if (from == null) {
return (
text: MailListTexts.noSender,
placeholder: false,
initial: null,
);
}
return (
text: from.displayName,
placeholder: false,
initial: initialOf(from.name, fallback: from.email),
);
}
}
/// 이름 앞의 "(주)" · "[공지]" 같은 묶음 — 머리글자에서 뺀다
final RegExp _leadingBracket = RegExp(r'^\s*[(\[][^)\]]*[)\]]\s*');
/// 동그란 사진의 머리글자 — "(주)한빛솔루션" → "한", 이름이 없으면 주소의 첫 글자(웹 `initialOf` 와 같다). 둘 다 비면 null
String? initialOf(String? name, {String fallback = ''}) {
final String cleaned = (name ?? '').replaceFirst(_leadingBracket, '').trim();
final String source = cleaned.isNotEmpty ? cleaned : fallback.trim();
if (source.isEmpty) return null;
return String.fromCharCode(source.runes.first).toUpperCase();
}
/// 목록의 받은 시각 — 오늘 "오전 10:42" · 어제 "어제" · 올해 "11월 3일" · 그 전 "2025. 10. 24." (웹 `formatMailListTime`).
/// 기기 시간대로 본다
String formatMailListTime(DateTime at, DateTime now) {
final DateTime local = at.toLocal();
final DateTime today = now.toLocal();
if (isSameCalendarDay(local, today)) return koreanTimeOfDay(local);
if (isSameCalendarDay(local, previousCalendarDay(today))) {
return MailListTexts.yesterday;
}
if (local.year == today.year) return koreanMonthDay(local);
return koreanNumericDate(local);
}
/// 붙잡아 둔 발송의 표시 — 예약 "예약 · 11월 7일 오전 9:00"(요일 없이 — 7일 안이라 연도도 없다), 보내기 취소를 기다리는 동안 "보내는 중"
String pendingSendLabel(PendingSend pending) {
if (pending.kind == PendingSendKind.undo) return MailListTexts.sending;
final DateTime local = pending.sendAt.toLocal();
return MailListTexts.scheduled(
'${koreanMonthDay(local)} ${koreanTimeOfDay(local)}',
);
}
@@ -1,16 +1,184 @@
/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
/// 메일 모듈의 화면 글 — 원본은 앱 보드 `App1MailList`(`docs/design/webmail/App1MailList.dc.html`)다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준).
/// 보드에 없는 상태(여러 통 처리의 알림 · 영구 삭제 확인 · 받지 못함 · 붙잡힌 메일 등)의 글은 웹 상수
/// (`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말.
/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다.
/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」.
library;
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다
abstract final class MailTexts {
/// 메일 첫 화면 — 받은편지함 목록
static const String home = '메일';
/// 다른 메일함의 목록 (탭 안)
static const String mailbox = '메일함';
/// 메일 읽기 (전체 화면)
static const String message = '메일 읽기';
/// 메일 쓰기 (전체 화면)
static const String compose = '메일 쓰기';
}
/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」)
abstract final class MailListTexts {
// ── 위 막대 ──
static const String openDrawer = '메일함 열기';
static const String search = '메일 찾기';
/// [찾기]를 눌렀을 때 — 검색 화면은 다음 차례다(보드에 없다)
static const String searchNotReady = '메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요.';
/// 메일함 이름을 받기 전의 제목 — 받은편지함 주소(`/mail`)는 [inbox]
static const String mailboxFallback = '메일함';
/// 받은편지함 — 메일함 목록을 받기 전에도 첫 화면 제목은 이것이다(서버의 표준 이름과 같다)
static const String inbox = '받은편지함';
/// 제목 아래 — "안 읽은 메일 3통" · "안 읽은 메일이 없습니다"
static String unread(int count) =>
count > 0 ? '안 읽은 메일 ${formatCount(count)}통' : '안 읽은 메일이 없습니다';
/// 목록의 화면 낭독기 이름 — "받은편지함 메일"
static String listLabel(String mailboxName) => '$mailboxName 메일';
// ── 줄 ──
/// 안 읽은 줄의 앞 — 화면 낭독기만 읽는다(뱃지 없이 굵은 글자로만 가른다)
static const String unreadPrefix = '안 읽음';
static const String attachment = '첨부 있음';
static const String flagged = '중요';
/// 동그란 사진(고르기) — "고르기: 제목"
static String selectOne(String subject) => '고르기: $subject';
static const String noSubject = '(제목 없음)';
static const String noSender = '(보낸 사람 없음)';
/// 보낸편지함 — 받는 사람이 없을 때
static const String noRecipients = '받는 사람 없음';
/// 임시보관함 — 받는 사람이 없을 때(흐린 글)
static const String draftNoRecipients = '(받는 사람 없음)';
/// 보낸편지함 줄의 앞 — "받는 사람: 박준호 외 2명"
static const String recipientPrefix = '받는 사람: ';
static String recipientsSummary(String first, int others) =>
others > 0 ? '$first 외 ${formatCount(others)}명' : first;
static const String yesterday = '어제';
/// 아직 보내지 않은 내 메일 — 시각 자리
static const String sending = '보내는 중';
static String scheduled(String time) => '예약 · $time';
/// 아직 보내지 않은 메일을 고르려 할 때 (웹 `heldNotSelectable`)
static const String heldNotSelectable =
'보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.';
// ── 새로고침 · 불러오기 ──
/// 당겨서 새로고침 — 화면 낭독기
static const String refreshing = '새 메일을 확인하는 중';
/// 처음 불러오는 동안 — 화면 낭독기
static const String loading = '메일 목록 불러오는 중';
/// 다음 쪽을 받는 동안 — 화면 낭독기
static const String loadingMore = '메일을 더 불러오는 중';
// ── 받지 못함 ──
static const String retry = '다시 시도';
/// 없는 메일함(주소에 남은 옛 id) — 다시 해도 같아 빠져나갈 단추를 준다
static const String toInbox = '받은편지함으로';
static String traceCode(String code) => '문의 코드 $code';
// ── 빈 메일함 ──
/// "휴지통이 비어 있습니다"
static String emptyTitle(String mailboxName) =>
'$mailboxName${subjectParticle(mailboxName)} 비어 있습니다';
/// 메일함마다 빈 메일함 안내 — 키 null 은 사용자가 만든 메일함
static String emptyHint(MailboxRole? role) => switch (role) {
MailboxRole.inbox => '새 메일이 오면 여기에 표시됩니다.',
MailboxRole.drafts => '임시 저장한 메일이 여기에 표시됩니다.',
MailboxRole.sent => '보낸 메일이 여기에 표시됩니다.',
MailboxRole.junk => '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
MailboxRole.trash => '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
MailboxRole.archive => '보관한 메일이 여기에 표시됩니다.',
null => '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
};
/// 휴지통 · 스팸메일함 목록 위의 30일 줄 (웹 13단계 — 메일이 없으면 빈 안내에 붙인다)
static String? retentionNotice(MailboxRole? role) => switch (role) {
MailboxRole.trash => '휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
MailboxRole.junk => '스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
_ => null,
};
// ── [메일 쓰기] ──
static const String compose = '메일 쓰기';
}
/// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」)
abstract final class MailDrawerTexts {
static const String title = '메일함';
static const String myMailboxes = '내 메일함';
/// 서랍 밖(가림막)을 눌러 닫을 때 — 화면 낭독기 (보드에 없다)
static const String close = '메일함 닫기';
/// 안 읽은 수 — 화면 낭독기는 "안 읽음 3"으로 읽는다(웹과 같다)
static String unreadCount(int count) => '안 읽음 ${formatCount(count)}';
}
/// 여러 통 고르기 (보드 `App1MailList` 「길게 눌러 고르기」 · 웹 3단계 "여러 통 처리")
abstract final class MailBulkTexts {
/// 고르기 막대 — 화면 낭독기
static const String toolbar = '고른 메일';
static String selected(int count) => '${formatCount(count)}통 선택됨';
static const String clear = '선택 해제';
static const String markRead = '읽음 표시';
/// 고른 것이 모두 읽음이면 [markRead] 대신
static const String markUnread = '안 읽음 표시';
static const String flag = '중요 표시';
/// 고른 것이 모두 중요면 [flag] 대신
static const String unflag = '중요 해제';
static const String move = '이동';
/// 이동 시트의 제목
static const String moveSheetTitle = '옮길 메일함';
static const String delete = '삭제';
/// 휴지통에서는 [delete] 대신
static const String purge = '영구 삭제';
static const String undo = '되돌리기';
/// "메일 3통을 휴지통으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로'
static String moved(
int done,
String mailboxName, {
int skipped = 0,
}) => _withSkipped(
'메일 ${formatCount(done)}통을 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.',
done,
skipped,
);
static String purged(int done, {int skipped = 0}) =>
_withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped);
static String restored(int done, {int skipped = 0}) =>
_withSkipped('메일 ${formatCount(done)}통을 되돌렸습니다.', done, skipped);
/// 일부를 처리하지 못했으면 뒤에 덧붙인다 — 하나도 못 했으면 그 문장만
static String _withSkipped(String text, int done, int skipped) {
if (skipped <= 0) return text;
final String suffix = '${formatCount(skipped)}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.';
return done > 0 ? '$text $suffix' : suffix;
}
// ── 영구 삭제 확인 (휴지통에서만 — 처음 초점은 [취소]) ──
static String purgeTitle(int count) => '메일 ${formatCount(count)}통을 영구 삭제할까요?';
static const String purgeMessage = '휴지통에서 지운 메일은 되돌릴 수 없습니다.';
static const String purgeConfirm = '영구 삭제';
static const String purgeCancel = '취소';
}
@@ -0,0 +1,393 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/router/shell_bar.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_app_bar.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 메일 목록 — 앱 보드 `App1MailList` (앱 디자인 README 「메일 목록」).
///
/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(아직 준비 중 — 알림만). 서랍은 탭 막대 위까지 덮는다.
/// - 줄을 누르면 읽기 화면(전체 화면 — 임시보관함은 쓰기 화면에서 이어 쓴다). 읽음 처리는 읽기 화면이 한다.
/// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다.
/// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인).
/// - 화면이 보이는지(다른 탭 · 전체 화면 · 위에 쌓인 메일함에 가렸는지)는 TickerMode 로 안다.
class MailListPage extends ConsumerStatefulWidget {
const MailListPage({this.mailboxId, super.key});
/// 주소의 메일함 id — null 이면 받은편지함(주소 `/mail`)
final String? mailboxId;
@override
ConsumerState<MailListPage> createState() => _MailListPageState();
}
class _MailListPageState extends ConsumerState<MailListPage> {
/// 컨트롤러에 마지막으로 알린 보임 — 그린 뒤에 알린다(그리는 동안에는 프로바이더를 바꿀 수 없다)
bool? _reportedVisible;
bool _wantVisible = false;
MailListController get _controller =>
ref.read(mailListProvider(widget.mailboxId).notifier);
@override
void didUpdateWidget(MailListPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.mailboxId != widget.mailboxId) _reportedVisible = null;
}
void _scheduleVisibilityReport() {
if (_reportedVisible == _wantVisible) return;
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
if (!mounted || _reportedVisible == _wantVisible) return;
_reportedVisible = _wantVisible;
_controller.setVisible(_wantVisible);
});
}
// ── 하는 일 ──
Future<void> _openDrawer(String? currentMailboxId) async {
final Mailbox? chosen = await showMailboxDrawer(
context,
currentMailboxId: currentMailboxId,
);
if (chosen == null || !mounted || chosen.id == currentMailboxId) return;
context.go(
chosen.role == MailboxRole.inbox
? MailRoutes.home
: MailRoutes.mailbox(chosen.id),
);
}
void _showSearchNotice() {
ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady);
}
void _toggle(MessageSummary message) {
if (_controller.toggleSelection(message.id)) return;
if (message.isHeld) {
ref
.read(mailToastProvider.notifier)
.notify(MailListTexts.heldNotSelectable);
}
}
void _open(MessageSummary message, MailboxRole? role) {
unawaited(
context.push<void>(
role == MailboxRole.drafts
? MailRoutes.composeDraft(message.id)
: MailRoutes.message(message.id),
),
);
}
Future<void> _move(List<Mailbox> mailboxes, String? currentMailboxId) async {
final List<MailboxNode> targets = moveTargetsOf(
mailboxes,
currentMailboxId,
);
if (targets.isEmpty) return;
final Mailbox? chosen = await showMailboxPickerSheet(
context,
targets: targets,
);
if (chosen == null || !mounted) return;
await _controller.moveSelected(chosen);
}
Future<void> _delete({required bool inTrash, required int count}) async {
if (!inTrash) {
await _controller.moveSelectedToTrash();
return;
}
final bool confirmed = await showConfirmDialog(
context,
title: MailBulkTexts.purgeTitle(count),
message: MailBulkTexts.purgeMessage,
confirmLabel: MailBulkTexts.purgeConfirm,
cancelLabel: MailBulkTexts.purgeCancel,
danger: true,
);
if (!confirmed || !mounted) return;
await _controller.purgeSelected();
}
@override
Widget build(BuildContext context) {
final MailListState list = ref.watch(mailListProvider(widget.mailboxId));
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
final Mailbox? mailbox = mailboxes.byId(list.mailboxId);
final bool inboxRoute = widget.mailboxId == null;
final MailboxRole? role =
mailbox?.role ?? (inboxRoute ? MailboxRole.inbox : null);
final String title =
mailbox?.name ??
(inboxRoute ? MailListTexts.inbox : MailListTexts.mailboxFallback);
final bool selecting = list.selecting;
final bool inTrash = role == MailboxRole.trash;
// 주소의 메일함을 메일함 목록과 맞춘 뒤 그린다 — 역할을 알기 전에는 역할에 기대는 것
// (고르기 막대의 삭제 · 임시보관함 줄 열기 · 보낸편지함 줄 모양 · 30일 줄)을 그리지 않는다
final bool mailboxesReady = mailboxes.status == MailboxesStatus.ready;
final bool unknownMailbox =
mailboxesReady && !inboxRoute && mailbox == null;
_wantVisible = TickerMode.valuesOf(context).enabled;
_scheduleVisibilityReport();
final PreferredSizeWidget appBar = selecting
? MailSelectionBar(
count: list.selectedCount,
allRead: list.allSelectedRead,
allFlagged: list.allSelectedFlagged,
inTrash: inTrash,
busy: list.busy,
onClear: _controller.clearSelection,
onToggleRead: () => unawaited(_controller.toggleSelectedRead()),
onToggleFlag: () => unawaited(_controller.toggleSelectedFlag()),
onMove: () => unawaited(_move(mailboxes.items, list.mailboxId)),
onDelete: () =>
unawaited(_delete(inTrash: inTrash, count: list.selectedCount)),
)
: MailListAppBar(
title: title,
subtitle: mailbox != null && showsUnreadCount(role)
? MailListTexts.unread(mailbox.unreadCount)
: null,
onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)),
onSearch: _showSearchNotice,
);
return PopScope(
canPop: !selecting,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (!didPop) _controller.clearSelection();
},
child: ShellBarHidden(
module: AppModuleId.mail,
hidden: selecting,
child: Scaffold(
appBar: appBar,
body: _MailListBody(
list: list,
role: role,
title: title,
mailboxesReady: mailboxesReady,
mailboxesFailure: mailboxes.failure,
unknownMailbox: unknownMailbox,
onRefresh: _controller.refresh,
onRetry: () => unawaited(_controller.retry()),
onRetryMailboxes: () =>
unawaited(ref.read(mailboxesProvider.notifier).reload()),
onLeave: inboxRoute ? null : () => context.go(MailRoutes.home),
onLoadMore: () => unawaited(_controller.loadMore()),
onRowTap: (MessageSummary message) =>
selecting ? _toggle(message) : _open(message, role),
onRowSelect: _toggle,
),
floatingActionButton: MailComposeArea(
key: const ValueKey<String>('mail-compose-area'),
showCompose: !selecting,
safeInsets: MediaQuery.paddingOf(context),
onCompose: () => unawaited(context.push<void>(MailRoutes.compose)),
),
floatingActionButtonLocation: const MailComposeAreaLocation(),
),
),
);
}
}
/// 목록 자리 — 불러오는 중 · 받지 못함 · 빈 메일함 · 목록. 어느 상태에서도 당겨서 새로고침이 된다.
/// 메일함 목록(역할)을 받기 전에는 목록을 그리지 않고 뼈대를, 받지 못했으면 그 실패를, 주소의 메일함이 없으면 `RES_001` 을 보인다
class _MailListBody extends StatelessWidget {
const _MailListBody({
required this.list,
required this.role,
required this.title,
required this.mailboxesReady,
required this.mailboxesFailure,
required this.unknownMailbox,
required this.onRefresh,
required this.onRetry,
required this.onRetryMailboxes,
required this.onLeave,
required this.onLoadMore,
required this.onRowTap,
required this.onRowSelect,
});
final MailListState list;
final MailboxRole? role;
final String title;
final bool mailboxesReady;
final ApiException? mailboxesFailure;
final bool unknownMailbox;
final Future<void> Function() onRefresh;
final VoidCallback onRetry;
final VoidCallback onRetryMailboxes;
final VoidCallback? onLeave;
final VoidCallback onLoadMore;
final ValueChanged<MessageSummary> onRowTap;
final ValueChanged<MessageSummary> onRowSelect;
@override
Widget build(BuildContext context) {
final ColorScheme scheme = Theme.of(context).colorScheme;
final ApiException? boxesFailure = mailboxesFailure;
const Widget skeleton = _SkeletonList();
final Widget content = switch (list.status) {
_ when boxesFailure != null => _FillViewport(
child: MailListError(error: boxesFailure, onRetry: onRetryMailboxes),
),
_ when unknownMailbox => _FillViewport(
child: MailListError(
error: ApiException.fromCode('RES_001'),
onRetry: onRetry,
onLeave: onLeave,
),
),
_ when !mailboxesReady => skeleton,
MailListStatus.loading => skeleton,
MailListStatus.failed => _FillViewport(
child: MailListError(
error: list.error!,
onRetry: onRetry,
onLeave: onLeave,
),
),
MailListStatus.ready when list.items.isEmpty => _FillViewport(
child: MailListEmpty(mailboxName: title, role: role),
),
MailListStatus.ready => _MessageList(
list: list,
role: role,
title: title,
onLoadMore: onLoadMore,
onRowTap: onRowTap,
onRowSelect: onRowSelect,
),
};
return RefreshIndicator(
onRefresh: onRefresh,
color: scheme.primary,
backgroundColor: scheme.surface,
semanticsLabel: MailListTexts.refreshing,
child: content,
);
}
}
/// 처음 불러오는 동안 — 뼈대 줄(당겨서 새로고침이 되게 스크롤 안에)
class _SkeletonList extends StatelessWidget {
const _SkeletonList();
@override
Widget build(BuildContext context) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const <Widget>[MailListSkeleton()],
);
}
}
/// 빈 메일함 · 받지 못함 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다
class _FillViewport extends StatelessWidget {
const _FillViewport({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
SliverFillRemaining(hasScrollBody: false, child: Center(child: child)),
],
);
}
}
class _MessageList extends ConsumerWidget {
const _MessageList({
required this.list,
required this.role,
required this.title,
required this.onLoadMore,
required this.onRowTap,
required this.onRowSelect,
});
final MailListState list;
final MailboxRole? role;
final String title;
final VoidCallback onLoadMore;
final ValueChanged<MessageSummary> onRowTap;
final ValueChanged<MessageSummary> onRowSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
final DateTime now = ref.read(clockProvider)();
final String? notice = MailListTexts.retentionNotice(role);
final int noticeCount = notice == null ? 0 : 1;
final bool footer = list.hasMore || list.loadingMore || list.loadMoreFailed;
final List<MessageSummary> items = list.items;
return Semantics(
container: true,
explicitChildNodes: true,
label: MailListTexts.listLabel(title),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
// 마지막 줄이 [메일 쓰기]에 가리지 않게 (보드의 목록 아래 여백 96)
padding: const EdgeInsets.only(
bottom: AppSize.fabHeight + AppSpacing.s40,
),
itemCount: noticeCount + items.length + (footer ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (notice != null && index == 0) return MailRetentionNotice(notice);
final int row = index - noticeCount;
if (row >= items.length) {
return MailListFooter(
loading: list.loadingMore,
failed: list.loadMoreFailed,
onReached: onLoadMore,
onRetry: onLoadMore,
);
}
final MessageSummary message = items[row];
return MessageRow(
key: ValueKey<String>(message.id),
view: mailRowViewOf(message, role: role, now: now),
selected: list.selectedIds.contains(message.id),
onTap: () => onRowTap(message),
onLongPress: () => onRowSelect(message),
onAvatarTap: () => onRowSelect(message),
);
},
),
);
}
}
@@ -0,0 +1,914 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/domain/mail_bulk.dart';
import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
/// 새 메일 확인으로 맞출 때 다시 받는 최대 수 — 불러온 만큼, 그보다 많이 불러왔으면 이만큼만(웹 `MAIL_LIST_SYNC_MAX`)
const int kMailListSyncMax = 100;
/// 보낼 시각 뒤 이만큼 기다렸다 목록을 다시 본다 — 엔진이 발송을 풀어도 새 메일 확인의 상태는 바뀌지 않는다
/// (계약 2절 "보내기 취소 · 예약 발송" — 웹 `PENDING_RELEASE_GRACE_MS`)
const Duration kHeldReleaseGrace = Duration(seconds: 2);
/// 다시 본 뒤에도 시각이 지난 채 붙잡힌 것으로 보이면 이 간격으로 다시 본다(웹 `PENDING_RELEASE_RECHECK_MS`)
const Duration kHeldReleaseRecheck = Duration(seconds: 5);
/// 그렇게 더 보는 횟수 — 넘으면 그 발송은 그만 본다(다음 새 메일 확인 · 새로고침이 맞춘다)
const int kHeldReleaseMaxRechecks = 3;
/// 메일함 하나의 목록 — 주소의 메일함 id 마다 하나(null 은 받은편지함 — 주소 `/mail`).
/// 목록 화면이 있는 동안만 둔다(autoDispose) — 메일함을 바꾸면 앞 목록은 사라지고 그 응답은 버린다.
final NotifierProviderFamily<MailListController, MailListState, String?>
mailListProvider = NotifierProvider.autoDispose
.family<MailListController, MailListState, String?>(MailListController.new);
/// 읽음 · 중요 표시의 바꿀 값
typedef _FlagChange = ({bool? isRead, bool? isFlagged});
/// 보내는 중인 표시 하나 — 값과 그것을 보낸 요청(같은 칸을 잇달아 바꾸면 마지막 요청이 주인이다)
typedef _PendingFlag = ({bool value, int owner});
/// 메일함 하나의 목록 — 계약 2절 `GET /api/messages` · "여러 통 한꺼번에" · "새 메일 확인" (웹 `stores/mail/list.ts` · `selection.ts` · `bulk.ts` 와 같은 규칙)
///
/// 늦게 온 응답 막기:
/// - 세대([_listSeq]): 처음부터 다시 받을 때마다(다시 시도 · 당겨서 새로고침 · 세션이 바뀜) 오른다 — 그 전에 보낸 목록 요청의 응답은 버린다.
/// - 이 목록이 바꾼 횟수([_localGeneration]): 읽음 · 중요 표시 · 빼기 · 이어 받기마다 — 그 전에 떠난 맞추기의 응답은 옛 상태라 버리고 한 번 더 맞춘다.
/// - 맞추기는 한 번에 하나다([_syncRun]) — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞춘다(늦게 온 옛 응답이 새 응답을 덮지 않게).
/// - 보내는 중인 읽음 · 중요 표시([_pendingRead] · [_pendingFlagged])는 그사이 받은 목록 위에 덮어쓴다(옛 값으로 되돌아 보이지 않게).
/// 칸마다 마지막으로 보낸 요청이 주인이다 — 앞 요청이 끝나도 뒤 요청의 표시는 남는다.
/// - 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 이 목록을 바꾼다(다시 만들어졌거나 사라졌으면 버린다).
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 개수 · 알림 · [되돌리기]도 하지 않는다 — 끝난 세션의 결과를 새 세션에 보이지 않게.
///
/// 여러 통 처리:
/// - 고를 수 있는 것은 불러온 메일까지, 아직 보내지 않은 메일(붙잡힌 발송)은 고를 수 없다.
/// 목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다(계약 2절 "메일함을 바꾸면 선택을 푼다").
/// - 읽음 · 중요 표시는 화면을 먼저 바꾸고 고르기를 끝낸다(앱 보드) — 서버가 바꾸지 못한 메일(`failedIds`) · 보내지 못한 메일만 되돌리고,
/// 통째로 실패했으면 고른 것도 되살린다(다시 해 볼 수 있게).
/// - 이동 · 삭제는 응답의 id 만 뺀다. 하나라도 처리했으면 고르기를 끝내고, 통째로 실패했으면 고른 것을 그대로 둔다.
/// - 휴지통 밖의 [삭제]는 휴지통으로 옮기기(+ [되돌리기]), 휴지통에서는 확인 뒤 영구 삭제(`POST /api/message-deletions`) — 웹처럼 `DELETE` 는 쓰지 않는다.
/// - 100통씩 차례로 보낸다.
class MailListController extends Notifier<MailListState> {
MailListController(this.route);
/// 주소의 메일함 id — null 이면 받은편지함
final String? route;
int _listSeq = 0;
int _localGeneration = 0;
final Map<String, _PendingFlag> _pendingRead = <String, _PendingFlag>{};
final Map<String, _PendingFlag> _pendingFlagged = <String, _PendingFlag>{};
/// 읽음 · 중요 표시 요청마다 하나씩 — 보내는 중인 표시의 주인
int _flagRequestSeq = 0;
/// 목록 화면이 보인다 — 다른 탭 · 전체 화면 · 위에 쌓인 메일함 아래에 가렸으면 거짓
bool _visible = false;
/// 가려진 동안 메일이 바뀌었다 — 다시 보이면 맞춘다
bool _dirty = false;
MailStateWatcher? _watcher;
/// 도는 맞추기 — 끝날 때까지 새 맞추기는 이것에 묶는다
Future<void>? _syncRun;
/// 맞추는 동안 또 맞춰 달라고 했다
bool _syncAgain = false;
/// 서버 시계 − 기기 시계 — 받은 목록 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다)
Duration _serverOffset = Duration.zero;
Timer? _heldTimer;
final Map<String, int> _heldRechecks = <String, int>{};
MailRepository get _repository => ref.read(mailRepositoryProvider);
Clock get _clock => ref.read(clockProvider);
@override
MailListState build() {
final String? account = ref.watch(mailAccountProvider);
_listSeq++;
_localGeneration++;
_pendingRead.clear();
_pendingFlagged.clear();
_dirty = false;
_syncRun = null;
_syncAgain = false;
_serverOffset = Duration.zero;
_heldRechecks.clear();
_cancelHeldTimer();
ref.onDispose(_onDispose);
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 — 세션 없는 요청이 갱신을 부르지 않게
if (account == null) return MailListState(mailboxId: route);
ref.listen<MailChange>(mailStateWatcherProvider, (
MailChange? previous,
MailChange next,
) {
// 처음부터 다시 만들 때의 0 은 바뀜이 아니다
if (next.seq > (previous?.seq ?? 0)) _onMailChange(next);
});
// 세션이 바뀌는 동안 아무도 듣지 않아 새 메일 확인이 새로 만들어졌을 수 있다 — 보이는 목록이면 새 것에 다시 알린다
// (build 안에서는 다른 프로바이더를 바꿀 수 없다)
if (_visible) {
final Ref built = ref;
scheduleMicrotask(() {
if (built.mounted && _visible) _tellWatcher();
});
}
final String? mailboxId =
route ??
ref.watch(
mailboxesProvider.select((MailboxesState s) => s.defaultMailboxId),
);
if (mailboxId == null) {
final ApiException? failure = ref.watch(
mailboxesProvider.select((MailboxesState s) => s.failure),
);
return failure == null
? const MailListState()
: const MailListState().failed(failure);
}
unawaited(_openFirst(mailboxId, _listSeq, silent: true));
return MailListState(mailboxId: mailboxId);
}
void _onDispose() {
_cancelHeldTimer();
final MailStateWatcher? watcher = _watcher;
if (watcher == null) return;
// 생명주기 안에서는 다른 프로바이더를 바꿀 수 없다 — 끝난 뒤에, 정말 사라졌을 때만 새 메일 확인에서 뺀다
// (다시 만들어진 것이면 Ref 가 새로 살아 있고 화면도 그대로다)
scheduleMicrotask(() {
if (!ref.mounted) watcher.setViewing(this, viewing: false);
});
}
// ── 화면 ─────────────────────────────────────────
/// 목록 화면이 보이게 · 안 보이게 됐다 — 보이는 동안만 새 메일을 확인하고, 가려진 동안 바뀌었으면 다시 보일 때 맞춘다.
/// 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다
void setVisible(bool visible) {
if (!ref.mounted) return;
final bool was = _visible;
_visible = visible;
_tellWatcher();
if (!visible) clearSelection();
if (visible && !was && _dirty) {
_dirty = false;
unawaited(_sync());
}
}
/// 새 메일 확인에 이 목록이 보이는지 알린다 — 새 메일 확인이 새로 만들어졌으면 옛것에서 빼고 새 것에
void _tellWatcher() {
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final MailStateWatcher? previous = _watcher;
if (previous != null && !identical(previous, watcher)) {
previous.setViewing(this, viewing: false);
}
_watcher = watcher;
watcher.setViewing(this, viewing: _visible);
}
/// [다시 시도] — 첫 쪽을 다시 받는다. 받은편지함 주소에서 메일함 목록을 받지 못했으면 그것부터
Future<void> retry() async {
if (!ref.mounted) return;
final String? mailboxId = state.mailboxId;
if (mailboxId == null) {
await ref.read(mailboxesProvider.notifier).reload();
return;
}
_listSeq++;
_localGeneration++;
state = MailListState(mailboxId: mailboxId);
await _openFirst(mailboxId, _listSeq, silent: true);
}
/// 당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받는다(보이는 목록은 둔 채). 처음 받지 못했던 목록이면 [retry]
Future<void> refresh() async {
if (!ref.mounted) return;
ref.read(mailboxesProvider.notifier).syncCounts();
final String? mailboxId = state.mailboxId;
if (mailboxId == null || state.status != MailListStatus.ready) {
await retry();
return;
}
_listSeq++;
_dirty = false;
state = state.copyWith(refreshing: true);
await _openFirst(mailboxId, _listSeq, silent: false);
}
/// 첫 쪽을 받는다 — 처음 여는 목록은 알림 없이(화면이 받지 못함을 그린다), 새로고침은 인터셉터가 알린다
Future<void> _openFirst(
String mailboxId,
int seq, {
required bool silent,
}) async {
final Ref ref = this.ref;
final bool keepVisible = !silent;
try {
final MailPage<MessageSummary> page = await _repository.listMessages(
mailboxId: mailboxId,
silent: silent,
);
if (!ref.mounted || seq != _listSeq) return;
_noteServerTime(page);
_localGeneration++;
state = state
.withItems(_withPendingFlags(page.items), pageInfo: page.pageInfo)
.copyWith(
refreshing: false,
loadingMore: false,
loadMoreFailed: false,
);
_afterItemsChanged();
} on DioException catch (error) {
if (!ref.mounted || seq != _listSeq) return;
if (keepVisible) {
// 보이던 목록은 그대로 둔다 — 알림은 인터셉터가 띄웠다. 새로고침이 세대를 올려 그 전에 떠난 이어 받기는 이미 버렸다
state = state.copyWith(refreshing: false, loadingMore: false);
return;
}
state = state.failed(toApiException(error));
}
}
/// 다음 쪽 — 끝 근처에 닿으면 화면이 부른다(끝의 [다시 시도]도). `pageInfo.nextCursor` 를 해석하지 않고 그대로 보낸다
Future<void> loadMore() async {
final Ref ref = this.ref;
if (!ref.mounted) return;
final MailListState current = state;
final String? cursor = current.pageInfo?.nextCursor;
final String? mailboxId = current.mailboxId;
if (!current.hasMore ||
cursor == null ||
mailboxId == null ||
current.loadingMore ||
current.refreshing ||
current.status != MailListStatus.ready) {
return;
}
final int seq = _listSeq;
state = current.copyWith(loadingMore: true, loadMoreFailed: false);
try {
final MailPage<MessageSummary> page = await _repository.listMessages(
mailboxId: mailboxId,
cursor: cursor,
);
if (!ref.mounted || seq != _listSeq) return;
_noteServerTime(page);
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
final Set<String> known = <String>{
for (final MessageSummary message in state.items) message.id,
};
final List<MessageSummary> added = <MessageSummary>[
for (final MessageSummary message in _withPendingFlags(page.items))
if (!known.contains(message.id)) message,
];
// 새로 붙은 것 없이 같은 커서가 돌아왔다 — 끝 표시가 같은 커서로 끝없이 부르지 않게 [다시 시도]로 둔다
// (모두 겹친 쪽이라도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 부른다)
final bool stalled = added.isEmpty && page.pageInfo.nextCursor == cursor;
// 이어 받은 뒤의 맞추기 응답은 이 쪽을 모른다 — 그 전에 떠난 맞추기는 버리고 한 번 더 맞춘다
_localGeneration++;
state = state
.withItems(<MessageSummary>[
...state.items,
...added,
], pageInfo: page.pageInfo)
.copyWith(loadingMore: false, loadMoreFailed: stalled);
_afterItemsChanged();
} on DioException {
if (!ref.mounted || seq != _listSeq) return;
// 알림은 인터셉터가 띄웠다 — 끝에 [다시 시도]를 둔다(저절로 다시 하지 않는다)
state = state.copyWith(loadingMore: false, loadMoreFailed: true);
}
}
void _noteServerTime(MailPage<MessageSummary> page) {
final DateTime? serverTime = page.serverTime;
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
}
// ── 새 메일 확인으로 맞추기 ───────────────────────────
void _onMailChange(MailChange change) {
if (identical(change.source, this)) return;
if (_visible) {
unawaited(_sync());
} else {
_dirty = true;
}
}
/// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인").
/// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다.
/// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다)
Future<void> _sync() {
final Future<void>? running = _syncRun;
if (running != null) {
_syncAgain = true;
return running;
}
final Future<void> run = _runSyncs(ref);
_syncRun = run;
return run;
}
Future<void> _runSyncs(Ref ref) async {
try {
bool retriedForLocalChange = false;
do {
_syncAgain = false;
final bool staleByLocalChange = await _syncOnce(ref);
// 묻는 사이 이 목록이 바뀌었으면 그 응답은 버리고 한 번 더(한 번만)
if (staleByLocalChange && !retriedForLocalChange) {
retriedForLocalChange = true;
_syncAgain = true;
}
} while (_syncAgain && ref.mounted);
} finally {
// 그사이 다시 만들어졌으면 새 build 가 이미 비웠다
if (ref.mounted) _syncRun = null;
}
}
/// 맞추기 한 번 — 묻는 사이 이 목록이 바뀌어 응답을 버렸으면 참
Future<bool> _syncOnce(Ref ref) async {
if (!ref.mounted) return false;
final MailListState current = state;
final String? mailboxId = current.mailboxId;
if (current.status != MailListStatus.ready || mailboxId == null) {
return false;
}
final int seq = _listSeq;
final int generation = _localGeneration;
final int limit = math.min(
kMailListSyncMax,
math.max(kMailPageSize, current.items.length),
);
try {
final MailPage<MessageSummary> page = await _repository.listMessages(
mailboxId: mailboxId,
limit: limit,
silent: true,
);
if (!ref.mounted ||
seq != _listSeq ||
state.status != MailListStatus.ready) {
return false;
}
if (generation != _localGeneration) return true;
_noteServerTime(page);
final MailListMerge merged = mergeRefreshedList(
current: state.items,
currentPageInfo: state.pageInfo,
fetched: _withPendingFlags(page.items),
fetchedPageInfo: page.pageInfo,
);
state = state.withItems(merged.items, pageInfo: merged.pageInfo);
_afterItemsChanged();
} on DioException {
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
}
return false;
}
/// 목록이 바뀐 뒤 — 아직 보내지 않은 메일이 풀릴 때를 다시 잡는다
void _afterItemsChanged() => _scheduleHeldCheck();
// ── 아직 보내지 않은 메일이 풀릴 때 ─────────────────────
String _heldKey(MessageSummary message, PendingSend pending) =>
'${message.id}\n${pending.sendAt.toIso8601String()}';
/// 서버 시계로 지금 — 받은 응답의 `Date` 로 잰 차이를 기기 시계에 더한다(없으면 기기 시계)
DateTime _serverNow() => _clock().add(_serverOffset);
/// 가장 이른 보낼 시각 + 2초에 목록을 다시 본다 — 시각이 지난 채 남으면 5초마다 3번 더(웹 `usePendingSendRefresh`).
/// 보낼 시각은 서버 시계로 잰다(기기 시계가 틀려도 제때 본다)
void _scheduleHeldCheck() {
_cancelHeldTimer();
if (!ref.mounted || state.status != MailListStatus.ready) return;
final DateTime now = _serverNow();
DateTime? next;
for (final MessageSummary message in state.items) {
final PendingSend? pending = message.pendingSend;
if (pending == null) continue;
final DateTime due = pending.sendAt.add(kHeldReleaseGrace);
final DateTime at;
if (due.isAfter(now)) {
at = due;
} else {
final int rechecks = _heldRechecks[_heldKey(message, pending)] ?? 0;
if (rechecks > kHeldReleaseMaxRechecks) continue;
at = now.add(kHeldReleaseRecheck);
}
if (next == null || at.isBefore(next)) next = at;
}
if (next == null) return;
_heldTimer = Timer(next.difference(now), _onHeldDue);
}
void _onHeldDue() {
_heldTimer = null;
final Ref ref = this.ref;
if (!ref.mounted) return;
final DateTime now = _serverNow();
for (final MessageSummary message in state.items) {
final PendingSend? pending = message.pendingSend;
if (pending == null ||
pending.sendAt.add(kHeldReleaseGrace).isAfter(now)) {
continue;
}
final String key = _heldKey(message, pending);
_heldRechecks[key] = (_heldRechecks[key] ?? 0) + 1;
}
if (!_visible) {
_dirty = true;
return;
}
// 맞추기가 실패해도(연결 끊김) 다음 차례를 다시 잡는다 — 횟수 한도는 그대로
unawaited(
_sync().whenComplete(() {
if (ref.mounted) _scheduleHeldCheck();
}),
);
}
void _cancelHeldTimer() {
_heldTimer?.cancel();
_heldTimer = null;
}
// ── 고르기 ─────────────────────────────────────────
/// 하나 고르기 · 풀기 — 동그란 사진 · 길게 누르기 · 고르는 중의 줄 누르기.
/// 고를 수 없는 메일(아직 보내지 않은 메일 · 목록에 없음)이면 아무것도 하지 않고 거짓
bool toggleSelection(String id) {
if (!ref.mounted) return false;
final MessageSummary? message = _find(id);
if (message == null || message.isHeld) return false;
final Set<String> next = <String>{...state.selectedIds};
if (!next.remove(id)) next.add(id);
state = state.copyWith(selectedIds: Set<String>.unmodifiable(next));
return true;
}
/// 고르기를 끝낸다 — ✕ · 뒤로 가기 · 목록이 가려짐
void clearSelection() {
if (!ref.mounted || state.selectedIds.isEmpty) return;
state = state.copyWith(selectedIds: const <String>{});
}
MessageSummary? _find(String id) {
for (final MessageSummary message in state.items) {
if (message.id == id) return message;
}
return null;
}
// ── 여러 통 처리 ─────────────────────────────────────
/// [읽음 표시] — 고른 것이 모두 읽음이면 안 읽음으로
Future<void> toggleSelectedRead() async {
if (!ref.mounted) return;
await _patchSelectedFlags((
isRead: !state.allSelectedRead,
isFlagged: null,
));
}
/// [중요 표시] — 고른 것이 모두 중요면 해제
Future<void> toggleSelectedFlag() async {
if (!ref.mounted) return;
await _patchSelectedFlags((
isRead: null,
isFlagged: !state.allSelectedFlagged,
));
}
/// 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고 고르기를 끝낸다.
/// 서버가 바꾸지 못한 메일 · 보내지 못한 메일만 바꾼 칸을 되돌리고(그사이 보내는 중인 다른 표시는 다시 입힌다),
/// 통째로 실패했으면 고른 것도 되살린다. 알림은 띄우지 않는다(목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
Future<void> _patchSelectedFlags(_FlagChange change) async {
final Ref ref = this.ref;
if (!ref.mounted) return;
final List<MessageSummary> targets = state.selectedMessages;
if (targets.isEmpty) return;
final int seq = _listSeq;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final List<String> ids = <String>[
for (final MessageSummary message in targets) message.id,
];
final Map<String, MessageSummary> before = <String, MessageSummary>{
for (final MessageSummary message in targets) message.id: message,
};
_adjustUnread(mailboxes, targets, change.isRead, direction: 1);
_localGeneration++;
state = state
.withItems(_applyFlags(state.items, ids.toSet(), change))
.copyWith(selectedIds: const <String>{});
final int owner = ++_flagRequestSeq;
_beginPending(ids, change, owner);
final MailRepository repository = _repository;
final BulkResult result = await sendInChunks(ids, kMailBulkMaxIds, (
List<String> chunk,
) async {
final MessagesPatched patched = await repository.updateMessages(
chunk,
MessageChanges(isRead: change.isRead, isFlagged: change.isFlagged),
);
return (done: patched.updatedIds, skipped: patched.failedIds);
});
final bool alive = ref.mounted;
final bool sameSession = tokens.sessionGeneration == session;
final Set<String> revertIds = <String>{
...result.skippedIds,
...result.unsentIds,
};
final List<MessageSummary> reverted = <MessageSummary>[
for (final String id in revertIds) ?before[id],
];
final bool fresh = alive && seq == _listSeq;
if (alive) {
_endPending(ids, change, owner);
_localGeneration++;
}
if (fresh) {
if (reverted.isNotEmpty) {
state = state.withItems(
_withPendingFlags(<MessageSummary>[
for (final MessageSummary message in state.items)
if (revertIds.contains(message.id) && before[message.id] != null)
message.copyWith(
isRead: change.isRead == null
? null
: before[message.id]!.isRead,
isFlagged: change.isFlagged == null
? null
: before[message.id]!.isFlagged,
)
else
message,
]),
);
}
// 통째로 실패했다 — 다시 해 볼 수 있게 고른 것을 되살린다(목록에서 빠졌거나 고를 수 없게 된 메일은 뺀다)
if (result.doneIds.isEmpty && result.skippedIds.isEmpty) {
final List<MessageSummary> items = state.items;
state = state
.copyWith(
selectedIds: Set<String>.unmodifiable(<String>{
...state.selectedIds,
...ids,
}),
)
.withItems(items);
}
} else if (alive) {
// 그사이 처음부터 다시 받았다 — 그 목록은 이 일이 서버에 닿기 전의 값일 수 있어 서버 기준으로 다시 맞춘다
unawaited(_sync());
}
// 끝난 세션의 결과는 새 세션에 보이지 않는다
if (!sameSession) return;
if (reverted.isNotEmpty) {
_adjustUnread(mailboxes, reverted, change.isRead, direction: -1);
}
// 바꾼 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다)
if (result.doneIds.isNotEmpty) {
watcher.markChanged(source: fresh ? this : null);
} else if (change.isRead != null) {
mailboxes.syncCounts();
}
}
/// 안 읽은 개수를 먼저 바꾼다 — [direction] 1 은 바꿀 때, -1 은 되돌릴 때
void _adjustUnread(
MailboxesNotifier mailboxes,
List<MessageSummary> messages,
bool? isRead, {
required int direction,
}) {
if (isRead == null) return;
for (final MessageSummary message in messages) {
if (message.isRead == isRead) continue;
mailboxes.adjustUnread(message.mailboxIds, (isRead ? -1 : 1) * direction);
}
}
List<MessageSummary> _applyFlags(
List<MessageSummary> items,
Set<String> ids,
_FlagChange change,
) => <MessageSummary>[
for (final MessageSummary message in items)
ids.contains(message.id)
? message.copyWith(isRead: change.isRead, isFlagged: change.isFlagged)
: message,
];
void _beginPending(List<String> ids, _FlagChange change, int owner) {
final bool? isRead = change.isRead;
final bool? isFlagged = change.isFlagged;
for (final String id in ids) {
if (isRead != null) _pendingRead[id] = (value: isRead, owner: owner);
if (isFlagged != null) {
_pendingFlagged[id] = (value: isFlagged, owner: owner);
}
}
}
/// 요청 [owner] 가 끝났다 — 그 요청이 아직 주인인 칸만 내린다(뒤에 보낸 요청의 표시는 남긴다)
void _endPending(List<String> ids, _FlagChange change, int owner) {
for (final String id in ids) {
if (change.isRead != null && _pendingRead[id]?.owner == owner) {
_pendingRead.remove(id);
}
if (change.isFlagged != null && _pendingFlagged[id]?.owner == owner) {
_pendingFlagged.remove(id);
}
}
}
/// 보내는 중인 읽음 · 중요 표시를 받은 목록 위에 덮어쓴다
List<MessageSummary> _withPendingFlags(List<MessageSummary> items) {
if (_pendingRead.isEmpty && _pendingFlagged.isEmpty) return items;
return <MessageSummary>[
for (final MessageSummary message in items)
if ((_pendingRead[message.id], _pendingFlagged[message.id]) case (
final _PendingFlag? read,
final _PendingFlag? flagged,
) when read != null || flagged != null)
message.copyWith(isRead: read?.value, isFlagged: flagged?.value)
else
message,
];
}
/// [삭제] (휴지통 밖) — 휴지통으로 옮긴다. 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다(옮기지 않는다)
Future<void> moveSelectedToTrash() async {
final Ref ref = this.ref;
if (!ref.mounted || state.busy || state.selectedMessages.isEmpty) return;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
Mailbox? trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash);
if (trash == null) {
await mailboxes.reload();
if (!ref.mounted) return;
trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash);
}
if (trash == null) {
ref
.read(errorNotifierProvider)
.show(
ref.read(mailboxesProvider).failure ??
ApiException.fromCode('BIZ_001'),
);
return;
}
await moveSelected(trash);
}
/// 고른 메일을 [target] 으로 옮긴다 — 응답의 `updatedIds` 만 목록에서 빼고 알림에 [되돌리기]를 단다.
/// 그대로 둔 메일 · 보내지 못한 메일이 있으면(503 은 일부만 바뀌었을 수 있다) 목록을 서버 기준으로 다시 맞춘다
Future<void> moveSelected(Mailbox target) async {
final Ref ref = this.ref;
if (!ref.mounted || state.busy) return;
final List<MessageSummary> targets = state.selectedMessages;
if (targets.isEmpty) return;
final int seq = _listSeq;
final MailRepository repository = _repository;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
final String? scope = state.mailboxId;
final Map<String, String> origins = <String, String>{
for (final MessageSummary message in targets)
if (_originOf(message, scope, target.id) case final String origin
when origin != target.id)
message.id: origin,
};
state = state.copyWith(busy: true);
final BulkResult result = await sendInChunks(
<String>[for (final MessageSummary message in targets) message.id],
kMailBulkMaxIds,
(List<String> chunk) async {
final MessagesPatched patched = await repository.updateMessages(
chunk,
MessageChanges(mailboxId: target.id),
);
return (done: patched.updatedIds, skipped: patched.failedIds);
},
);
final bool alive = ref.mounted;
final bool fresh = alive && seq == _listSeq;
_settleRemoval(result, alive: alive, fresh: fresh);
// 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다
if (tokens.sessionGeneration != session) return;
// 옮긴 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다)
if (result.doneIds.isNotEmpty) {
watcher.markChanged(source: fresh ? this : null);
} else {
mailboxes.syncCounts();
}
final int done = result.doneIds.length;
final int skipped = result.skippedIds.length;
if (done == 0 && skipped == 0) return;
final String text = MailBulkTexts.moved(
done,
target.name,
skipped: skipped,
);
final List<BulkOrigin> groups = groupByOrigin(result.doneIds, origins);
if (groups.isEmpty) {
toast.notify(text);
return;
}
toast.offer(
text,
actionLabel: MailBulkTexts.undo,
onAction: () => _restoreMoved(
groups,
repository: repository,
mailboxes: mailboxes,
watcher: watcher,
toast: toast,
tokens: tokens,
session: session,
),
);
}
/// 되돌리기에 쓸 원래 메일함 — 이 목록의 메일함, 그 메일이 거기 없으면 옮길 곳이 아닌 첫 메일함
String? _originOf(MessageSummary message, String? scope, String targetId) {
if (scope != null && message.mailboxIds.contains(scope)) return scope;
for (final String id in message.mailboxIds) {
if (id != targetId) return id;
}
return message.mailboxIds.isEmpty ? null : message.mailboxIds.first;
}
/// 휴지통에서 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지운 메일(`deletedIds`)만 목록에서 뺀다
Future<void> purgeSelected() async {
final Ref ref = this.ref;
if (!ref.mounted || state.busy) return;
final List<MessageSummary> targets = state.selectedMessages;
if (targets.isEmpty) return;
final int seq = _listSeq;
final MailRepository repository = _repository;
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
final TokenStore tokens = ref.read(tokenStoreProvider);
final int session = tokens.sessionGeneration;
state = state.copyWith(busy: true);
final BulkResult result = await sendInChunks(
<String>[for (final MessageSummary message in targets) message.id],
kMailBulkMaxIds,
(List<String> chunk) async {
final MessageDeletions deleted = await repository.deleteMessages(chunk);
return (done: deleted.deletedIds, skipped: deleted.skippedIds);
},
);
final bool alive = ref.mounted;
final bool fresh = alive && seq == _listSeq;
_settleRemoval(result, alive: alive, fresh: fresh);
if (tokens.sessionGeneration != session) return;
if (result.doneIds.isNotEmpty) {
watcher.markChanged(source: fresh ? this : null);
} else {
mailboxes.syncCounts();
}
final int done = result.doneIds.length;
final int skipped = result.skippedIds.length;
if (done > 0 || skipped > 0) {
toast.notify(MailBulkTexts.purged(done, skipped: skipped));
}
}
/// 옮기기 · 영구 삭제가 끝났다 — 이 목록이 그대로면 처리한 메일만 빼고(하나라도 했으면 고르기를 끝낸다),
/// 그대로 둔 메일이 있거나 그사이 처음부터 다시 받았으면 서버 기준으로 다시 맞춘다
void _settleRemoval(
BulkResult result, {
required bool alive,
required bool fresh,
}) {
if (!alive) return;
if (!fresh) {
state = state.copyWith(busy: false);
unawaited(_sync());
return;
}
_removeFromList(result.doneIds.toSet());
state = state.copyWith(
busy: false,
selectedIds: result.doneIds.isEmpty ? null : const <String>{},
);
if (result.hasLeftovers) unawaited(_sync());
}
/// 목록에서 뺀다 — 전체 수도 그만큼 줄인다
void _removeFromList(Set<String> ids) {
if (ids.isEmpty) return;
final List<MessageSummary> kept = <MessageSummary>[
for (final MessageSummary message in state.items)
if (!ids.contains(message.id)) message,
];
final int removed = state.items.length - kept.length;
final MailPageInfo? info = state.pageInfo;
final int? total = info?.total;
_localGeneration++;
state = state.withItems(
kept,
pageInfo: info != null && total != null && removed > 0
? info.copyWith(total: math.max(0, total - removed))
: info,
);
_afterItemsChanged();
}
}
/// [되돌리기] — 옮긴 메일을 원래 메일함별로 다시 옮긴 뒤 개수와 목록을 서버 기준으로 맞춘다.
///
/// 알림이 목록보다 오래 남을 수 있어(메일함을 바꿈) 목록 없이 돈다. 만든 세션에서만 움직인다 —
/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
Future<void> _restoreMoved(
List<BulkOrigin> origins, {
required MailRepository repository,
required MailboxesNotifier mailboxes,
required MailStateWatcher watcher,
required MailToastNotifier toast,
required TokenStore tokens,
required int session,
}) async {
if (tokens.sessionGeneration != session) return;
final List<String> done = <String>[];
final List<String> skipped = <String>[];
for (final BulkOrigin group in origins) {
final BulkResult result = await sendInChunks(group.ids, kMailBulkMaxIds, (
List<String> chunk,
) async {
final MessagesPatched patched = await repository.updateMessages(
chunk,
MessageChanges(mailboxId: group.mailboxId),
);
return (done: patched.updatedIds, skipped: patched.failedIds);
});
done.addAll(result.doneIds);
skipped.addAll(result.skippedIds);
if (result.unsentIds.isNotEmpty) break;
if (tokens.sessionGeneration != session) return;
}
if (tokens.sessionGeneration != session) return;
// 모든 목록 · 메일함 개수가 서버 기준으로 맞춘다
if (done.isNotEmpty) {
watcher.markChanged();
} else {
mailboxes.syncCounts();
}
if (done.isNotEmpty || skipped.isNotEmpty) {
toast.notify(MailBulkTexts.restored(done.length, skipped: skipped.length));
}
}
@@ -0,0 +1,140 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:flutter/foundation.dart';
/// 목록의 상태
enum MailListStatus {
/// 첫 쪽을 받는 중
loading,
/// 받았다(비었을 수도 있다)
ready,
/// 첫 쪽을 받지 못했다 — 받지 못함 + [다시 시도]
failed,
}
/// 메일함 하나의 목록 · 고른 메일
@immutable
class MailListState {
const MailListState({
this.status = MailListStatus.loading,
this.mailboxId,
this.items = const <MessageSummary>[],
this.pageInfo,
this.error,
this.refreshing = false,
this.loadingMore = false,
this.loadMoreFailed = false,
this.selectedIds = const <String>{},
this.busy = false,
});
final MailListStatus status;
/// 보는 메일함 — 받은편지함 주소(`/mail`)는 메일함 목록을 받기 전까지 null
final String? mailboxId;
final List<MessageSummary> items;
final MailPageInfo? pageInfo;
/// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID)
final ApiException? error;
/// 당겨서 새로고침하는 중
final bool refreshing;
/// 다음 쪽을 받는 중
final bool loadingMore;
/// 다음 쪽을 받지 못했다 — 끝에 [다시 시도]
final bool loadMoreFailed;
/// 고른 메일 id — 아직 보내지 않은 메일(붙잡힌 발송)은 들지 않는다
final Set<String> selectedIds;
/// 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다(읽음 · 중요 표시는 막지 않는다)
final bool busy;
/// 다음 쪽이 있다
bool get hasMore => pageInfo?.hasMore ?? false;
/// 여러 통 고르기 중
bool get selecting => selectedIds.isNotEmpty;
/// 고른 메일 — 목록 차례 그대로
List<MessageSummary> get selectedMessages => <MessageSummary>[
for (final MessageSummary message in items)
if (selectedIds.contains(message.id) && !message.isHeld) message,
];
int get selectedCount => selectedMessages.length;
/// 고른 메일이 모두 읽음 — [읽음 표시]가 [안 읽음 표시]가 된다
bool get allSelectedRead {
final List<MessageSummary> selected = selectedMessages;
return selected.isNotEmpty &&
selected.every((MessageSummary message) => message.isRead);
}
/// 고른 메일이 모두 중요 — [중요 표시]가 [중요 해제]가 된다
bool get allSelectedFlagged {
final List<MessageSummary> selected = selectedMessages;
return selected.isNotEmpty &&
selected.every((MessageSummary message) => message.isFlagged);
}
MailListState copyWith({
MailListStatus? status,
String? mailboxId,
bool? refreshing,
bool? loadingMore,
bool? loadMoreFailed,
Set<String>? selectedIds,
bool? busy,
}) => MailListState(
status: status ?? this.status,
mailboxId: mailboxId ?? this.mailboxId,
items: items,
pageInfo: pageInfo,
error: error,
refreshing: refreshing ?? this.refreshing,
loadingMore: loadingMore ?? this.loadingMore,
loadMoreFailed: loadMoreFailed ?? this.loadMoreFailed,
selectedIds: selectedIds ?? this.selectedIds,
busy: busy ?? this.busy,
);
/// 받은 목록으로 바꾼다 — 목록에서 빠졌거나 고를 수 없게 된 메일은 고른 것에서도 뺀다
MailListState withItems(
List<MessageSummary> items, {
MailPageInfo? pageInfo,
MailListStatus status = MailListStatus.ready,
}) {
final Set<String> selectable = <String>{
for (final MessageSummary message in items)
if (!message.isHeld) message.id,
};
final Set<String> kept = selectedIds.where(selectable.contains).toSet();
return MailListState(
status: status,
mailboxId: mailboxId,
items: List<MessageSummary>.unmodifiable(items),
pageInfo: pageInfo ?? this.pageInfo,
refreshing: refreshing,
loadingMore: loadingMore,
loadMoreFailed: loadMoreFailed,
selectedIds: kept.length == selectedIds.length
? selectedIds
: Set<String>.unmodifiable(kept),
busy: busy,
);
}
/// 첫 쪽을 받지 못했다
MailListState failed(ApiException error) => MailListState(
status: MailListStatus.failed,
mailboxId: mailboxId,
error: error,
);
}
@@ -0,0 +1,22 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다
final Provider<MailRepository> mailRepositoryProvider =
Provider<MailRepository>(
(Ref ref) => MailRepository(ref.watch(dioProvider)),
);
/// 메일을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null.
///
/// 메일 모듈의 상태(메일함 · 목록 · 알림 · 새 메일 확인)는 이것을 듣고 바뀌면 처음부터 다시 만든다 —
/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 직원의 메일이 남지 않게. 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다.
final Provider<String?> mailAccountProvider = Provider<String?>((Ref ref) {
final SessionState? session = ref.watch(sessionProvider).value;
return session is SignedIn && session.info.restriction == null
? session.info.email
: null;
});
@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 새 메일 확인 간격 — 화면이 보이는 동안 이만큼마다 바뀐 것이 있는지 묻는다(웹 `MAIL_STATE_POLL_INTERVAL_MS` 와 같다)
const Duration kMailStatePollInterval = Duration(seconds: 60);
/// 앱이 다시 앞으로 오면 마지막으로 물은 지 이만큼 지났을 때 바로 한 번 묻는다(웹 `MAIL_STATE_RECHECK_GAP_MS`)
const Duration kMailStateRecheckGap = Duration(seconds: 10);
/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다
@immutable
class MailChange {
const MailChange(this.seq, {this.source});
/// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0
final int seq;
/// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null
final Object? source;
@override
bool operator ==(Object other) =>
other is MailChange &&
other.seq == seq &&
identical(other.source, source);
@override
int get hashCode => Object.hash(seq, identityHashCode(source));
}
/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다.
final NotifierProvider<MailStateWatcher, MailChange> mailStateWatcherProvider =
NotifierProvider.autoDispose<MailStateWatcher, MailChange>(
MailStateWatcher.new,
);
/// 새 메일 확인 (계약 2절 "새 메일 확인" · 웹 3단계 "새 메일" — `useMailStatePolling` 과 같은 결)
///
/// - 메일 목록 화면이 보이고([setViewing]) 앱이 앞에 있는 동안만 1분마다 `GET /api/mail-state` 를 묻는다. 한 번에 하나씩.
/// - 다시 보이게 되면(앱이 앞으로 옴 · 다른 탭이나 전체 화면에서 돌아옴) 마지막으로 물은 지 10초가 지났으면 바로 한 번, 아니면 원래 차례에.
/// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다.
/// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다.
/// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다).
/// - 푸시(새 메일 알림)는 다음 차례다.
class MailStateWatcher extends Notifier<MailChange> {
final Set<Object> _viewers = <Object>{};
Timer? _timer;
bool _foreground = true;
bool _signedIn = false;
bool _asking = false;
DateTime? _lastAskedAt;
String? _lastState;
Clock get _clock => ref.read(clockProvider);
@override
MailChange build() {
_signedIn = ref.watch(mailAccountProvider) != null;
// 세션이 바뀌면 기준 · 물은 때를 버린다(화면의 등록은 그대로 — 화면은 남아 있다)
_timer?.cancel();
_timer = null;
_asking = false;
_lastAskedAt = null;
_lastState = null;
_foreground = ref.read(appForegroundProvider);
ref.listen<bool>(appForegroundProvider, (bool? previous, bool next) {
final bool before = _active;
_foreground = next;
if (before != _active) _onActiveChanged();
});
ref.onDispose(() {
_timer?.cancel();
_timer = null;
});
// 다시 만들어진 뒤에도 물을 때이면(보이는 목록이 그대로 있다) 새 차례를 잡는다 — build 안에서는 묻지 않는다
final Ref built = ref;
scheduleMicrotask(() {
if (built.mounted && _active && _timer == null && !_asking) {
_onActiveChanged();
}
});
return const MailChange(0);
}
/// 지금 물을 때인가 — 로그인했고, 보이는 목록이 있고, 앱이 앞에 있다
bool get _active => _signedIn && _foreground && _viewers.isNotEmpty;
/// 목록 하나가 보이게 · 안 보이게 됐다 — [viewer] 는 그 목록
void setViewing(Object viewer, {required bool viewing}) {
if (!ref.mounted) return;
final bool before = _active;
if (viewing) {
_viewers.add(viewer);
} else {
_viewers.remove(viewer);
}
if (before != _active) _onActiveChanged();
}
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록)
void markChanged({Object? source}) {
if (!ref.mounted) return;
state = MailChange(state.seq + 1, source: source);
}
void _onActiveChanged() {
if (!_active) {
_timer?.cancel();
_timer = null;
return;
}
// 묻는 중이면 그 물음이 끝난 뒤 다음 차례를 잡는다
if (_asking) return;
final DateTime? last = _lastAskedAt;
final Duration? elapsed = last == null ? null : _clock().difference(last);
if (elapsed == null ||
elapsed.isNegative ||
elapsed >= kMailStateRecheckGap) {
unawaited(_ask());
} else {
_schedule(kMailStatePollInterval - elapsed);
}
}
void _schedule(Duration delay) {
_timer?.cancel();
_timer = null;
if (!_active) return;
_timer = Timer(delay.isNegative ? Duration.zero : delay, () {
_timer = null;
unawaited(_ask());
});
}
Future<void> _ask() async {
if (_asking || !_active) return;
final Ref ref = this.ref;
_asking = true;
_timer?.cancel();
_timer = null;
_lastAskedAt = _clock();
try {
final String value = await ref.read(mailRepositoryProvider).mailState();
if (!ref.mounted) return;
final String? previous = _lastState;
_lastState = value;
if (previous != null && previous != value) {
state = MailChange(state.seq + 1);
}
} on DioException {
// 알리지 않는다 — 다음 차례에 다시 묻는다
} finally {
// 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다
if (ref.mounted) {
_asking = false;
_schedule(kMailStatePollInterval);
}
}
}
}
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/accessible_navigation.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 단추 없는 알림이 닫히기까지 — 웹 `TOAST_DURATION_MS.success` 와 같다
const Duration kMailToastDuration = Duration(seconds: 5);
/// 단추가 있는 알림([되돌리기])이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히(웹 `TOAST_ACTION_DURATION_MS`)
const Duration kMailToastActionDuration = Duration(seconds: 10);
/// 메일 화면 아래의 알림 줄 — 단추가 있는 알림만 둔다(보드 `App1MailList` 의 "메일 2통을 휴지통으로 옮겼습니다. [되돌리기]")
@immutable
class MailToast {
const MailToast({
required this.id,
required this.text,
required this.actionLabel,
});
/// 알림마다 다르다 — 단추 · 닫기가 그 알림에만 듣게
final int id;
final String text;
final String actionLabel;
}
/// 단추 없는 알림을 띄우는 곳 — 앱의 전역 알림 자리(`ScaffoldMessenger` 스낵바, 테마의 알림 모양).
///
/// 탭 안 화면의 Scaffold 가 받으므로 [메일 쓰기] · 단추 있는 알림 줄 위에 뜬다 — 둘이 겹치지 않고, [되돌리기]를 바꿔 끼우지 않는다.
/// 시험은 덮어써 적어 둔다.
final Provider<void Function(String text)> mailNoticeSinkProvider =
Provider<void Function(String text)>((Ref ref) {
final GlobalKey<ScaffoldMessengerState> key = ref.watch(
scaffoldMessengerKeyProvider,
);
return (String text) {
final ScaffoldMessengerState? messenger = key.currentState;
if (messenger == null) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(text), duration: kMailToastDuration),
);
};
});
/// 메일 화면의 알림 — 메일 화면이 있는 동안만 둔다. 세션이 바뀌면 닫는다(되돌리기는 만든 세션에서만 움직인다).
final NotifierProvider<MailToastNotifier, MailToast?> mailToastProvider =
NotifierProvider.autoDispose<MailToastNotifier, MailToast?>(
MailToastNotifier.new,
);
/// 메일 화면의 알림.
///
/// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다.
/// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결).
/// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 —
/// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함).
/// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다.
/// - 단추는 한 번만 돈다(두 번 눌러도 — 웹 `takeAction`).
class MailToastNotifier extends Notifier<MailToast?> {
Timer? _timer;
Future<void> Function()? _action;
int _seq = 0;
/// 화면 낭독기를 쓰는 중 — 단추 알림을 저절로 닫지 않는다
bool _holding = false;
@override
MailToast? build() {
ref.watch(mailAccountProvider);
_cancelTimer();
_action = null;
_holding = ref.read(accessibleNavigationProvider);
ref.listen<bool>(accessibleNavigationProvider, (bool? previous, bool next) {
_holding = next;
_onHoldChanged();
});
ref.onDispose(_cancelTimer);
return null;
}
/// 단추 없는 알림 — 전역 알림 자리에 띄운다
void notify(String text) {
if (!ref.mounted) return;
ref.read(mailNoticeSinkProvider)(text);
}
/// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다
void offer(
String text, {
required String actionLabel,
required Future<void> Function() onAction,
}) {
if (!ref.mounted) return;
final int id = ++_seq;
_cancelTimer();
_action = onAction;
state = MailToast(id: id, text: text, actionLabel: actionLabel);
if (!_holding) _startTimer(id);
}
/// 닫는다 — [id] 의 알림이 아직 보일 때만
void dismiss(int id) {
if (!ref.mounted || state?.id != id) return;
_cancelTimer();
_action = null;
state = null;
}
/// 단추 — 알림을 닫고 한 번만 한다
void runAction(int id) {
if (!ref.mounted || state?.id != id) return;
final Future<void> Function()? action = _action;
_action = null;
dismiss(id);
if (action != null) unawaited(action());
}
void _onHoldChanged() {
final MailToast? toast = state;
if (toast == null) return;
if (_holding) {
_cancelTimer();
} else if (_timer == null) {
_startTimer(toast.id);
}
}
void _startTimer(int id) {
_cancelTimer();
_timer = Timer(kMailToastActionDuration, () {
_timer = null;
dismiss(id);
});
}
void _cancelTimer() {
_timer?.cancel();
_timer = null;
}
}
@@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 메일함 목록의 상태
enum MailboxesStatus { loading, ready, failed }
/// 메일함 목록 — 서랍 · 목록 제목 · 안 읽은 수 · 옮길 곳이 함께 쓴다
@immutable
class MailboxesState {
const MailboxesState({
this.status = MailboxesStatus.loading,
this.items = const <Mailbox>[],
this.error,
});
final MailboxesStatus status;
final List<Mailbox> items;
/// 처음 받지 못했을 때의 실패 — 이미 받은 목록이 있으면 다시 받기 실패는 여기 두지 않는다
final ApiException? error;
/// 처음부터 받지 못했다 — 받은편지함 목록도 열 수 없다
ApiException? get failure => status == MailboxesStatus.failed ? error : null;
/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 첫 메일함. 받기 전이면 null
String? get defaultMailboxId =>
status == MailboxesStatus.ready ? defaultMailboxOf(items)?.id : null;
/// 서랍 차례
List<MailboxNode> get ordered => orderMailboxes(items);
Mailbox? byId(String? id) {
if (id == null) return null;
for (final Mailbox mailbox in items) {
if (mailbox.id == id) return mailbox;
}
return null;
}
Mailbox? byRole(MailboxRole role) => findMailboxByRole(items, role);
}
/// 메일함 목록 — 메일 화면이 있는 동안만 둔다(autoDispose). 세션이 바뀌면 처음부터.
final NotifierProvider<MailboxesNotifier, MailboxesState> mailboxesProvider =
NotifierProvider.autoDispose<MailboxesNotifier, MailboxesState>(
MailboxesNotifier.new,
);
/// 메일함 목록 — `GET /api/mailboxes` (계약 2절)
///
/// - 처음 받지 못하면 목록 화면이 받지 못함 + [다시 시도]를 그린다. 이미 받은 뒤의 다시 받기는 실패해도 조용히 둔다(웹과 같다).
/// - 새 메일 확인이 바뀜을 알리면 개수를 다시 맞춘다. 읽음 표시는 개수를 먼저 바꾸고 다음 조회가 서버 기준으로 맞춘다.
/// - 늦게 온 응답은 버린다(나중에 보낸 요청의 응답만 쓴다).
class MailboxesNotifier extends Notifier<MailboxesState> {
int _seq = 0;
@override
MailboxesState build() {
final String? account = ref.watch(mailAccountProvider);
_seq++;
// 새 메일 확인 · 다른 목록의 옮기기가 바뀜을 알리면 개수를 맞춘다 (처음부터 다시 만들 때의 0 은 바뀜이 아니다)
ref.listen<MailChange>(mailStateWatcherProvider, (
MailChange? previous,
MailChange next,
) {
if (next.seq > (previous?.seq ?? 0)) syncCounts();
});
if (account == null) return const MailboxesState();
unawaited(_load(_seq));
return const MailboxesState();
}
/// 다시 받는다 — 처음 받지 못했던 목록은 불러오는 중으로 바꾼다([다시 시도])
Future<void> reload() {
if (!ref.mounted) return Future<void>.value();
_seq++;
if (state.status != MailboxesStatus.ready) {
state = const MailboxesState();
}
return _load(_seq);
}
/// 보이는 화면은 그대로 둔 채 개수를 서버 기준으로 다시 맞춘다 — 옮기기 · 삭제 · 새로고침 · 새 메일 확인 뒤
void syncCounts() {
if (!ref.mounted) return;
_seq++;
unawaited(_load(_seq));
}
/// 안 읽은 개수를 화면에서 먼저 바꾼다 — 서버 기준은 다음 조회가 맞춘다
void adjustUnread(List<String> mailboxIds, int delta) {
if (!ref.mounted || delta == 0) return;
state = MailboxesState(
status: state.status,
error: state.error,
items: <Mailbox>[
for (final Mailbox mailbox in state.items)
mailboxIds.contains(mailbox.id)
? mailbox.copyWith(
unreadCount: (mailbox.unreadCount + delta)
.clamp(0, math.max(0, mailbox.totalCount))
.toInt(),
)
: mailbox,
],
);
}
Future<void> _load(int seq) async {
final Ref ref = this.ref;
try {
final List<Mailbox> items = await ref
.read(mailRepositoryProvider)
.listMailboxes();
if (!ref.mounted || seq != _seq) return;
state = MailboxesState(
status: MailboxesStatus.ready,
items: List<Mailbox>.unmodifiable(items),
);
} on DioException catch (error) {
if (!ref.mounted || seq != _seq) return;
// 이미 목록이 보이면 조용히 둔다
if (state.status == MailboxesStatus.ready) return;
state = MailboxesState(
status: MailboxesStatus.failed,
error: toApiException(error),
);
}
}
}
@@ -0,0 +1,195 @@
import 'dart:math' as math;
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// [메일 쓰기] 단추의 그림자 높이 — 보드의 `0 6px 16px` 그림자
const double _composeElevation = 6;
/// 알림 줄의 그림자 높이 — 보드의 `0 10px 28px` 그림자
const double _toastElevation = 8;
/// 화면 아래의 [메일 쓰기] · 알림 줄 자리 — Scaffold 의 넓은 단추 자리(`floatingActionButton`)에 둔다.
///
/// - 보드 `App1MailList`: [메일 쓰기]는 탭 막대 위 16(아래에서 88), 알림이 뜨면 알림(아래 12 · 좌우 12) 위로 12만큼 함께 올라간다.
/// - 넓은 단추 자리에 두는 까닭: Scaffold 는 전역 알림(API 실패 · 단추 없는 알림 스낵바)을 이 자리 **위에** 띄운다 — 단추 · 알림 줄을 가리지 않는다.
/// - 고르는 중에는 [메일 쓰기]를 숨긴다(보드 — 탭 막대도 숨는다). 알림 줄은 남는다.
/// - 탭 막대가 숨으면(고르는 중 · 자판) 화면 아래가 시스템 내비게이션 자리까지 내려온다 — 안전 영역(아래 · 좌우, [safeInsets])만큼 띄운다.
/// Scaffold 는 이 자리에 안전 영역을 넘기지 않으므로(네 방향 모두 지운다) Scaffold 밖에서 읽어 넘겨받는다.
/// 탭 막대가 있을 때는 틀이 아래 여백을 이미 뺐다(0).
class MailComposeArea extends ConsumerWidget {
const MailComposeArea({
required this.showCompose,
required this.safeInsets,
required this.onCompose,
super.key,
});
final bool showCompose;
/// 화면의 안전 영역(아래 · 좌우) — Scaffold 를 감싼 화면의 `MediaQuery.paddingOf`
final EdgeInsets safeInsets;
final VoidCallback onCompose;
@override
Widget build(BuildContext context, WidgetRef ref) {
final MailToast? toast = ref.watch(mailToastProvider);
if (!showCompose && toast == null) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.fromLTRB(
safeInsets.left,
0,
safeInsets.right,
safeInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
if (showCompose)
Padding(
padding: EdgeInsets.only(
right: AppSpacing.s16,
bottom: toast == null ? AppSpacing.s16 : AppSpacing.s12,
),
child: _ComposeButton(onPressed: onCompose),
),
if (toast != null)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
0,
AppSpacing.s12,
AppSpacing.s12,
),
child: MailToastCard(
toast: toast,
onAction: () =>
ref.read(mailToastProvider.notifier).runAction(toast.id),
),
),
),
],
),
);
}
}
class _ComposeButton extends StatelessWidget {
const _ComposeButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
return FloatingActionButton.extended(
// 같은 화면에 목록이 둘(받은편지함 위에 다른 메일함) 쌓일 수 있다 — 화면 사이를 나는 그림(Hero)은 쓰지 않는다
heroTag: null,
onPressed: onPressed,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: _composeElevation,
highlightElevation: _composeElevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xxl),
),
extendedPadding: const EdgeInsetsDirectional.only(
start: AppSpacing.s16,
end: AppSpacing.s20,
),
extendedIconLabelSpacing: AppSpacing.s10,
extendedTextStyle: theme.textTheme.labelLarge,
icon: const Icon(Icons.edit_outlined, size: AppSize.iconToolbar),
label: const Text(MailListTexts.compose),
);
}
}
/// 알림 줄 — 짙은 청록 바탕 · 흰 글 · 오른쪽 [되돌리기] (보드 `App1MailList` · 앱 테마의 알림 색과 같다)
class MailToastCard extends StatelessWidget {
const MailToastCard({required this.toast, required this.onAction, super.key});
final MailToast toast;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
liveRegion: true,
child: Material(
color: scheme.inverseSurface,
elevation: _toastElevation,
shadowColor: scheme.shadow,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppSize.toastMinHeight),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s2,
AppSpacing.s4,
AppSpacing.s2,
),
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.s10,
),
child: Text(
toast.text,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onPrimary,
),
),
),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: extra.onBrandLink,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
),
onPressed: onAction,
child: Text(toast.actionLabel),
),
],
),
),
),
),
);
}
}
/// [메일 쓰기] · 알림 줄 자리를 화면 오른쪽 아래에 붙인다 — 여백(안전 영역 포함)은 자리 안([MailComposeArea])이 정한다.
/// (Flutter 의 기본 자리는 단추 하나를 위한 여백 · 안전 영역 셈을 하므로 쓰지 않는다)
///
/// 알림 줄이 있으면 자리가 화면 폭을 다 쓰므로 왼쪽 끝에 붙는다. Scaffold 는 전역 알림을 이 자리 위에 띄운다.
class MailComposeAreaLocation extends FloatingActionButtonLocation {
const MailComposeAreaLocation();
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
final Size area = scaffoldGeometry.floatingActionButtonSize;
final double x = math.max(
0,
scaffoldGeometry.scaffoldSize.width - area.width,
);
return Offset(x, scaffoldGeometry.contentBottom - area.height);
}
}
@@ -0,0 +1,213 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter/material.dart';
/// 목록의 위 막대 — 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기 (보드 `App1MailList` 「받은편지함」, 높이 64)
class MailListAppBar extends StatelessWidget implements PreferredSizeWidget {
const MailListAppBar({
required this.title,
required this.onOpenDrawer,
required this.onSearch,
this.subtitle,
super.key,
});
final String title;
/// 제목 아래 작은 글 — "안 읽은 메일 3통". 보낸편지함 · 임시보관함 · 휴지통은 없다
final String? subtitle;
final VoidCallback onOpenDrawer;
final VoidCallback onSearch;
@override
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? sub = subtitle;
return AppBar(
toolbarHeight: AppSize.appBarHeight,
leadingWidth: AppSize.touchMin + AppSpacing.s8,
titleSpacing: AppSpacing.s4,
shape: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
leading: IconButton(
icon: const Icon(Icons.menu, size: AppSize.iconLarge),
tooltip: MailListTexts.openDrawer,
color: extra.textStrong,
onPressed: onOpenDrawer,
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Semantics(
header: true,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
height: AppTypography.lineHeightTight,
),
),
),
if (sub != null)
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
],
),
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.search, size: AppSize.iconLarge),
tooltip: MailListTexts.search,
color: extra.textStrong,
onPressed: onSearch,
),
],
);
}
}
/// 여러 통 고르기 막대 — 선택 해제 · "N통 선택됨" · 읽음 표시 · 중요 표시 · 이동 · 삭제
/// (보드 `App1MailList` 「길게 눌러 고르기」 — 바탕 옅은 청록, 화면 낭독기 이름 "고른 메일")
class MailSelectionBar extends StatelessWidget implements PreferredSizeWidget {
const MailSelectionBar({
required this.count,
required this.allRead,
required this.allFlagged,
required this.inTrash,
required this.busy,
required this.onClear,
required this.onToggleRead,
required this.onToggleFlag,
required this.onMove,
required this.onDelete,
super.key,
});
final int count;
/// 고른 것이 모두 읽음 — [안 읽음 표시]
final bool allRead;
/// 고른 것이 모두 중요 — [중요 해제]
final bool allFlagged;
/// 휴지통 — [삭제]가 빨간 [영구 삭제]
final bool inTrash;
/// 옮기기 · 지우기를 하는 중 — 이동 · 삭제를 다시 누르지 못한다
final bool busy;
final VoidCallback onClear;
final VoidCallback onToggleRead;
final VoidCallback onToggleFlag;
final VoidCallback onMove;
final VoidCallback onDelete;
@override
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final Color toolColor = scheme.onPrimaryContainer;
return Semantics(
container: true,
label: MailBulkTexts.toolbar,
explicitChildNodes: true,
child: AppBar(
toolbarHeight: AppSize.appBarHeight,
backgroundColor: scheme.secondaryContainer,
leadingWidth: AppSize.touchMin + AppSpacing.s8,
titleSpacing: AppSpacing.s4,
shape: Border(
bottom: BorderSide(
color: extra.brandTintBorder,
width: AppSize.borderWidth,
),
),
leading: IconButton(
icon: const Icon(Icons.close, size: AppSize.iconLarge),
tooltip: MailBulkTexts.clear,
color: toolColor,
onPressed: onClear,
),
title: Semantics(
liveRegion: true,
child: Text(
MailBulkTexts.selected(count),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
color: toolColor,
),
),
),
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
actions: <Widget>[
IconButton(
icon: Icon(
allRead
? Icons.mark_email_unread_outlined
: Icons.drafts_outlined,
size: AppSize.iconToolbar,
),
tooltip: allRead
? MailBulkTexts.markUnread
: MailBulkTexts.markRead,
color: toolColor,
onPressed: onToggleRead,
),
IconButton(
icon: Icon(
allFlagged ? Icons.star : Icons.star_border,
size: AppSize.iconToolbar,
),
tooltip: allFlagged ? MailBulkTexts.unflag : MailBulkTexts.flag,
color: toolColor,
onPressed: onToggleFlag,
),
IconButton(
icon: const Icon(
Icons.drive_file_move_outlined,
size: AppSize.iconToolbar,
),
tooltip: MailBulkTexts.move,
color: toolColor,
onPressed: busy ? null : onMove,
),
IconButton(
icon: Icon(
inTrash ? Icons.delete_forever_outlined : Icons.delete_outline,
size: AppSize.iconToolbar,
),
tooltip: inTrash ? MailBulkTexts.purge : MailBulkTexts.delete,
color: inTrash ? scheme.error : toolColor,
onPressed: busy ? null : onDelete,
),
],
),
);
}
}
@@ -0,0 +1,366 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
const int kTraceCodeLength = 8;
/// 처음 불러오는 동안의 뼈대 줄 수 — 휴대폰 한 화면쯤
const int _skeletonRows = 8;
/// 빈 메일함 · 받지 못함의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
const double _stateBottomSpace = 120;
/// 뼈대 줄의 막대 폭 — 보낸 사람 · 제목(미리 보기 막대는 줄 폭을 다 쓴다)
const double _skeletonWhoWidth = 0.4;
const double _skeletonSubjectWidth = 0.8;
/// 끝에서 도는 표시의 선 두께 — 작은 원(24)에 맞춘다
const double _footerSpinnerStroke = 2.5;
/// 빈 메일함 — 아이콘 원 · "휴지통이 비어 있습니다" · 메일함마다 안내(휴지통 · 스팸메일함은 30일 안내가 붙는다)
/// (보드 `App1MailList` 「빈 메일함 (휴지통)」)
class MailListEmpty extends StatelessWidget {
const MailListEmpty({
required this.mailboxName,
required this.role,
super.key,
});
final String mailboxName;
final MailboxRole? role;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return _StateLayout(
icon: mailboxIconOf(role),
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: MailListTexts.emptyTitle(mailboxName),
children: <Widget>[
KeepAllText(
MailListTexts.emptyHint(role),
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
height: AppTypography.lineHeightRelaxed,
),
),
],
);
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다)
class MailListError extends StatelessWidget {
const MailListError({
required this.error,
required this.onRetry,
this.onLeave,
super.key,
});
final ApiException error;
final VoidCallback onRetry;
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? leave = onLeave;
final bool notFound = error.code == 'RES_001' && leave != null;
final String? trace = traceCodeOf(error.traceId);
return _StateLayout(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
iconBackground: theme.colorScheme.errorContainer,
title: error.message,
children: <Widget>[
if (trace != null)
Text(
MailListTexts.traceCode(trace),
style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted),
),
TextButton(
onPressed: notFound ? leave : onRetry,
child: Text(notFound ? MailListTexts.toInbox : MailListTexts.retry),
),
],
);
}
}
/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null
String? traceCodeOf(String? traceId) {
final String? value = traceId?.trim();
if (value == null || value.isEmpty) return null;
return value.length > kTraceCodeLength
? value.substring(0, kTraceCodeLength)
: value;
}
class _StateLayout extends StatelessWidget {
const _StateLayout({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.children,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s40,
0,
AppSpacing.s40,
_stateBottomSpace,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.stateIcon,
height: AppSize.stateIcon,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackground,
),
child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor),
),
),
const SizedBox(height: AppSpacing.s16),
// 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다
DefaultTextStyle.merge(
textAlign: TextAlign.center,
child: Semantics(
header: true,
child: KeepAllText(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
const SizedBox(height: AppSpacing.s8),
for (final Widget child in children)
DefaultTextStyle.merge(textAlign: TextAlign.center, child: child),
],
),
);
}
}
/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중")
class MailListSkeleton extends StatelessWidget {
const MailListSkeleton({super.key});
@override
Widget build(BuildContext context) {
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
return Semantics(
label: MailListTexts.loading,
liveRegion: true,
child: ExcludeSemantics(
child: Column(
children: <Widget>[
for (int index = 0; index < _skeletonRows; index++)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s14,
AppSpacing.s16,
AppSpacing.s14,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: AppSize.avatar,
height: AppSize.avatar,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bar,
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FractionallySizedBox(
widthFactor: _skeletonWhoWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
FractionallySizedBox(
widthFactor: _skeletonSubjectWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
_SkeletonBar(color: bar),
],
),
),
],
),
),
],
),
),
);
}
}
class _SkeletonBar extends StatelessWidget {
const _SkeletonBar({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
height: AppSpacing.s10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.xs),
),
);
}
}
/// 휴지통 · 스팸메일함 목록 맨 위의 30일 줄 — 회색 바탕 · 시계 아이콘(웹 13단계 — 늘 보인다, 닫기 없음)
class MailRetentionNotice extends StatelessWidget {
const MailRetentionNotice(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s16,
vertical: AppSpacing.s8,
),
child: Row(
children: <Widget>[
Icon(
Icons.schedule,
size: AppSize.iconTiny,
color: extra.textMuted,
),
const SizedBox(width: AppSpacing.s8),
Expanded(
child: KeepAllText(
text,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
}
/// 목록 끝 — 다음 쪽을 받는 동안 도는 표시, 받지 못했으면 [다시 시도].
/// 화면에 들어오면(목록이 끝 근처를 그리면) 그린 뒤 [onReached] 를 부르고, 끝 표시가 남아 있는 동안 다시 그릴 때마다 또 부른다
/// (받은 쪽이 모두 이미 있던 메일이라 줄이 늘지 않음 · 새로고침이 앞 요청을 버림). 받는 중 · 더 없음은 부르는 쪽이 거른다.
/// 받지 못한 동안은 부르지 않는다(저절로 다시 하지 않는다 — 같은 커서가 되풀이돼도 [다시 시도]로 멈춘다)
class MailListFooter extends StatefulWidget {
const MailListFooter({
required this.loading,
required this.failed,
required this.onReached,
required this.onRetry,
super.key,
});
final bool loading;
final bool failed;
final VoidCallback onReached;
final VoidCallback onRetry;
@override
State<MailListFooter> createState() => _MailListFooterState();
}
class _MailListFooterState extends State<MailListFooter> {
@override
void initState() {
super.initState();
_reachAfterFrame();
}
@override
void didUpdateWidget(MailListFooter oldWidget) {
super.didUpdateWidget(oldWidget);
// 받는 중이던 표시가 끝난 순간을 기다리지 않는다 — 응답이 한 프레임 안에 오면 그 순간을 보지 못한다
_reachAfterFrame();
}
void _reachAfterFrame() {
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
if (mounted && !widget.loading && !widget.failed) widget.onReached();
});
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: AppSize.touchMin + AppSpacing.s16,
child: Center(
child: widget.failed
? TextButton(
onPressed: widget.onRetry,
child: const Text(MailListTexts.retry),
)
: Semantics(
label: MailListTexts.loadingMore,
child: const SizedBox.square(
dimension: AppSize.iconLarge,
child: CircularProgressIndicator(
strokeWidth: _footerSpinnerStroke,
),
),
),
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
import 'package:flutter/material.dart';
/// 옮길 메일함을 고르는 시트 — 아래에서 올라온다(보드에 없다 — 서랍과 같은 줄 모양).
///
/// [targets] 는 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함이다(`moveTargetsOf` — 웹과 같다). 고르면 그 메일함, 닫으면 null.
Future<Mailbox?> showMailboxPickerSheet(
BuildContext context, {
required List<MailboxNode> targets,
}) {
final ThemeData theme = Theme.of(context);
return showModalBottomSheet<Mailbox>(
context: context,
useRootNavigator: true,
showDragHandle: true,
backgroundColor: theme.colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
builder: (BuildContext sheetContext) => _MailboxPickerSheet(targets),
);
}
class _MailboxPickerSheet extends StatelessWidget {
const _MailboxPickerSheet(this.targets);
final List<MailboxNode> targets;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s28,
0,
AppSpacing.s28,
AppSpacing.s8,
),
child: Semantics(
header: true,
child: Text(
MailBulkTexts.moveSheetTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
),
),
),
),
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s4,
AppSpacing.s12,
AppSpacing.s16,
),
children: <Widget>[
for (final MailboxNode node in targets)
MailboxTile(
node: node,
selected: false,
onTap: () => Navigator.of(context).pop(node.mailbox),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,321 @@
import 'dart:math' as math;
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 서랍이 미끄러져 들어오는 시간
const Duration _drawerDuration = Duration(milliseconds: 250);
/// 하위 메일함 들여쓰기는 3단계까지 (그 아래는 같은 깊이 — 웹과 같다)
const int _maxIndentDepth = 3;
/// 서랍의 그림자 높이 — 보드의 `0 14px 36px` 그림자에 가깝게(Material 모달 서랍)
const double _drawerElevation = 16;
/// 메일함 서랍을 연다 — 탭 막대 위까지 화면 전체를 덮는다(앱 보드 `App1MailList` 「메일함 서랍」 · 앱 디자인 README).
///
/// 메일함을 고르면 그 메일함을 돌려준다(부르는 쪽이 그 목록으로 간다). 바깥을 누르거나 뒤로 가기면 null.
Future<Mailbox?> showMailboxDrawer(
BuildContext context, {
required String? currentMailboxId,
}) {
return showGeneralDialog<Mailbox>(
context: context,
barrierDismissible: true,
barrierLabel: MailDrawerTexts.close,
barrierColor: Theme.of(context).colorScheme.scrim,
transitionDuration: _drawerDuration,
pageBuilder:
(
BuildContext dialogContext,
Animation<double> animation,
Animation<double> secondaryAnimation,
) => Align(
alignment: AlignmentDirectional.centerStart,
child: MailboxDrawer(currentMailboxId: currentMailboxId),
),
transitionBuilder:
(
BuildContext dialogContext,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) => SlideTransition(
position: Tween<Offset>(begin: const Offset(-1, 0), end: Offset.zero)
.animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
);
}
/// 메일함 서랍 — 기본 메일함 · 내 메일함(하위는 들여 쓴다). 안 읽은 수는 회색 숫자(뱃지 없음)
class MailboxDrawer extends ConsumerWidget {
const MailboxDrawer({required this.currentMailboxId, super.key});
/// 지금 보는 메일함 — 청록 바탕
final String? currentMailboxId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final ThemeData theme = Theme.of(context);
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
final double width = math.min(
AppSize.drawerWidth,
MediaQuery.sizeOf(context).width - AppSize.drawerMinGap,
);
return Semantics(
container: true,
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
label: MailDrawerTexts.title,
child: Material(
color: theme.colorScheme.surface,
elevation: _drawerElevation,
shadowColor: theme.colorScheme.shadow,
clipBehavior: Clip.antiAlias,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(AppRadius.xxl),
),
child: SizedBox(
width: width,
height: double.infinity,
child: SafeArea(
right: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s28,
AppSpacing.s24,
AppSpacing.s28,
AppSpacing.s8,
),
child: Semantics(
header: true,
child: Text(
MailDrawerTexts.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
),
),
),
),
Expanded(
child: _DrawerBody(
mailboxes: mailboxes,
currentMailboxId: currentMailboxId,
onRetry: () =>
ref.read(mailboxesProvider.notifier).reload(),
),
),
],
),
),
),
),
);
}
}
class _DrawerBody extends StatelessWidget {
const _DrawerBody({
required this.mailboxes,
required this.currentMailboxId,
required this.onRetry,
});
final MailboxesState mailboxes;
final String? currentMailboxId;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final ApiException? failure = mailboxes.failure;
if (failure != null) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
KeepAllText(
failure.message,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
),
),
const SizedBox(height: AppSpacing.s8),
TextButton(
onPressed: onRetry,
child: const Text(MailListTexts.retry),
),
],
),
);
}
if (mailboxes.status != MailboxesStatus.ready) {
return const Center(child: CircularProgressIndicator());
}
final ({List<MailboxNode> system, List<MailboxNode> mine}) groups =
splitMailboxGroups(mailboxes.ordered);
return ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s4,
AppSpacing.s12,
AppSpacing.s12,
),
children: <Widget>[
for (final MailboxNode node in groups.system)
MailboxTile(
node: node,
selected: node.mailbox.id == currentMailboxId,
showUnread: true,
onTap: () => Navigator.of(context).pop(node.mailbox),
),
if (groups.mine.isNotEmpty) ...<Widget>[
Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.s8,
horizontal: AppSpacing.s4,
),
child: Divider(color: extra.borderSubtle),
),
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s4,
AppSpacing.s16,
AppSpacing.s2,
),
child: Semantics(
header: true,
child: Text(
MailDrawerTexts.myMailboxes,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textMuted,
),
),
),
),
for (final MailboxNode node in groups.mine)
MailboxTile(
node: node,
selected: node.mailbox.id == currentMailboxId,
showUnread: true,
onTap: () => Navigator.of(context).pop(node.mailbox),
),
],
],
);
}
}
/// 메일함 한 줄 — 알약 모양(높이 48 이상) · 아이콘 · 이름 · 안 읽은 수(회색 숫자). 서랍 · 옮길 곳 시트가 함께 쓴다
class MailboxTile extends StatelessWidget {
const MailboxTile({
required this.node,
required this.selected,
required this.onTap,
this.showUnread = false,
super.key,
});
final MailboxNode node;
/// 지금 보는 메일함 — 청록 바탕 · 굵게
final bool selected;
/// 안 읽은 수를 보인다 (서랍만)
final bool showUnread;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final Mailbox mailbox = node.mailbox;
final Color foreground = selected
? scheme.onPrimaryContainer
: extra.textStrong;
final int unread = mailbox.unreadCount;
final double indent =
math.min(node.depth, _maxIndentDepth) * AppSpacing.s16;
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.s2),
child: Semantics(
selected: selected,
child: Material(
color: selected ? scheme.primaryContainer : Colors.transparent,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppSize.touchMin),
child: Padding(
padding: EdgeInsetsDirectional.only(
start: AppSpacing.s16 + indent,
end: AppSpacing.s16,
),
child: Row(
children: <Widget>[
Icon(
mailboxIconOf(mailbox.role),
size: AppSize.iconMedium,
color: foreground,
),
const SizedBox(width: AppSpacing.s14),
Expanded(
child: Text(
mailbox.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: selected
? AppTypography.semiBold
: AppTypography.medium,
color: foreground,
),
),
),
if (showUnread && unread > 0)
Text(
formatCount(unread),
semanticsLabel: MailDrawerTexts.unreadCount(unread),
style: theme.textTheme.bodySmall?.copyWith(
color: selected ? foreground : extra.textMuted,
fontFeatures: const <FontFeature>[
FontFeature.tabularFigures(),
],
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,13 @@
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:flutter/material.dart';
/// 메일함 아이콘 — 웹 `MAILBOX_ICONS`(lucide)에 맞춘 Material 아이콘 (서랍 · 옮길 곳 · 빈 메일함)
IconData mailboxIconOf(MailboxRole? role) => switch (role) {
MailboxRole.inbox => Icons.inbox_outlined,
MailboxRole.sent => Icons.send_outlined,
MailboxRole.drafts => Icons.description_outlined,
MailboxRole.junk => Icons.report_outlined,
MailboxRole.trash => Icons.delete_outline,
MailboxRole.archive => Icons.archive_outlined,
null => Icons.folder_outlined,
};
@@ -0,0 +1,277 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter/material.dart';
/// 목록 줄 하나 — 보드 `App1MailList` 의 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 한 줄.
///
/// - 안 읽음은 굵은 글자로만 가른다(뱃지 없음 — 화면 낭독기는 앞에 "안 읽음").
/// - 사진을 누르면 고르기 · 풀기, 줄을 길게 누르면 고르기. 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다).
/// - 아직 보내지 않은 메일은 고를 수 없다 — 사진 단추는 꺼진 것으로 읽히고, 눌러도 까닭만 알린다(부르는 쪽이).
class MessageRow extends StatelessWidget {
const MessageRow({
required this.view,
required this.selected,
required this.onTap,
required this.onLongPress,
required this.onAvatarTap,
super.key,
});
final MailRowView view;
/// 고른 줄 — 바탕이 옅은 청록이고 사진 자리에 체크
final bool selected;
final VoidCallback onTap;
final VoidCallback onLongPress;
final VoidCallback onAvatarTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
selected: selected,
label: view.semanticsLabel,
child: Material(
color: selected ? extra.rowChecked : theme.colorScheme.surface,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHigh,
width: AppSize.borderWidth,
),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s8,
AppSpacing.s10,
AppSpacing.s16,
AppSpacing.s10,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MailAvatarButton(
initial: view.initial,
selected: selected,
held: view.held,
label: view.selectLabel,
onTap: onAvatarTap,
),
const SizedBox(width: AppSpacing.s6),
Expanded(child: ExcludeSemantics(child: _RowTexts(view))),
],
),
),
),
),
),
);
}
}
/// 동그란 사진 — 누르는 곳 48 안의 40 원(머리글자 · 고르면 청록 원에 체크).
/// 화면 낭독기는 "고르기: 제목" 체크 칸으로 읽는다. 아직 보내지 않은 메일이면 꺼진 칸이고 설명에 까닭을 단다
class MailAvatarButton extends StatelessWidget {
const MailAvatarButton({
required this.initial,
required this.selected,
required this.held,
required this.label,
required this.onTap,
super.key,
});
final String? initial;
final bool selected;
final bool held;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
button: true,
checked: selected,
enabled: !held,
label: label,
hint: held ? MailListTexts.heldNotSelectable : null,
onTap: onTap,
child: InkResponse(
onTap: onTap,
excludeFromSemantics: true,
radius: AppSize.touchMin / 2,
customBorder: const CircleBorder(),
child: SizedBox.square(
dimension: AppSize.touchMin,
child: Center(
child: ExcludeSemantics(
child: _AvatarFace(initial: initial, selected: selected),
),
),
),
),
);
}
}
class _AvatarFace extends StatelessWidget {
const _AvatarFace({required this.initial, required this.selected});
final String? initial;
final bool selected;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final String? letter = initial;
final Widget face;
if (selected) {
face = Icon(
Icons.check,
size: AppSize.iconMedium,
color: scheme.onPrimary,
);
} else if (letter != null) {
face = Text(
letter,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.semiBold,
color: scheme.onPrimaryContainer,
),
);
} else {
face = Icon(
Icons.person_outline,
size: AppSize.iconMedium,
color: scheme.onPrimaryContainer,
);
}
return Container(
width: AppSize.avatar,
height: AppSize.avatar,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? scheme.primary : scheme.primaryContainer,
),
child: face,
);
}
}
/// 줄의 글 세 줄 — 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기
class _RowTexts extends StatelessWidget {
const _RowTexts(this.view);
final MailRowView view;
static const List<FontFeature> _tabular = <FontFeature>[
FontFeature.tabularFigures(),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextTheme text = theme.textTheme;
final bool unread = view.unread;
final TextStyle? whoStyle = text.bodyMedium?.copyWith(
fontWeight: unread ? AppTypography.bold : AppTypography.medium,
color: view.whoPlaceholder ? extra.textMuted : scheme.onSurface,
);
final TextStyle? timeStyle = text.labelSmall?.copyWith(
fontWeight: unread ? AppTypography.semiBold : AppTypography.regular,
color: view.timePending
? scheme.primary
: (unread ? scheme.onSurface : extra.textMuted),
fontFeatures: _tabular,
);
final TextStyle? subjectStyle = text.bodyMedium?.copyWith(
fontWeight: unread ? AppTypography.semiBold : AppTypography.regular,
color: view.subjectPlaceholder
? extra.textMuted
: (unread ? scheme.onSurface : extra.textStrong),
);
final TextStyle? previewStyle = text.bodySmall?.copyWith(
color: extra.textMuted,
);
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.s2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Expanded(
child: Text(
view.who,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: whoStyle,
),
),
const SizedBox(width: AppSpacing.s8),
Text(view.time, maxLines: 1, style: timeStyle),
],
),
Row(
children: <Widget>[
Expanded(
child: Text(
view.subject,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: subjectStyle,
),
),
if (view.hasAttachment)
Padding(
padding: const EdgeInsets.only(left: AppSpacing.s6),
child: Icon(
Icons.attach_file,
size: AppSize.iconTiny,
color: extra.textMuted,
semanticLabel: MailListTexts.attachment,
),
),
if (view.flagged)
Padding(
padding: const EdgeInsets.only(left: AppSpacing.s6),
child: Icon(
Icons.star,
size: AppSize.iconTiny,
color: extra.star,
semanticLabel: MailListTexts.flagged,
),
),
],
),
Text(
view.preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: previewStyle,
),
],
),
);
}
}