Merge branch 'develop'
This commit is contained in:
commit
496b1cb797
76 files changed
+3577
-1453
No files matched your search
@@ -3,7 +3,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Native Android activity PiP; the Flutter playback session keeps the player.
|
||||
/// 原生活动级画中画桥:Android 是 Activity PiP(画面即 Flutter 渲染),
|
||||
/// iOS 是影子播放器接力(画面/声音来自原生 AVPlayer,退出时进度回传)。
|
||||
class AndroidPictureInPicture {
|
||||
AndroidPictureInPicture({MethodChannel? channel})
|
||||
: _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip');
|
||||
@@ -11,22 +12,147 @@ class AndroidPictureInPicture {
|
||||
final MethodChannel _channel;
|
||||
|
||||
bool get isPlatformSupported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
void setStateListener(ValueChanged<bool>? listener) {
|
||||
_channel.setMethodCallHandler(
|
||||
listener == null
|
||||
? null
|
||||
: (call) async {
|
||||
if (call.method == 'onPipChanged') {
|
||||
listener(call.arguments == true);
|
||||
}
|
||||
},
|
||||
);
|
||||
void Function(bool active, int finalPositionMs)? _pipChangedListener;
|
||||
VoidCallback? _playbackEndedListener;
|
||||
|
||||
/// PiP 状态回调:iOS 停止时 [finalPositionMs] 为影子最终进度(毫秒)。
|
||||
void setStateListener(void Function(bool active, int finalPositionMs)? listener) {
|
||||
_pipChangedListener = listener;
|
||||
_refreshHandler();
|
||||
}
|
||||
|
||||
Future<bool> enter() async {
|
||||
/// iOS 画中画内影子播完当前集(Android 不会触发)。
|
||||
void setPlaybackEndedListener(VoidCallback? listener) {
|
||||
_playbackEndedListener = listener;
|
||||
_refreshHandler();
|
||||
}
|
||||
|
||||
void _refreshHandler() {
|
||||
if (_pipChangedListener == null && _playbackEndedListener == null) {
|
||||
_channel.setMethodCallHandler(null);
|
||||
return;
|
||||
}
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'pipDebug':
|
||||
debugPrint('pip[native]: ${call.arguments}');
|
||||
case 'onPipChanged':
|
||||
final listener = _pipChangedListener;
|
||||
if (listener == null) return;
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
listener(
|
||||
args['active'] == true,
|
||||
(args['positionMs'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
} else {
|
||||
// Android 只回传 bool。
|
||||
listener(args == true, 0);
|
||||
}
|
||||
case 'onPipPlaybackEnded':
|
||||
_playbackEndedListener?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 进入画中画。Android 忽略片源参数;iOS 用 [url] 与 [positionMs]
|
||||
/// 初始化影子播放器(对齐到当前进度、静音待命)。
|
||||
Future<bool> enter({required String url, required int positionMs}) async {
|
||||
if (!isPlatformSupported) return false;
|
||||
return await _channel.invokeMethod<bool>('enter') ?? false;
|
||||
return await _channel.invokeMethod<bool>('enter', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
}) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// 创建影子播放器但不起播(仅 iOS):
|
||||
/// 自动小窗待命形态——静音暂停,退后台由 [armAutoEnter] 拉起。
|
||||
Future<void> prepareShadow({
|
||||
required String url,
|
||||
required int positionMs,
|
||||
bool autoEnter = false,
|
||||
}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('prepareShadow', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
'autoEnter': autoEnter,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 退后台瞬间把影子对齐到当前进度并静音起播,触发系统自动画中画(仅 iOS)。
|
||||
Future<void> armAutoEnter(int positionMs) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('armAutoEnter', {
|
||||
'positionMs': positionMs,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 回前台后影子回待命(暂停、静音)(仅 iOS)。
|
||||
Future<void> disarmAutoEnter() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('disarmAutoEnter');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 显示原生还原过渡层(仅 iOS;还原动画开始时原生侧也会自动盖上)。
|
||||
Future<void> showRestoreOverlay() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('showRestoreOverlay');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 淡出原生还原过渡层(仅 iOS;视频首帧确认后调用)。
|
||||
Future<void> hideRestoreOverlay() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('hideRestoreOverlay');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 解除影子静音并起播(仅 iOS 有意义,进入 PiP 后声音交给影子)。
|
||||
Future<void> unmute() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('unmute');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS 画中画内换集:影子只换播放项,窗口不中断。
|
||||
Future<void> replaceItem({required String url, required int positionMs}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('replaceItem', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 请求退出画中画(iOS;触发 onPipChanged(false) 后由 Dart 收尾)。
|
||||
Future<void> stop() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('stop');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 销毁影子播放器(仅 iOS)。
|
||||
Future<void> teardown() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('teardown');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ part 'playback_neighbors.dart';
|
||||
part 'playback_session_controls.dart';
|
||||
|
||||
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
|
||||
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
|
||||
const Duration _qualityHandoffHold = Duration(milliseconds: 500);
|
||||
|
||||
typedef PlayerFactory = PlayerPort Function(Episode episode);
|
||||
typedef PlayerPreparation =
|
||||
|
||||
@@ -144,6 +144,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
/// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。
|
||||
WatchRecord? recordForDrama(String dramaId) {
|
||||
for (final record in records) {
|
||||
if (record.dramaId == dramaId) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
|
||||
@@ -27,13 +27,13 @@ abstract final class AppColors {
|
||||
static const overlaySecondary = Color(0xFFC7C7CC);
|
||||
static const overlayLabel = Color(0xFFE6E6EB);
|
||||
|
||||
/// 主操作、选中态、播放进度(品牌橙)。
|
||||
static const blue = Color(0xFFFF6B2C);
|
||||
/// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。
|
||||
static const blue = Color(0xFFF04E4E);
|
||||
static const primary = blue;
|
||||
static const actionRed = Color(0xFFFF3B30);
|
||||
|
||||
/// 主操作按下态。
|
||||
static const bluePressed = Color(0xFFE55A1F);
|
||||
static const bluePressed = Color(0xFFD64040);
|
||||
|
||||
/// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。
|
||||
static const blueSoft = Color(0xFFB0B0B0);
|
||||
@@ -47,6 +47,10 @@ abstract final class AppColors {
|
||||
static const homeGiftPink = Color(0xFFFF4D8D);
|
||||
static const vipGoldStart = Color(0xFFF5D9A0);
|
||||
static const vipGoldEnd = Color(0xFFE8B65A);
|
||||
|
||||
/// 「我的」页 VIP 卡的浅香槟金(见设计稿,接近纯色)。
|
||||
static const vipCardStart = Color(0xFFF6E8C6);
|
||||
static const vipCardEnd = Color(0xFFEDD9A8);
|
||||
static const vipBadgeSurface = Color(0x1F3D2408);
|
||||
static const bronze = Color(0xFFCD7F32);
|
||||
static const bronzeDeep = Color(0xFFA9713A);
|
||||
@@ -75,6 +79,15 @@ abstract final class AppColors {
|
||||
/// 首页/追剧/我的统一的纯黑内容背景。
|
||||
static const appBackground = Color(0xFF000000);
|
||||
|
||||
/// 追剧页顶部的暗红光晕,向下淡入纯黑(见设计稿)。
|
||||
static const followingGlow = Color(0xFF4C161A);
|
||||
|
||||
/// 光晕的过渡色:让暖色维持更久再压黑。
|
||||
static const followingGlowMid = Color(0xFF1E0B0C);
|
||||
|
||||
/// 继续观看横幅剧名的暖白(见设计稿,非纯白)。
|
||||
static const bannerTitleWarm = Color(0xFFF5EFE4);
|
||||
|
||||
/// 中性深灰卡片与控件表面。
|
||||
static const contentCard = Color(0xFF16161A);
|
||||
static const contentSurface = Color(0xFF1C1C1E);
|
||||
@@ -96,7 +109,7 @@ abstract final class AppColors {
|
||||
static const mineGradientTop = Color(0xFF3A1B0E);
|
||||
static const mineGradientMiddle = Color(0xFF1A0C07);
|
||||
static const mineGradientBottom = Color(0xFF000000);
|
||||
static const mineGradientGlow = Color(0x33FF6B2C);
|
||||
static const mineGradientGlow = Color(0x33F04E4E);
|
||||
static const mineGridSurface = Color(0x99101010);
|
||||
static const minePanelSurface = Color(0xFF101010);
|
||||
static const mineMoreDivider = Color(0xFF1C1C1E);
|
||||
@@ -111,7 +124,7 @@ abstract final class AppColors {
|
||||
static const mineIconLanguage = Color(0xFF22D3EE);
|
||||
static const mineIconSettings = Color(0xFFA1A1AA);
|
||||
static const mineIconNotices = Color(0xFFFF8A4C);
|
||||
static const recommendPlaylistGlow = Color(0x26FF6B2C);
|
||||
static const recommendPlaylistGlow = Color(0x26F04E4E);
|
||||
|
||||
/// 次级文字与列表 chevron。
|
||||
static const textPrimary = Color(0xFFFFFFFF);
|
||||
@@ -136,6 +149,7 @@ abstract final class AppColors {
|
||||
/// 底部导航专用:纯黑背景、选中品牌红与未选中白。
|
||||
static const navBackground = Color(0xFF000000);
|
||||
static const brandRed = Color(0xFFF04E4E);
|
||||
static const brandRedPressed = Color(0xFFD64040);
|
||||
static const navSelected = brandRed;
|
||||
|
||||
/// 卡片阴影(深色几乎不用阴影)。
|
||||
@@ -214,7 +228,7 @@ abstract final class AppColors {
|
||||
static const bannerScrimEnd = Color(0xE6000000);
|
||||
|
||||
/// 票券虚线等主色描边。
|
||||
static const primaryBorder = Color(0x66FF6B2C);
|
||||
static const primaryBorder = Color(0x66F04E4E);
|
||||
|
||||
static const outlineDark = border;
|
||||
static const outlineVariantDark = border;
|
||||
|
||||
@@ -146,10 +146,12 @@ abstract final class AppDimens {
|
||||
static const double homeActionGap = 6;
|
||||
|
||||
/// 推荐分区。
|
||||
static const double recommendSectionGap = 12;
|
||||
static const double recommendSectionTitle = 17;
|
||||
static const double recommendSectionSubtitle = 11;
|
||||
static const double recommendSectionMore = 12;
|
||||
static const double recommendRowHeight = 252;
|
||||
// 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。
|
||||
static const double recommendRowHeight = 228;
|
||||
static const double recommendCardWidth = 122;
|
||||
|
||||
/// 首页热播榜。
|
||||
@@ -169,25 +171,26 @@ abstract final class AppDimens {
|
||||
|
||||
/// 全部短剧筛选与追剧双列。
|
||||
static const double filterChipFont = 16;
|
||||
static const double filterChipHeight = 32;
|
||||
static const double filterChipRadius = 16;
|
||||
static const double filterChipHeight = 28;
|
||||
static const double filterChipRadius = 14;
|
||||
static const double filterChipHorizontalPadding = 12;
|
||||
static const double filterRowGap = 6;
|
||||
static const double filterItemGap = 10;
|
||||
static const double allDramasHorizontal = 12;
|
||||
static const double allDramasGridGap = 6;
|
||||
static const double allDramasGridRowGap = 12;
|
||||
static const double allDramasGridRowGap = 6;
|
||||
static const double allDramasFilterToGridGap = 12;
|
||||
static const double followingHeaderHeight = 52;
|
||||
static const double followingTabFont = 18;
|
||||
static const double followingTabGap = 24;
|
||||
static const double followingEditIconSize = 20;
|
||||
static const double followingEditHitSize = 40;
|
||||
static const double followingAddIconSize = 28;
|
||||
static const double followingAddIconSize = 20;
|
||||
static const double followingAddIconStroke = 1.5;
|
||||
static const double followingGridGap = 6;
|
||||
static const double followingGridRowGap = 12;
|
||||
static const double followingCardTextExtent = 70;
|
||||
// 单行标题 16*1.3 + 间距 6+4 + 副标题约 16 ≈ 47。
|
||||
static const double followingCardTextExtent = 48;
|
||||
static const double followingSubtitleFont = 12;
|
||||
static const double followingSubtitleGap = 4;
|
||||
static const double followingProgressHeight = 2;
|
||||
@@ -201,6 +204,47 @@ abstract final class AppDimens {
|
||||
static const double followingHistoryProgressGap = 8;
|
||||
static const double followingHistoryLabelGap = 4;
|
||||
static const double followingHistoryActionHeight = 28;
|
||||
|
||||
/// 追剧页新版头部与收藏布局。
|
||||
static const double followingTitleFont = 26;
|
||||
static const double followingTitleRowHeight = 42;
|
||||
static const double followingTitleTopGap = 8;
|
||||
static const double followingSearchIcon = 26;
|
||||
static const double followingManageFont = 16;
|
||||
static const double followingHeaderActionGap = 8;
|
||||
static const double followingSegmentFont = 16;
|
||||
static const double followingSegmentGap = 28;
|
||||
static const double followingSegmentRowHeight = 42;
|
||||
static const double followingSegmentIndicatorWidth = 24;
|
||||
static const double followingSegmentIndicatorHeight = 3;
|
||||
static const double followingSegmentIndicatorRadius = 2;
|
||||
static const double followingSegmentIndicatorTopGap = 6;
|
||||
static const double followingBannerHeight = 176;
|
||||
static const double followingBannerRadius = 16;
|
||||
static const double followingBannerEdgeGap = 16;
|
||||
static const double followingBannerInnerGap = 16;
|
||||
static const double followingBannerTitleFont = 22;
|
||||
static const double followingBannerSubtitleFont = 13;
|
||||
static const double followingBannerMetaGap = 8;
|
||||
static const double followingBannerButtonHeight = 40;
|
||||
static const double followingBannerButtonRadius = 20;
|
||||
static const double followingBannerButtonFont = 15;
|
||||
static const double followingBannerButtonIcon = 18;
|
||||
static const double followingBannerButtonGap = 6;
|
||||
static const double followingBannerButtonHorizontalPadding = 20;
|
||||
static const double followingBannerButtonTopGap = 14;
|
||||
static const double followingBannerProgressHeight = 3;
|
||||
static const double followingSectionTitleFont = 16;
|
||||
static const double followingSectionCountFont = 13;
|
||||
static const double followingSectionTopGap = 20;
|
||||
static const double followingSectionBottomGap = 12;
|
||||
static const double followingCardTitleFontNew = 16;
|
||||
static const double followingCardSubtitleFontNew = 13;
|
||||
static const double followingBadgeFont = 11;
|
||||
static const double followingBadgeRadius = 6;
|
||||
static const double followingBadgeInset = 8;
|
||||
static const double followingBadgePaddingH = 8;
|
||||
static const double followingBadgePaddingV = 3;
|
||||
static const Duration followingTabAnimationDuration = Duration(
|
||||
milliseconds: 180,
|
||||
);
|
||||
@@ -211,8 +255,8 @@ abstract final class AppDimens {
|
||||
/// 「我的」页中性视觉。
|
||||
static const double mineCardRadius = 16;
|
||||
static const double mineSectionTitle = 15;
|
||||
static const double mineAvatar = 56;
|
||||
static const double mineUserName = 20;
|
||||
static const double mineAvatar = 64;
|
||||
static const double mineUserName = 24;
|
||||
static const double mineUserNameLineHeight = 1.15;
|
||||
static const double mineUserNameUidGap = 2;
|
||||
static const double mineUid = 12;
|
||||
@@ -227,7 +271,23 @@ abstract final class AppDimens {
|
||||
static const double mineGridDividerWidth = 1;
|
||||
static const double mineSectionTitleFont = 16;
|
||||
static const double mineSectionLinkFont = 12;
|
||||
static const double mineSectionGap = 20;
|
||||
static const double mineSectionGap = 12;
|
||||
|
||||
/// 「我的」页新版:快捷图标行与服务列表。
|
||||
static const double mineQuickActionIcon = 28;
|
||||
static const double mineQuickActionLabelFont = 14;
|
||||
static const double mineQuickActionGap = 8;
|
||||
static const double mineServiceRowHeight = 56;
|
||||
static const double mineServiceIcon = 24;
|
||||
static const double mineServiceTitleFont = 16;
|
||||
static const double mineServiceValueFont = 14;
|
||||
static const double mineVipBenefitFont = 14;
|
||||
static const double mineVipBenefitIcon = 20;
|
||||
static const double mineVipBenefitBox = 30;
|
||||
static const double mineVipBenefitBoxRadius = 7;
|
||||
static const double mineVipBenefitGlyph = 17;
|
||||
static const double mineVipBenefitRowGap = 4;
|
||||
static const double mineVipButtonHeight = 36;
|
||||
static const double mineContinueCardWidth = 96;
|
||||
static const double mineContinueRowHeight = 172;
|
||||
static const double mineContinueTitleFont = 13;
|
||||
|
||||
@@ -40,7 +40,7 @@ abstract final class AppTheme {
|
||||
/// 全局深色主题。
|
||||
static ThemeData dark() {
|
||||
const scheme = ColorScheme.dark(
|
||||
primary: AppColors.blue,
|
||||
primary: AppColors.brandRed,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.ink,
|
||||
@@ -54,7 +54,7 @@ abstract final class AppTheme {
|
||||
outline: AppColors.border,
|
||||
outlineVariant: AppColors.border,
|
||||
surfaceTint: AppColors.transparent,
|
||||
inversePrimary: AppColors.bluePressed,
|
||||
inversePrimary: AppColors.brandRedPressed,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onPrimary,
|
||||
);
|
||||
@@ -66,16 +66,16 @@ abstract final class AppTheme {
|
||||
canvasColor: AppColors.ink,
|
||||
fontFamily: 'PlusJakartaSans',
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: AppColors.blue,
|
||||
color: AppColors.brandRed,
|
||||
),
|
||||
sliderTheme: const SliderThemeData(
|
||||
activeTrackColor: AppColors.blue,
|
||||
activeTrackColor: AppColors.brandRed,
|
||||
inactiveTrackColor: AppColors.border,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
),
|
||||
tabBarTheme: const TabBarThemeData(
|
||||
labelColor: AppColors.blue,
|
||||
indicatorColor: AppColors.blue,
|
||||
labelColor: AppColors.brandRed,
|
||||
indicatorColor: AppColors.brandRed,
|
||||
unselectedLabelColor: AppColors.muted,
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
@@ -138,12 +138,12 @@ abstract final class AppTheme {
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: _inputRadius,
|
||||
borderSide: const BorderSide(color: AppColors.blue),
|
||||
borderSide: const BorderSide(color: AppColors.brandRed),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: AppColors.raised,
|
||||
selectedColor: AppColors.blue.withValues(alpha: 0.22),
|
||||
selectedColor: AppColors.brandRed.withValues(alpha: 0.22),
|
||||
labelStyle: const TextStyle(color: AppColors.onSurface),
|
||||
secondaryLabelStyle: const TextStyle(color: AppColors.muted),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
@@ -216,7 +216,7 @@ abstract final class AppTheme {
|
||||
),
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(states) =>
|
||||
states.contains(WidgetState.selected) ? AppColors.blue : null,
|
||||
states.contains(WidgetState.selected) ? AppColors.brandRed : null,
|
||||
),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
@@ -273,8 +273,8 @@ abstract final class AppTheme {
|
||||
if (states.contains(WidgetState.pressed) ||
|
||||
states.contains(WidgetState.hovered) ||
|
||||
states.contains(WidgetState.focused)) {
|
||||
return AppColors.bluePressed;
|
||||
return AppColors.brandRedPressed;
|
||||
}
|
||||
return AppColors.blue;
|
||||
return AppColors.brandRed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// 主题红晕渐变容器:弹框(居中)与底部弹层(半屏)共用的外壳。
|
||||
/// 纯黑弹框统一升级为「顶部红光 → 近黑」的家族渐变。
|
||||
class AppGlowPanel extends StatelessWidget {
|
||||
const AppGlowPanel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(20)),
|
||||
this.border = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final BorderRadius borderRadius;
|
||||
final bool border;
|
||||
|
||||
static const _gradient = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.followingGlow,
|
||||
AppColors.followingGlowMid,
|
||||
Color(0xFF101012),
|
||||
],
|
||||
stops: [0, 0.22, 0.62],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: _gradient,
|
||||
borderRadius: borderRadius,
|
||||
border: border
|
||||
? Border.all(color: AppColors.contentBorder)
|
||||
: null,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题渐变确认弹框:替代 AlertDialog 的家族化外壳。
|
||||
/// 按钮沿用项目惯例:纯文字 TextButton,主操作品牌红。
|
||||
class AppGlowDialog extends StatelessWidget {
|
||||
const AppGlowDialog({
|
||||
super.key,
|
||||
this.title,
|
||||
this.content,
|
||||
this.contentChild,
|
||||
this.actions,
|
||||
this.contentTextStyle,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String? content;
|
||||
|
||||
/// 富内容(表单/滚动区),与 [content] 二选一。
|
||||
final Widget? contentChild;
|
||||
final List<Widget>? actions;
|
||||
final TextStyle? contentTextStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bodyStyle = contentTextStyle ??
|
||||
TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
return Dialog(
|
||||
backgroundColor: AppColors.transparent,
|
||||
elevation: 0,
|
||||
child: AppGlowPanel(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 22, 22, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.sectionTitle,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
if (content != null)
|
||||
Text(content!, style: bodyStyle)
|
||||
else
|
||||
?contentChild,
|
||||
if (actions != null && actions!.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < actions!.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
actions![i],
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../pages/auth/auth_view.dart';
|
||||
import '../data/services/auth_controller.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'app_glow_panel.dart';
|
||||
|
||||
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
|
||||
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
|
||||
@@ -23,11 +24,11 @@ Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final go = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
backgroundColor: AppColors.transparent,
|
||||
builder: (sheetContext) => AppGlowPanel(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
|
||||
border: false,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||
child: Column(
|
||||
@@ -71,6 +72,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../config/server_endpoint.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'app_glow_panel.dart';
|
||||
|
||||
const String _title = '设置服务地址';
|
||||
const String _save = '保存';
|
||||
@@ -72,9 +73,9 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text(_title),
|
||||
content: SingleChildScrollView(
|
||||
return AppGlowDialog(
|
||||
title: _title,
|
||||
contentChild: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'subpage_header.dart';
|
||||
|
||||
/// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。
|
||||
@@ -29,7 +28,19 @@ class PlainPageScaffold extends StatelessWidget {
|
||||
key: pageKey,
|
||||
backgroundColor: AppColors.ink,
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
|
||||
// 与首页各 tab 同族:纯黑底 + 顶部暗红光晕。
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.followingGlow,
|
||||
AppColors.followingGlowMid,
|
||||
AppColors.appBackground,
|
||||
],
|
||||
stops: [0, 0.14, 0.3],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -74,9 +74,13 @@ class RemoteImage extends StatelessWidget {
|
||||
: null);
|
||||
final cacheWidth =
|
||||
decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio);
|
||||
final cacheHeight =
|
||||
decodeHeight ??
|
||||
(inferredHeight == null
|
||||
// cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和
|
||||
// cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了
|
||||
// decodeHeight、或 fit 不保持宽高比时才限定解码高度。
|
||||
final keepsAspectRatio =
|
||||
fit == BoxFit.cover || fit == BoxFit.contain;
|
||||
final cacheHeight = decodeHeight ??
|
||||
(keepsAspectRatio || inferredHeight == null
|
||||
? null
|
||||
: resolveImageDecodePixels(inferredHeight, pixelRatio));
|
||||
return Image.network(
|
||||
|
||||
@@ -8,7 +8,7 @@ const double _height = 52;
|
||||
const double _heightWithSubtitle = 64;
|
||||
const double _horizontal = 14;
|
||||
const double _backSize = 40;
|
||||
const double _backGap = 12;
|
||||
const double _backGap = 6;
|
||||
const double _titleFontSize = 18;
|
||||
const double _subtitleFontSize = 12;
|
||||
const double _actionGap = 8;
|
||||
@@ -163,7 +163,7 @@ class AppSubpageHeader extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: _titleFontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
|
||||
Reference in new issue
Block a user