feat(reward): 新增客户端奖励中心

This commit is contained in:
Zhou Weidong committed 2026-11-13 13:43:38 +08:00
1 parent 67670d46b1
commit bea08110c9
68 files changed
+3987 -332

No files matched your search

+1 -1
View File
@@ -6,5 +6,5 @@
/// 开发机的局域网 IP(真机上的 127.0.0.1 指向手机自己)。地址不要带结尾斜杠。
abstract final class DevConfig {
/// 开发机后端地址(Mac 当前 Wi-Fi 地址,换网络后需要同步修改)。
static const String baseUrl = 'http://192.168.31.243:8081';
static const String baseUrl = 'http://192.168.31.120:8081';
}
+242
View File
@@ -0,0 +1,242 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 奖励中心首屏数据。
class RewardOverview {
const RewardOverview({
required this.serverDate,
required this.timezone,
required this.coinBalance,
required this.checkin,
this.sections = const [],
this.activities = const [],
});
final String serverDate;
final String timezone;
final int coinBalance;
final RewardCheckinState checkin;
final List<RewardSection> sections;
final List<RewardActivity> activities;
factory RewardOverview.fromJson(Map<String, dynamic> json) {
return RewardOverview(
serverDate: json['serverDate'] as String? ?? '',
timezone: json['timezone'] as String? ?? '',
coinBalance: jsonToInt(json['coinBalance']),
checkin: RewardCheckinState.fromJson(_map(json['checkin'])),
sections: _maps(json['sections']).map(RewardSection.fromJson).toList(),
activities: _maps(
json['activities'],
).map(RewardActivity.fromJson).toList(),
);
}
}
/// 七日签到状态。
class RewardCheckinState {
const RewardCheckinState({
required this.taskId,
required this.checkedInToday,
required this.streak,
required this.currentDay,
this.days = const [],
});
final String taskId;
final bool checkedInToday;
final int streak;
final int currentDay;
final List<RewardCheckinDay> days;
factory RewardCheckinState.fromJson(Map<String, dynamic> json) {
return RewardCheckinState(
taskId: (json['taskId'] ?? '').toString(),
checkedInToday: json['checkedInToday'] == true,
streak: jsonToInt(json['streak']),
currentDay: jsonToInt(json['currentDay']),
days: _maps(json['days']).map(RewardCheckinDay.fromJson).toList(),
);
}
}
/// 签到周期中的一天。
class RewardCheckinDay {
const RewardCheckinDay({
required this.dayIndex,
required this.rewardCoin,
required this.checked,
required this.current,
});
final int dayIndex;
final int rewardCoin;
final bool checked;
final bool current;
factory RewardCheckinDay.fromJson(Map<String, dynamic> json) {
return RewardCheckinDay(
dayIndex: jsonToInt(json['dayIndex']),
rewardCoin: jsonToInt(json['rewardCoin']),
checked: json['checked'] == true,
current: json['current'] == true,
);
}
}
/// 一组奖励任务。
class RewardSection {
const RewardSection({
required this.code,
required this.title,
this.tasks = const [],
});
final String code;
final String title;
final List<RewardTask> tasks;
factory RewardSection.fromJson(Map<String, dynamic> json) {
return RewardSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
tasks: _maps(json['tasks']).map(RewardTask.fromJson).toList(),
);
}
}
/// 奖励任务卡片。
class RewardTask {
const RewardTask({
required this.id,
required this.code,
required this.type,
required this.title,
required this.subtitle,
required this.ctaText,
required this.actionType,
required this.actionUrl,
required this.progressValue,
required this.targetValue,
required this.rewardCoin,
required this.status,
this.iconUrl = '',
});
final String id;
final String code;
final String type;
final String title;
final String subtitle;
final String ctaText;
final String actionType;
final String actionUrl;
final String iconUrl;
final int progressValue;
final int targetValue;
final int rewardCoin;
final String status;
bool get claimable => status == 'COMPLETED';
bool get claimed => status == 'CLAIMED';
double get progress =>
targetValue <= 0 ? 0 : (progressValue / targetValue).clamp(0.0, 1.0);
factory RewardTask.fromJson(Map<String, dynamic> json) {
return RewardTask(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
iconUrl: json['iconUrl'] as String? ?? '',
progressValue: jsonToInt(json['progressValue']),
targetValue: jsonToInt(json['targetValue']),
rewardCoin: jsonToInt(json['rewardCoin']),
status: json['status'] as String? ?? 'IN_PROGRESS',
);
}
}
/// 奖励活动展示卡片。
class RewardActivity {
const RewardActivity({
required this.id,
required this.code,
required this.title,
required this.summary,
required this.ctaText,
required this.actionType,
required this.actionUrl,
this.coverUrl = '',
});
final String id;
final String code;
final String title;
final String summary;
final String ctaText;
final String actionType;
final String actionUrl;
final String coverUrl;
factory RewardActivity.fromJson(Map<String, dynamic> json) {
return RewardActivity(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
coverUrl: json['coverUrl'] as String? ?? '',
);
}
}
/// 签到或任务领取后的余额变化结果。
class RewardActionResult {
const RewardActionResult({
required this.rewardCoin,
required this.coinBalance,
required this.alreadyCompleted,
});
final int rewardCoin;
final int coinBalance;
final bool alreadyCompleted;
factory RewardActionResult.fromCheckin(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyCheckedIn'] == true,
);
}
factory RewardActionResult.fromClaim(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyGranted'] == true,
);
}
}
Map<String, dynamic> _map(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return const {};
}
List<Map<String, dynamic>> _maps(Object? value) {
if (value is! List) return const [];
return value
.whereType<Map>()
.map((item) => item.cast<String, dynamic>())
.toList();
}
+30
View File
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import 'api_client.dart';
import 'response_decoders.dart';
/// 奖励中心接口,对应契约 §1.8.1。
class RewardApi {
const RewardApi(this._client);
final ApiClient _client;
Future<RewardOverview> overview(String locale) async {
final data = await _client.get(
'/app/rewards/overview',
query: {'locale': locale},
);
return RewardOverview.fromJson(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> checkIn() async {
final data = await _client.post('/app/rewards/check-in');
return RewardActionResult.fromCheckin(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> claim(String taskId) async {
final data = await _client.post('/app/rewards/tasks/$taskId/claim');
return RewardActionResult.fromClaim(ResponseDecoders.asMap(data));
}
}
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import '../remote/reward_api.dart';
/// 奖励中心数据来源。
abstract interface class RewardRepository {
Future<RewardOverview> fetchOverview(String locale);
Future<RewardActionResult> checkIn();
Future<RewardActionResult> claim(String taskId);
}
/// 奖励中心 HTTP 仓库。
class HttpRewardRepository implements RewardRepository {
const HttpRewardRepository(this._api);
final RewardApi _api;
@override
Future<RewardOverview> fetchOverview(String locale) => _api.overview(locale);
@override
Future<RewardActionResult> checkIn() => _api.checkIn();
@override
Future<RewardActionResult> claim(String taskId) => _api.claim(taskId);
}
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
@@ -58,6 +59,10 @@ class GoogleAuthService {
static const String _serverClientId =
'11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com';
/// 授权等待上限:插件在账号被撤销后可能永不返回,必须有超时兜底,
/// 否则页面 loading 状态会一直卡住、按钮再也点不动。
static const Duration _authTimeout = Duration(seconds: 60);
final GoogleSignIn _signIn;
bool _initialized = false;
@@ -68,22 +73,41 @@ class GoogleAuthService {
if (!_signIn.supportsAuthenticate()) {
return const GoogleSignInOutcome.unavailable();
}
final account = await _signIn.authenticate();
// 先清掉插件缓存的旧账号:用户可能在 Google 侧撤销过授权或切换过账号,
// 残留状态会让 authenticate 直接返回失效账号或干脆不弹授权页。
await _clearStaleAccount();
final account = await _signIn.authenticate().timeout(_authTimeout);
final idToken = account.authentication.idToken;
if (idToken == null || idToken.isEmpty) {
return const GoogleSignInOutcome.failed();
}
return GoogleSignInOutcome.success(idToken);
} on TimeoutException {
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
} on GoogleSignInException catch (error) {
if (error.code == GoogleSignInExceptionCode.canceled) {
return const GoogleSignInOutcome.canceled();
}
// 失败多半是插件缓存了已失效的账号(用户手动撤销授权、换账号),
// 清掉本地状态,下次点击才能重新拉起授权页。
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
} catch (_) {
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
}
}
/// 清掉插件缓存的 Google 账号;失败不影响登录结果,异常直接吞掉。
Future<void> _clearStaleAccount() async {
try {
await _signIn.signOut();
} catch (_) {
// 忽略:清缓存失败不应阻断页面恢复
}
}
/// 插件要求先 initialize 才能 authenticate;只做一次。
Future<void> _ensureInitialized() async {
if (_initialized) return;
@@ -0,0 +1,108 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/reward.dart';
import '../remote/api_exception.dart';
import '../repositories/reward_repository.dart';
/// 奖励中心状态与签到、领取动作。
class RewardController extends ChangeNotifier {
RewardController(this._repository);
final RewardRepository _repository;
RewardOverview? overview;
bool loading = false;
bool checkingIn = false;
String? claimingTaskId;
String? error;
String? actionError;
bool _disposed = false;
String _locale = 'id';
Future<void> load(String locale) async {
if (_disposed || loading) return;
_locale = locale;
loading = true;
error = null;
_notify();
try {
overview = await _repository.fetchOverview(locale);
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'rewardLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 签到成功后刷新总览,确保签到格和余额来自服务端事实。
Future<RewardActionResult?> checkIn() async {
if (_disposed || checkingIn) return null;
checkingIn = true;
actionError = null;
_notify();
try {
final result = await _repository.checkIn();
await _reload();
return result;
} on ApiException catch (exception) {
actionError = exception.message;
return null;
} catch (_) {
actionError = 'rewardActionFailed';
return null;
} finally {
checkingIn = false;
_notify();
}
}
/// 领取任务奖励;同一时刻只允许一个领取动作。
Future<RewardActionResult?> claim(String taskId) async {
if (_disposed || claimingTaskId != null) return null;
claimingTaskId = taskId;
actionError = null;
_notify();
try {
final result = await _repository.claim(taskId);
await _reload();
return result;
} on ApiException catch (exception) {
actionError = exception.message;
return null;
} catch (_) {
actionError = 'rewardActionFailed';
return null;
} finally {
claimingTaskId = null;
_notify();
}
}
void reset() {
overview = null;
loading = false;
checkingIn = false;
claimingTaskId = null;
error = null;
actionError = null;
_notify();
}
Future<void> _reload() async {
overview = await _repository.fetchOverview(_locale);
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+7
View File
@@ -182,6 +182,7 @@ abstract final class AppColors {
static const navBackground = Color(0xFF000000);
static const brandRed = Color(0xFFF04E4E);
static const brandRedPressed = Color(0xFFD64040);
static const navSelectedGlow = Color(0x33F04E4E);
static const navSelected = brandRed;
/// 卡片阴影(深色几乎不用阴影)。
@@ -222,6 +223,12 @@ abstract final class AppColors {
/// 玻璃面板描边。
static const glassBorder = Color(0x33FFFFFF);
/// 玻璃控件使用的发丝级轮廓,避免边缘抢过内容。
static const glassHairline = Color(0x12FFFFFF);
/// 轻量玻璃控件的白色表面。
static const glassSurface = Color(0x24FFFFFF);
static const glassSurfaceStart = Color(0xEB16161A);
static const glassSurfaceEnd = Color(0xD1101010);
+4 -4
View File
@@ -21,7 +21,7 @@ abstract final class AppDimens {
static const double buttonRadius = 10;
static const double inputRadius = 10;
static const double chipRadius = 6;
static const double coverRadius = 12;
static const double coverRadius = 8;
static const double sheetRadius = 12;
static const double caption = 12;
@@ -92,7 +92,7 @@ abstract final class AppDimens {
static const double homeGridGap = 6;
static const double homeGridRowGap = 10;
static const double homeCardRadius = 6;
static const double cardCoverRadius = 8;
static const double cardCoverRadius = coverRadius;
static const double homeCardPadding = 8;
static const double cardTitleFont = 15;
static const double cardTitleLineHeight = 1.3;
@@ -157,7 +157,7 @@ abstract final class AppDimens {
/// 首页热播榜。
static const double rankingHeroTitle = 22;
static const double rankingHeroSubtitle = 11;
static const double rankingCoverRadius = 6;
static const double rankingCoverRadius = coverRadius;
static const double rankingCardCoverWidth = 96;
static const double rankingCardCoverHeight = 128;
@@ -189,7 +189,7 @@ abstract final class AppDimens {
static const double followingProgressHeight = 2;
static const double followingHistoryCoverWidth = 96;
static const double followingHistoryCoverHeight = 128;
static const double followingHistoryCoverRadius = 6;
static const double followingHistoryCoverRadius = coverRadius;
static const double followingHistoryContentGap = 12;
static const double followingHistoryRowGap = 16;
static const double followingHistoryProgressHeight = 3;
+40 -28
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
import 'app_press_scale.dart';
const double _maxNavigationTextScale = 1.3;
@@ -32,7 +33,7 @@ class AppBottomNavItem {
final bool selected;
}
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
/// 均分底部导航:纯黑背景,品牌红选中态,白色未选中态。
class AppBottomNav extends StatelessWidget {
const AppBottomNav({super.key, required this.items, this.barKey});
@@ -91,35 +92,46 @@ class _NavDestination extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedScale(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
scale: item.selected ? 1 : 0.96,
child:
item.customIcon ??
(assetPath == null
? Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
)
: ColorFiltered(
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
AppPressScale(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDimens.navIcon),
boxShadow: item.selected
? const [
BoxShadow(
color: AppColors.navSelectedGlow,
blurRadius: 12,
spreadRadius: 1,
),
child: Image.asset(
assetPath,
width: AppDimens.navIcon,
height: AppDimens.navIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
]
: const [],
),
child:
item.customIcon ??
(assetPath == null
? Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
)
: ColorFiltered(
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
),
),
)),
child: Image.asset(
assetPath,
width: AppDimens.navIcon,
height: AppDimens.navIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
),
),
)),
),
),
const SizedBox(height: AppDimens.navLabelGap),
Text(
+53
View File
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
const double _pressedScale = 0.97;
/// 为可点击内容提供轻量、短促的按压缩放反馈。
class AppPressScale extends StatefulWidget {
const AppPressScale({
super.key,
required this.child,
this.onTap,
this.scale = _pressedScale,
});
final Widget child;
final VoidCallback? onTap;
final double scale;
@override
State<AppPressScale> createState() => _AppPressScaleState();
}
class _AppPressScaleState extends State<AppPressScale> {
bool _pressed = false;
void _setPressed(bool pressed) {
if (_pressed == pressed) {
return;
}
setState(() => _pressed = pressed);
}
@override
Widget build(BuildContext context) {
final scale = _pressed ? widget.scale : 1.0;
final feedback = Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _setPressed(true),
onPointerUp: (_) => _setPressed(false),
onPointerCancel: (_) => _setPressed(false),
child: Transform.scale(scale: scale, child: widget.child),
);
if (widget.onTap == null) {
return feedback;
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onTap,
child: feedback,
);
}
}
+6 -1
View File
@@ -11,6 +11,7 @@ const double _maxPullDistance = 96;
const double _refreshThreshold = 56;
const double _indicatorHeight = 56;
const double _indicatorDisplacement = 40;
const double _indicatorTopPadding = 8;
const Duration _settleDuration = Duration(milliseconds: 200);
enum _PullState { idle, pull, armed, refreshing }
@@ -27,6 +28,10 @@ class AppPullToRefresh extends StatefulWidget {
static const AlwaysScrollableScrollPhysics physics =
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
/// 达到刷新阈值时,指示器顶部相对容器顶部的下移距离。
static const double indicatorRevealOffset =
_indicatorDisplacement + _indicatorTopPadding;
final Widget child;
final Future<void> Function() onRefresh;
final double edgeOffset;
@@ -191,7 +196,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.only(top: _indicatorTopPadding),
child: _indicator(),
),
),
+7 -1
View File
@@ -11,6 +11,7 @@ import 'root_nav_glyph.dart';
enum AppRootTab {
home(),
recommend(),
reward(),
following(),
mine();
@@ -21,6 +22,7 @@ extension AppRootTabUi on AppRootTab {
RootNavGlyphKind get glyphKind => switch (this) {
AppRootTab.home => RootNavGlyphKind.home,
AppRootTab.recommend => RootNavGlyphKind.recommend,
AppRootTab.reward => RootNavGlyphKind.reward,
AppRootTab.following => RootNavGlyphKind.following,
AppRootTab.mine => RootNavGlyphKind.mine,
};
@@ -28,6 +30,7 @@ extension AppRootTabUi on AppRootTab {
Key get tabKey => switch (this) {
AppRootTab.home => const Key('tab-home'),
AppRootTab.recommend => const Key('tab-recommend'),
AppRootTab.reward => const Key('tab-reward'),
AppRootTab.following => const Key('tab-follow'),
AppRootTab.mine => const Key('tab-mine'),
};
@@ -35,6 +38,7 @@ extension AppRootTabUi on AppRootTab {
IconData get icon => switch (this) {
AppRootTab.home => Icons.home_outlined,
AppRootTab.recommend => Icons.smart_display_outlined,
AppRootTab.reward => Icons.card_giftcard_outlined,
AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded,
};
@@ -42,6 +46,7 @@ extension AppRootTabUi on AppRootTab {
IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.home_rounded,
AppRootTab.recommend => Icons.smart_display_rounded,
AppRootTab.reward => Icons.card_giftcard_rounded,
AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded,
};
@@ -49,12 +54,13 @@ extension AppRootTabUi on AppRootTab {
String label(AppLocalizations l10n) => switch (this) {
AppRootTab.home => l10n.homeTab,
AppRootTab.recommend => l10n.recommendedTab,
AppRootTab.reward => l10n.rewardTab,
AppRootTab.following => l10n.followingTab,
AppRootTab.mine => l10n.myTab,
};
}
/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。
/// 根导航唯一入口,确保五个主页面使用完全一致的底栏配置。
class AppRootBottomNav extends StatelessWidget {
const AppRootBottomNav({
super.key,
+1 -1
View File
@@ -168,7 +168,7 @@ class _SkeletonBlock extends StatelessWidget {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.skeletonSurface,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
),
);
}
+30 -2
View File
@@ -7,8 +7,8 @@ import '../theme/app_colors.dart';
const double _designSize = 30;
/// 根导航四个入口对应的图标种类。
enum RootNavGlyphKind { home, recommend, following, mine }
/// 根导航五个入口对应的图标种类。
enum RootNavGlyphKind { home, recommend, reward, following, mine }
/// 根导航插画图标,根据选中态使用品牌红和中性灰白配色。
class RootNavGlyph extends StatelessWidget {
@@ -52,6 +52,8 @@ class _RootNavGlyphPainter extends CustomPainter {
_paintHome(canvas);
case RootNavGlyphKind.recommend:
_paintRecommend(canvas);
case RootNavGlyphKind.reward:
_paintReward(canvas);
case RootNavGlyphKind.following:
_paintFollowing(canvas);
case RootNavGlyphKind.mine:
@@ -151,6 +153,32 @@ class _RootNavGlyphPainter extends CustomPainter {
canvas.drawPath(bookmark, Paint()..color = _light);
}
void _paintReward(Canvas canvas) {
final box = RRect.fromRectAndRadius(
const Rect.fromLTRB(4, 10, 26, 26),
const Radius.circular(4),
);
canvas.drawShadow(Path()..addRRect(box), AppColors.shadow, 2, false);
canvas.drawRRect(box, _gradient(const Rect.fromLTWH(4, 10, 22, 16)));
canvas.drawRect(const Rect.fromLTRB(3, 8, 27, 13), Paint()..color = _main);
canvas.drawRect(
const Rect.fromLTRB(13, 8, 17, 26),
Paint()..color = _light,
);
final left = Path()
..moveTo(15, 9)
..cubicTo(8, 9, 7, 3, 11, 3)
..cubicTo(14, 3, 15, 7, 15, 9)
..close();
final right = Path()
..moveTo(15, 9)
..cubicTo(22, 9, 23, 3, 19, 3)
..cubicTo(16, 3, 15, 7, 15, 9)
..close();
canvas.drawPath(left, Paint()..color = _light);
canvas.drawPath(right, Paint()..color = _light);
}
void _paintMine(Canvas canvas) {
final bounds = Rect.fromCircle(center: const Offset(15, 15), radius: 13);
canvas.drawShadow(Path()..addOval(bounds), AppColors.shadow, 2, false);