feat(reward): 新增客户端奖励中心
This commit is contained in:
1 parent
67670d46b1
commit
bea08110c9
68 files changed
+3987
-332
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user