Files
nj/app/lib/features/home/views/home_page.dart
iven bb388ed8ff
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
问题修复:
1. Web端保存的日记看不到:createJournal 返回值未捕获,server ID 丢失导致
   后续元素保存用错 ID。现在使用 saved.id 贯穿全部操作。
2. 管理端看不到新建日记:后端 list_journals 添加 is_private 过滤,admin/teacher
   查看他人日记时排除私密日记。
3. RemoteJournalRepository 添加 onJournalChanged 变更通知流,HomeBloc 可自动刷新。
4. SyncEngine(native + web)enqueue 添加 is_private 防御性检查,私密日记不入队。
5. 编辑器 _persistState 条件入队:仅非私密日记同步到后端。
6. 分享流程改造:首次从私密变为公开时入队 create 操作上传。
7. 日记卡片添加可见性标签(仅自己可见/班级可见/公开),私密日记可点击分享。
8. 首页 _sharePrivateJournal 弹出 ShareBottomSheet 主动分享。
2026-06-04 12:03:24 +08:00

911 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 首页·日记流 — 严格对齐 spec §3.4 home-daily.html
//
// 视觉层级(从上到下):
// 1. 问候语 + 日期(右上角搜索按钮)
// 2. 连续记录徽章 streak-badge (pill)
// 3. 心情选择器5 选 1bg=#FFFFFF surface 卡片)
// 4. "今天的日记" 渐变卡片 + 浮动写按钮
// 5. 三栏统计(本月日记/连续天数/总日记数)
// 6. 最近记录标题 + 查看全部
// 7. 日记卡片列表
//
// 颜色规范spec §7.1
// - 页面背景用 var(--bg) #FFF8F0不是纯白
// - Card 用 var(--surface) #FFFFFF与页面背景形成层次
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart';
import '../../../data/services/sync_engine.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../../editor/widgets/share_bottom_sheet.dart';
import '../bloc/home_bloc.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => HomeBloc(
journalRepository: context.read<JournalRepository>(),
)..add(const HomeLoadData()),
child: const _HomeView(),
);
}
}
class _HomeView extends StatelessWidget {
const _HomeView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
final loaded = state is HomeLoaded ? state : const HomeLoaded();
final isLoading = state is HomeLoading;
return Scaffold(
backgroundColor: bg,
body: isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: () async {
context.read<HomeBloc>().add(const HomeRefresh());
},
child: _buildContent(context, loaded),
),
);
},
);
}
Widget _buildContent(BuildContext context, HomeLoaded state) {
final now = DateTime.now();
final greeting = _greeting(now.hour);
final dateText = _formatDate(now);
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
DesignTokens.spacing20,
MediaQuery.of(context).padding.top + DesignTokens.spacing8,
DesignTokens.spacing20,
DesignTokens.spacing24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GreetingHeader(
greeting: greeting,
username: '小暖',
dateText: dateText,
onSearchTap: () => context.push('/search'),
),
const SizedBox(height: DesignTokens.spacing16),
if (state.streakDays > 0) ...[
_StreakBadge(days: state.streakDays),
const SizedBox(height: DesignTokens.spacing16),
],
_MoodSelectorCard(
topMood: state.topMood,
todayWeather: state.todayWeather,
onMoodTap: (_) => context.push('/editor'),
),
const SizedBox(height: DesignTokens.spacing20),
_TodayCard(
hasTodayEntry: state.hasTodayEntry,
onTap: () => context.push('/editor'),
),
const SizedBox(height: DesignTokens.spacing20),
_QuickStats(
monthCount: state.monthCount,
streakDays: state.streakDays,
totalCount: state.totalCount,
),
const SizedBox(height: DesignTokens.spacing24),
_SectionHeader(
title: '最近记录',
onSeeAll: () => context.go('/calendar'),
),
const SizedBox(height: DesignTokens.spacing12),
state.recentJournals.isEmpty
? const _EmptyJournalState()
: _JournalList(journals: state.recentJournals),
],
),
);
}
String _greeting(int hour) {
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
if (hour < 22) return '晚上好';
return '夜深了';
}
String _formatDate(DateTime now) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final w = weekdays[now.weekday - 1];
return '${now.year}${now.month}${now.day}日 · $w';
}
}
/// 1. 顶部问候 + 日期 + 搜索按钮
class _GreetingHeader extends StatelessWidget {
const _GreetingHeader({
required this.greeting,
required this.username,
required this.dateText,
required this.onSearchTap,
});
final String greeting;
final String username;
final String dateText;
final VoidCallback onSearchTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final fg = colorScheme.onSurface;
final muted = colorScheme.onSurfaceVariant;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateText,
style: TextStyle(fontSize: 13, color: muted, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 30,
fontWeight: FontWeight.w700,
color: fg,
height: 1.2,
),
children: [
TextSpan(text: '$greeting'),
TextSpan(
text: username,
style: TextStyle(color: colorScheme.primary),
),
],
),
),
],
),
),
InkWell(
onTap: onSearchTap,
customBorder: const CircleBorder(),
child: Container(
width: DesignTokens.touchMin,
height: DesignTokens.touchMin,
decoration: BoxDecoration(
color: colorScheme.surface,
shape: BoxShape.circle,
boxShadow: AppShadows.soft(context),
),
child: Icon(Icons.search_rounded, size: 20, color: fg),
),
),
],
);
}
}
/// 2. 连续记录徽章 (pill, tertiary-soft 背景)
class _StreakBadge extends StatelessWidget {
const _StreakBadge({required this.days});
final int days;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.tertiarySoftLight,
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.local_fire_department_rounded,
size: 14,
color: Color(0xFFB8860B),
),
const SizedBox(width: 4),
Text(
'连续记录 $days',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFB8860B),
),
),
],
),
);
}
}
/// 3. 心情选择器卡片 — bg=#FFFFFF, radius=16
class _MoodSelectorCard extends StatelessWidget {
const _MoodSelectorCard({
required this.topMood,
required this.todayWeather,
required this.onMoodTap,
});
final Mood? topMood;
final Weather? todayWeather;
final ValueChanged<Mood> onMoodTap;
static const _moods = [
('😊', '开心', Mood.happy),
('😐', '平静', Mood.calm),
('😢', '难过', Mood.sad),
('😡', '生气', Mood.angry),
('🤔', '思考', Mood.thinking),
];
static const _weatherMap = {
Weather.sunny: ('', ''),
Weather.cloudy: ('', '多云'),
Weather.rainy: ('🌧', ''),
Weather.snowy: ('', ''),
Weather.windy: ('💨', ''),
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing20,
vertical: DesignTokens.spacing16,
),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
),
child: Column(
children: [
Row(
children: [
Text(
'今天心情如何?',
style: TextStyle(
fontFamily: AppTypography.handwrittenFont,
fontSize: 17,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const Spacer(),
Builder(builder: (context) {
final w = _weatherMap[todayWeather] ?? _weatherMap[Weather.sunny]!;
return Text(
'${w.$1} ${w.$2}',
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
);
}),
],
),
const SizedBox(height: DesignTokens.spacing12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _moods.map((m) {
final isTop = topMood == m.$3;
return InkWell(
onTap: () => onMoodTap(m.$3),
customBorder: const CircleBorder(),
child: _MoodOption(
emoji: m.$1,
label: m.$2,
selected: isTop,
),
);
}).toList(),
),
],
),
);
}
}
class _MoodOption extends StatelessWidget {
const _MoodOption({
required this.emoji,
required this.label,
required this.selected,
});
final String emoji;
final String label;
final bool selected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: DesignTokens.animFast,
width: DesignTokens.touchMin,
height: DesignTokens.touchMin,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surfaceWarmLight : Colors.transparent,
borderRadius: AppRadius.mdBorder,
),
child: Text(emoji, style: const TextStyle(fontSize: 28)),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
class _TodayCard extends StatelessWidget {
const _TodayCard({required this.hasTodayEntry, required this.onTap});
final bool hasTodayEntry;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.lgBorder,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.spacing24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
),
borderRadius: AppRadius.lgBorder,
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.25),
offset: const Offset(0, 4),
blurRadius: 14,
),
],
),
child: Stack(
children: [
Positioned(
right: -30,
top: -30,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
),
),
Positioned(
left: -20,
bottom: -20,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今天的日记',
style: TextStyle(
fontFamily: AppTypography.handwrittenFont,
fontSize: 13,
color: Colors.white.withValues(alpha: 0.85),
),
),
const SizedBox(height: 8),
Text(
hasTodayEntry ? '继续今天的记录' : '写点什么吧...',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.bgLight,
),
),
const SizedBox(height: 6),
Text(
'记录一个温暖的瞬间...',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.75),
),
),
],
),
Positioned(
right: DesignTokens.spacing20,
bottom: DesignTokens.spacing20,
child: Material(
color: Colors.white,
shape: const CircleBorder(),
elevation: 4,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 48,
height: 48,
child: Icon(Icons.add_rounded, size: 22, color: AppColors.accent),
),
),
),
),
],
),
),
),
);
}
}
/// 5. 三栏统计
class _QuickStats extends StatelessWidget {
const _QuickStats({
required this.monthCount,
required this.streakDays,
required this.totalCount,
});
final int monthCount;
final int streakDays;
final int totalCount;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: _stat(context, '$monthCount', '本月日记', AppColors.accent)),
const SizedBox(width: DesignTokens.spacing12),
Expanded(child: _stat(context, '$streakDays', '连续天数', AppColors.secondary)),
const SizedBox(width: DesignTokens.spacing12),
Expanded(child: _stat(context, '$totalCount', '总日记数', AppColors.fgLight)),
],
);
}
Widget _stat(BuildContext context, String num, String label, Color color) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(DesignTokens.spacing16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
),
child: Column(
children: [
Text(
num,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 30,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
/// 6. 区块标题
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.onSeeAll});
final String title;
final VoidCallback onSeeAll;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 20,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
TextButton(
onPressed: onSeeAll,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size(44, 32),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('查看全部', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
],
);
}
}
/// 7. 日记列表
class _JournalList extends StatelessWidget {
const _JournalList({required this.journals});
final List<JournalEntry> journals;
@override
Widget build(BuildContext context) {
return Column(
children: journals
.map((j) => Padding(
padding: const EdgeInsets.only(bottom: DesignTokens.spacing12),
child: _JournalCard(journal: j),
))
.toList(),
);
}
}
class _JournalCard extends StatelessWidget {
const _JournalCard({required this.journal});
final JournalEntry journal;
String _moodEmoji(Mood m) => switch (m) {
Mood.happy => '😊',
Mood.calm => '😐',
Mood.sad => '😢',
Mood.angry => '😡',
Mood.thinking => '🤔',
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
final excerpt = journal.contentExcerpt?.isNotEmpty == true
? journal.contentExcerpt!
: (journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' '));
return Material(
color: theme.colorScheme.surface,
borderRadius: AppRadius.mdBorder,
child: InkWell(
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.all(DesignTokens.spacing16),
decoration: BoxDecoration(
borderRadius: AppRadius.mdBorder,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.surfaceWarmLight,
borderRadius: AppRadius.smBorder,
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 32)),
),
const SizedBox(width: DesignTokens.spacing16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${journal.date.month}${journal.date.day}',
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(width: 6),
// 可见性标签
_VisibilityBadge(
isPrivate: journal.isPrivate,
sharedToClass: journal.sharedToClass,
onTap: journal.isPrivate
? () => _sharePrivateJournal(context, journal)
: null,
),
],
),
const SizedBox(height: 2),
Text(
journal.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 15,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant, height: 1.5),
),
],
),
),
const SizedBox(width: 8),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: moodColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
),
],
),
),
),
);
}
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
String? userClassId;
String userClassName = '我的班级';
try {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
try {
final classRepo = context.read<ClassRepository>();
final classes = await classRepo.getMyClasses();
if (classes.isNotEmpty) {
userClassId = classes.first.id;
userClassName = classes.first.name;
}
} catch (_) {
// 没有班级信息,使用默认值
}
}
} catch (_) {}
// ignore: use_build_context_synchronously
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) => ShareBottomSheet(
classId: userClassId,
className: userClassName,
onDecision: (shareToClass) async {
try {
final repo = context.read<JournalRepository>();
// 将私密日记变为公开
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
);
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
// 刷新首页列表
// ignore: use_build_context_synchronously
context.read<HomeBloc>().add(const HomeRefresh());
} catch (e) {
debugPrint('分享日记失败: $e');
}
},
),
);
}
}
/// 可见性标签 — 显示日记的可见性状态
///
/// - 私密:🔒 仅自己可见(可点击分享)
/// - 分享到班级:🏫 班级可见
/// - 公开:🌐 所有人可见
class _VisibilityBadge extends StatelessWidget {
const _VisibilityBadge({
required this.isPrivate,
required this.sharedToClass,
this.onTap,
});
final bool isPrivate;
final bool sharedToClass;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (isPrivate) {
// 私密日记 — 显示锁定图标,可点击分享
return InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.tertiarySoftLight,
borderRadius: AppRadius.pillBorder,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)),
SizedBox(width: 3),
Text(
'仅自己可见',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)),
),
],
),
),
);
}
if (sharedToClass) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: AppRadius.pillBorder,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups, size: 12, color: AppColors.secondary),
SizedBox(width: 3),
Text(
'班级可见',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.public, size: 12, color: AppColors.accent),
const SizedBox(width: 3),
Text(
'公开',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent),
),
],
),
);
}
}
class _EmptyJournalState extends StatelessWidget {
const _EmptyJournalState();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
child: Column(
children: [
Icon(
Icons.auto_stories_rounded,
size: 64,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: DesignTokens.spacing16),
Text(
'开始你的第一篇手账日记吧!',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: DesignTokens.spacing24),
FilledButton.icon(
onPressed: () => context.push('/editor'),
icon: const Icon(Icons.add_rounded),
label: const Text('写日记'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: AppColors.bgLight,
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
),
);
}
}