feat(mine): 我的 Tab UI 重设计(#39~#41)

- ProfileHeroCard:72pt 渐变头像(8色 uid%8 主题)、@J{uid} handle、bio 简介、掩码手机号
- AppBar:compact,右侧 QR 图标 + 编辑铅笔
- 彩色图标行(_IconBox 36pt 圆角)+ 4 卡片组对齐 iOS SettingsView
  - 账户:我的钱包 / 账户安全
  - 工具:收藏 / 最近呼叫 / 链接设备 / 聊天文件夹
  - 偏好设置:通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题
  - 关于:用户协议 / 隐私政策 / 版本号
- SettingsState 增加 bio 字段(#41),loadProfile 同步赋值
- Doc/mine_tab_architecture.md 补充 UI 重设计章节(§7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 20:14:34 +09:00
parent 2eb2299709
commit db10d1fcd2
3 changed files with 327 additions and 85 deletions

View File

@@ -1,6 +1,6 @@
# 我的MineTab — 架构文档 # 我的MineTab — 架构文档
> 对应 Gitea issues #5#13 > 对应 Gitea issues #5#13#39#41UI 重设计)
> 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile > 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile
--- ---
@@ -152,7 +152,57 @@ settingsViewModelProvider
--- ---
## 7. 待完成事项 ## 7. UI 重设计(#39 / #40 / #41
### 7.1 ProfileHeroCard (#39 / #41)
| 元素 | 规格 |
|------|------|
| 头像 | 72pt 圆形;无头像时 8 色渐变占位uid%8 |
| 昵称 | fontWeight w700titleMedium |
| Handle | `@J{uid}`bodySmall onSurfaceVariant |
| 手机号 | 掩码 `+CC ***XXXX`bodySmall |
| Bio | 非空显示,为空显示「添加一句话简介」(斜体,半透明) |
| AppBar | compact右侧QR 图标 + 编辑铅笔 |
渐变色方案(`_ProfileHeroCard._gradients[uid.abs() % 8]`
```
0: [#4776E6, #8E54E9] 1: [#11998E, #38EF7D]
2: [#FC466B, #3F5EFB] 3: [#F7971E, #FFD200]
4: [#56CCF2, #2F80ED] 5: [#EB3349, #F45C43]
6: [#1FA2FF, #12D8FA] 7: [#9D50BB, #6E48AA]
```
### 7.2 彩色图标行与分组卡片 (#40)
`_IconBox`36pt 圆角正方形8pt白色图标iOS Settings 风格。
| 卡片组 | 菜单项 | 颜色 |
|--------|--------|------|
| 账户 | 我的钱包 | #FFAA5B |
| | 账户安全 | #8A5CF6 |
| 工具 | 收藏 | #FFAF45 |
| | 最近呼叫 | #4CB050 |
| | 链接设备 | #5667FF |
| | 聊天文件夹 | #F2994A |
| 偏好设置 | 通知和声音 | #FF8B5E |
| | 隐私设置 | #0BB8A9 |
| | 黑名单 | #FF4B4B |
| | 语言 | #5667FF |
| | 主题 | #8A5CF6 |
| 关于 | 用户协议 | gray |
| | 隐私政策 | gray |
| | 版本号(静态)| gray无 chevron |
### 7.3 SettingsState bio 字段 (#41)
- `SettingsState.bio: String`(默认 `''`
- `SettingsViewModel.loadProfile()` 赋值 `bio: profile.bio`
- 数据来源:`ProfileResponse.bio``GET /app/api/user/profile`
---
## 8. 待完成事项
- **#6 头像上传**:接入 CDN upload参考 iOS CDN 流程) - **#6 头像上传**:接入 CDN upload参考 iOS CDN 流程)
- **#8 主题持久化**:解开 `ThemeModeNotifier.build()``setMode()` 中的 TODO - **#8 主题持久化**:解开 `ThemeModeNotifier.build()``setMode()` 中的 TODO

View File

@@ -12,6 +12,7 @@ class SettingsState {
final String? avatarUrl; final String? avatarUrl;
final String maskedContact; final String maskedContact;
final int uid; final int uid;
final String bio;
final bool isLoading; final bool isLoading;
final bool isLoggingOut; final bool isLoggingOut;
final String? error; final String? error;
@@ -21,6 +22,7 @@ class SettingsState {
this.avatarUrl, this.avatarUrl,
this.maskedContact = '', this.maskedContact = '',
this.uid = 0, this.uid = 0,
this.bio = '',
this.isLoading = false, this.isLoading = false,
this.isLoggingOut = false, this.isLoggingOut = false,
this.error, this.error,
@@ -32,6 +34,7 @@ class SettingsState {
bool clearAvatarUrl = false, bool clearAvatarUrl = false,
String? maskedContact, String? maskedContact,
int? uid, int? uid,
String? bio,
bool? isLoading, bool? isLoading,
bool? isLoggingOut, bool? isLoggingOut,
String? error, String? error,
@@ -42,6 +45,7 @@ class SettingsState {
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl), avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
maskedContact: maskedContact ?? this.maskedContact, maskedContact: maskedContact ?? this.maskedContact,
uid: uid ?? this.uid, uid: uid ?? this.uid,
bio: bio ?? this.bio,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isLoggingOut: isLoggingOut ?? this.isLoggingOut, isLoggingOut: isLoggingOut ?? this.isLoggingOut,
error: clearError ? null : (error ?? this.error), error: clearError ? null : (error ?? this.error),
@@ -84,6 +88,7 @@ class SettingsViewModel extends Notifier<SettingsState> {
avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic, avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic,
maskedContact: _maskContact(profile.contact, profile.countryCode), maskedContact: _maskContact(profile.contact, profile.countryCode),
uid: profile.uid, uid: profile.uid,
bio: profile.bio,
); );
} catch (e) { } catch (e) {
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());

View File

@@ -1,22 +1,36 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/foundation/config.dart';
import 'package:im_app/features/settings/presentation/settings_view_model.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart';
/// 我的页面(原设置页 /// 我的页#39 / #40 / #41
/// ///
/// 结构: /// ## 结构iOS SettingsView.swift 对齐)
/// ┌─ 个人资料卡 ──────────────────────────────────────────┐ ///
/// 头像 昵称 /// - ProfileHeroCard72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio
/// │ 手机号(掩码) UID: xxx │ /// - AppBarcompact右侧 QR 图标 + 编辑铅笔
/// └──────────────────────────────────────────────────────┘ /// - 卡片组 1账户我的钱包 / 账户安全
/// 偏好设置 → 主题 / 语言 / 通知 /// - 卡片组 2工具收藏 / 最近呼叫 / 链接设备 / 聊天文件夹
/// 工具 → 黑名单 / 网络诊断 /// - 卡片组 3「偏好设置」通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题
/// 关于 → 关于本应用 /// - 卡片组 4「关于」用户协议 / 隐私政策 / 版本号
/// [退出登录] /// - 退出登录(全宽红色按钮)
class SettingsPage extends ConsumerWidget { class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
// ── iOS 色板 ──────────────────────────────────────────────────────────────
static const Color _walletColor = Color(0xFFFFAA5B);
static const Color _securityColor = Color(0xFF8A5CF6);
static const Color _favoriteColor = Color(0xFFFFAF45);
static const Color _callColor = Color(0xFF4CB050);
static const Color _deviceColor = Color(0xFF5667FF);
static const Color _folderColor = Color(0xFFF2994A);
static const Color _notifColor = Color(0xFFFF8B5E);
static const Color _privacyColor = Color(0xFF0BB8A9);
static const Color _blockColor = Color(0xFFFF4B4B);
static const Color _langColor = Color(0xFF5667FF);
static const Color _themeColor = Color(0xFF8A5CF6);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(settingsViewModelProvider); final state = ref.watch(settingsViewModelProvider);
@@ -26,75 +40,158 @@ class SettingsPage extends ConsumerWidget {
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar.large( // ── AppBarcompactQR + edit ────────────────────────────────────
SliverAppBar(
title: const Text('我的'), title: const Text('我的'),
pinned: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner_rounded),
tooltip: '我的二维码',
onPressed: () {}, // TODO: QR code page
), ),
SliverToBoxAdapter( IconButton(
child: Padding( icon: const Icon(Icons.edit_outlined),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), tooltip: '编辑资料',
child: _ProfileCard(state: state, onTap: () => vm.navigateToEditProfile(context)), onPressed: () => vm.navigateToEditProfile(context),
), ),
const SizedBox(width: 4),
],
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ── ProfileHeroCard ───────────────────────────────────────
_ProfileHeroCard(
state: state,
onTap: () => vm.navigateToEditProfile(context),
),
const SizedBox(height: 16),
// ── 卡片组 1账户 ────────────────────────────────────────
_SettingsCard(
items: [
_RowConfig(
icon: Icons.credit_card_rounded,
iconColor: _walletColor,
title: '我的钱包',
onTap: () {}, // TODO: 钱包页
),
_RowConfig(
icon: Icons.shield_rounded,
iconColor: _securityColor,
title: '账户安全',
onTap: () {}, // TODO: 账户安全页
),
],
),
const SizedBox(height: 16),
// ── 卡片组 2工具 ────────────────────────────────────────
_SettingsCard(
items: [
_RowConfig(
icon: Icons.star_rounded,
iconColor: _favoriteColor,
title: '收藏',
onTap: () {}, // TODO: 收藏页
),
_RowConfig(
icon: Icons.phone_rounded,
iconColor: _callColor,
title: '最近呼叫',
onTap: () {}, // TODO: 呼叫记录页
),
_RowConfig(
icon: Icons.laptop_rounded,
iconColor: _deviceColor,
title: '链接设备',
onTap: () {}, // TODO: 设备管理页
),
_RowConfig(
icon: Icons.folder_rounded,
iconColor: _folderColor,
title: '聊天文件夹',
onTap: () {}, // TODO: Issue #11
),
],
),
const SizedBox(height: 16),
// ── 卡片组 3偏好设置 ────────────────────────────────────
_SectionLabel('偏好设置'), _SectionLabel('偏好设置'),
_SettingsCard( _SettingsCard(
items: [ items: [
_RowConfig( _RowConfig(
icon: Icons.palette_outlined, icon: Icons.notifications_rounded,
title: '主题', iconColor: _notifColor,
onTap: () => vm.navigateToTheme(context), title: '通知和声音',
),
_RowConfig(
icon: Icons.language,
title: '语言',
onTap: () => vm.navigateToLanguage(context),
),
_RowConfig(
icon: Icons.notifications_outlined,
title: '通知',
onTap: () {}, // TODO: 通知设置页 onTap: () {}, // TODO: 通知设置页
), ),
],
),
const SizedBox(height: 16),
_SectionLabel('工具'),
_SettingsCard(
items: [
_RowConfig( _RowConfig(
icon: Icons.folder_outlined, icon: Icons.lock_rounded,
title: '聊天文件夹', iconColor: _privacyColor,
onTap: () {}, // TODO: Issue #11 title: '隐私设置',
onTap: () {}, // TODO: 隐私设置页
), ),
_RowConfig( _RowConfig(
icon: Icons.block, icon: Icons.block_rounded,
iconColor: _blockColor,
title: '黑名单', title: '黑名单',
onTap: () => vm.navigateToBlocklist(context), onTap: () => vm.navigateToBlocklist(context),
), ),
_RowConfig( _RowConfig(
icon: Icons.network_check, icon: Icons.language_rounded,
title: '网络诊断', iconColor: _langColor,
onTap: () => vm.navigateToNetworkDiagnostics(context), title: '语言',
onTap: () => vm.navigateToLanguage(context),
),
_RowConfig(
icon: Icons.palette_rounded,
iconColor: _themeColor,
title: '主题',
onTap: () => vm.navigateToTheme(context),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// ── 卡片组 4关于 ────────────────────────────────────────
_SectionLabel('关于'), _SectionLabel('关于'),
_SettingsCard( _SettingsCard(
items: [ items: [
_RowConfig( _RowConfig(
icon: Icons.info_outline, icon: Icons.description_outlined,
title: '关于本应用', iconColor: Colors.grey,
title: '用户协议',
onTap: () => vm.navigateToAbout(context), onTap: () => vm.navigateToAbout(context),
), ),
_RowConfig(
icon: Icons.privacy_tip_outlined,
iconColor: Colors.grey,
title: '隐私政策',
onTap: () => vm.navigateToAbout(context),
),
_RowConfig(
icon: Icons.info_outline_rounded,
iconColor: Colors.grey,
title: '版本号',
subtitle: AppConfig.appVersion.isEmpty
? '1.0.0'
: AppConfig.appVersion,
onTap: () {},
showChevron: false,
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// ── 退出登录 ───────────────────────────────────────────────
_LogoutButton( _LogoutButton(
isLoading: state.isLoggingOut, isLoading: state.isLoggingOut,
onTap: () => _confirmLogout(context, ref), onTap: () => _confirmLogout(context, ref),
@@ -134,16 +231,32 @@ class SettingsPage extends ConsumerWidget {
} }
} }
// ── 个人资料卡 ───────────────────────────────────────────────────────────────── // ── ProfileHeroCard (#39 / #41) ───────────────────────────────────────────────
class _ProfileCard extends StatelessWidget { /// iOS SettingsView.swift ProfileHeroCard 对齐
const _ProfileCard({required this.state, required this.onTap}); ///
/// 8 色渐变占位uid % 8+ 昵称 + @J{uid} handle + 手机号 + bio
class _ProfileHeroCard extends StatelessWidget {
const _ProfileHeroCard({required this.state, required this.onTap});
final SettingsState state; final SettingsState state;
final VoidCallback onTap; final VoidCallback onTap;
// 8 色渐变方案iOS 端 ProfileHeroCard 同款)
static const _gradients = [
[Color(0xFF4776E6), Color(0xFF8E54E9)], // 0: 蓝紫
[Color(0xFF11998E), Color(0xFF38EF7D)], // 1: 青绿
[Color(0xFFFC466B), Color(0xFF3F5EFB)], // 2: 粉蓝
[Color(0xFFF7971E), Color(0xFFFFD200)], // 3: 橙黄
[Color(0xFF56CCF2), Color(0xFF2F80ED)], // 4: 天蓝
[Color(0xFFEB3349), Color(0xFFF45C43)], // 5: 红橙
[Color(0xFF1FA2FF), Color(0xFF12D8FA)], // 6: 蓝青
[Color(0xFF9D50BB), Color(0xFF6E48AA)], // 7: 深紫
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card( return Card(
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -151,8 +264,13 @@ class _ProfileCard extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
_Avatar(avatarUrl: state.avatarUrl, isLoading: state.isLoading), _HeroAvatar(
avatarUrl: state.avatarUrl,
uid: state.uid,
isLoading: state.isLoading,
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: state.isLoading child: state.isLoading
@@ -162,25 +280,44 @@ class _ProfileCard extends StatelessWidget {
children: [ children: [
Text( Text(
state.nickname.isEmpty ? '加载中…' : state.nickname, state.nickname.isEmpty ? '加载中…' : state.nickname,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context)
fontWeight: FontWeight.w600, .textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
), ),
), const SizedBox(height: 2),
const SizedBox(height: 4), if (state.uid > 0)
Text( Text(
state.maskedContact.isNotEmpty '@J${state.uid}',
? state.maskedContact style: Theme.of(context)
: 'UID: ${state.uid}', .textTheme
style: Theme.of(context).textTheme.bodySmall?.copyWith( .bodySmall
color: Colors.grey, ?.copyWith(color: cs.onSurfaceVariant),
), ),
), if (state.maskedContact.isNotEmpty)
if (state.maskedContact.isNotEmpty && state.uid > 0)
Text( Text(
'UID: ${state.uid}', state.maskedContact,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context)
color: Colors.grey, .textTheme
.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
), ),
const SizedBox(height: 2),
Text(
state.bio.isNotEmpty ? state.bio : '添加一句话简介',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: state.bio.isNotEmpty
? cs.onSurfaceVariant
: cs.onSurfaceVariant.withOpacity(0.5),
fontStyle: state.bio.isEmpty
? FontStyle.italic
: FontStyle.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@@ -194,33 +331,51 @@ class _ProfileCard extends StatelessWidget {
} }
} }
class _Avatar extends StatelessWidget { class _HeroAvatar extends StatelessWidget {
const _Avatar({required this.avatarUrl, required this.isLoading}); const _HeroAvatar({
required this.avatarUrl,
required this.uid,
required this.isLoading,
});
final String? avatarUrl; final String? avatarUrl;
final int uid;
final bool isLoading; final bool isLoading;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const radius = 36.0; // 72pt diameter
if (isLoading) { if (isLoading) {
return const CircleAvatar( return const CircleAvatar(
radius: 28, radius: radius,
child: SizedBox( child: SizedBox(
width: 20, width: 24,
height: 20, height: 24,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
); );
} }
if (avatarUrl != null && avatarUrl!.isNotEmpty) { if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar( return CircleAvatar(
radius: 28, radius: radius,
backgroundImage: NetworkImage(avatarUrl!), backgroundImage: NetworkImage(avatarUrl!),
); );
} }
return const CircleAvatar( // 渐变占位uid % 8
radius: 28, final colors = _ProfileHeroCard._gradients[uid.abs() % 8];
child: Icon(Icons.person, size: 28), return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Icon(Icons.person, size: 36, color: Colors.white),
); );
} }
} }
@@ -253,11 +408,10 @@ class _ProfileSkeleton extends StatelessWidget {
} }
} }
// ── 设置分组 ─────────────────────────────────────────────────────────────────── // ── 设置分组 (#40) ─────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget { class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text); const _SectionLabel(this.text);
final String text; final String text;
@override @override
@@ -278,20 +432,23 @@ class _SectionLabel extends StatelessWidget {
class _RowConfig { class _RowConfig {
const _RowConfig({ const _RowConfig({
required this.icon, required this.icon,
required this.iconColor,
required this.title, required this.title,
this.subtitle, this.subtitle,
required this.onTap, required this.onTap,
this.showChevron = true,
}); });
final IconData icon; final IconData icon;
final Color iconColor;
final String title; final String title;
final String? subtitle; final String? subtitle;
final VoidCallback onTap; final VoidCallback onTap;
final bool showChevron;
} }
class _SettingsCard extends StatelessWidget { class _SettingsCard extends StatelessWidget {
const _SettingsCard({required this.items}); const _SettingsCard({required this.items});
final List<_RowConfig> items; final List<_RowConfig> items;
@override @override
@@ -303,7 +460,7 @@ class _SettingsCard extends StatelessWidget {
if (i > 0) if (i > 0)
Divider( Divider(
height: 1, height: 1,
indent: 52, indent: 60,
color: Theme.of(context).dividerColor.withOpacity(0.5), color: Theme.of(context).dividerColor.withOpacity(0.5),
), ),
_SettingsRow(config: items[i]), _SettingsRow(config: items[i]),
@@ -314,28 +471,58 @@ class _SettingsCard extends StatelessWidget {
} }
} }
/// iOS 风格行36pt 彩色圆角正方形图标 + 标题 + 可选副标题 + chevron
class _SettingsRow extends StatelessWidget { class _SettingsRow extends StatelessWidget {
const _SettingsRow({required this.config}); const _SettingsRow({required this.config});
final _RowConfig config; final _RowConfig config;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
leading: Icon(config.icon, size: 22), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
leading: _IconBox(icon: config.icon, color: config.iconColor),
title: Text(config.title), title: Text(config.title),
subtitle: config.subtitle != null ? Text(config.subtitle!) : null, subtitle: config.subtitle != null
trailing: const Icon(Icons.chevron_right, size: 18, color: Colors.grey), ? Text(
config.subtitle!,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey),
)
: null,
trailing: config.showChevron
? const Icon(Icons.chevron_right, size: 18, color: Colors.grey)
: null,
onTap: config.onTap, onTap: config.onTap,
); );
} }
} }
/// 36pt 圆角正方形彩色图标盒iOS Settings icon style
class _IconBox extends StatelessWidget {
const _IconBox({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: Colors.white, size: 20),
);
}
}
// ── 退出登录 ─────────────────────────────────────────────────────────────────── // ── 退出登录 ───────────────────────────────────────────────────────────────────
class _LogoutButton extends StatelessWidget { class _LogoutButton extends StatelessWidget {
const _LogoutButton({required this.isLoading, required this.onTap}); const _LogoutButton({required this.isLoading, required this.onTap});
final bool isLoading; final bool isLoading;
final VoidCallback onTap; final VoidCallback onTap;