优化:导航栏功能移动到顶部

This commit is contained in:
ivan 2026-04-19 18:03:18 +08:00
parent 01362f00a5
commit 07a047f031
11 changed files with 325 additions and 282 deletions

View File

@ -162,94 +162,6 @@
}
]
},
{
"type": "frame",
"id": "NV7Kd",
"name": "tabRow",
"width": "fill_container",
"fill": "#00000000",
"gap": 14,
"padding": [
6,
0,
16,
0
],
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "douq8",
"name": "t1",
"fill": "#FFFFFF",
"content": "Dance",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "700"
},
{
"type": "text",
"id": "NKz0E",
"name": "d1",
"fill": "#FFFFFF66",
"content": "|",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
},
{
"type": "text",
"id": "AZTis",
"name": "t2",
"fill": "#FFFFFF",
"content": "Dress",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "700"
},
{
"type": "text",
"id": "Nh9EQ",
"name": "d2",
"fill": "#FFFFFF66",
"content": "|",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
},
{
"type": "text",
"id": "H8vvk",
"name": "t3",
"fill": "#FFFFFF",
"content": "Bottle",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "700"
},
{
"type": "text",
"id": "DvuJh",
"name": "d3",
"fill": "#FFFFFF66",
"content": "|",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
},
{
"type": "text",
"id": "RDOkw",
"name": "t4",
"fill": "#FFFFFF",
"content": "Photo Repair",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "3lNJT",
@ -272,8 +184,9 @@
"type": "frame",
"id": "aHMps",
"name": "createBtn",
"width": 212,
"height": 50,
"opacity": 0.88,
"width": 186,
"height": 42,
"fill": {
"type": "gradient",
"gradientType": "linear",
@ -313,10 +226,10 @@
},
"blur": 28
},
"gap": 12,
"gap": 10,
"padding": [
14,
28
11,
22
],
"justifyContent": "center",
"alignItems": "center",
@ -325,8 +238,8 @@
"type": "frame",
"id": "TAocZ",
"name": "plusCirc",
"width": 28,
"height": 28,
"width": 24,
"height": 24,
"fill": "#FFFFFF",
"cornerRadius": 20,
"stroke": {
@ -351,8 +264,8 @@
"type": "icon_font",
"id": "9PFVT",
"name": "plusIc",
"width": 14,
"height": 14,
"width": 12,
"height": 12,
"iconFontName": "plus",
"iconFontFamily": "lucide",
"fill": "#B45309"
@ -366,7 +279,7 @@
"fill": "#1C1917",
"content": "Create Now",
"fontFamily": "Inter",
"fontSize": 18,
"fontSize": 16,
"fontWeight": "800",
"letterSpacing": 0.4
}
@ -487,21 +400,27 @@
{
"type": "frame",
"id": "rULLj",
"name": "backF",
"name": "closeF",
"width": 44,
"height": 44,
"fill": "#00000000",
"fill": "#FFFFFF",
"cornerRadius": 14,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E7E5E4"
},
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "byDOX",
"width": 28,
"height": 28,
"iconFontName": "chevron-left",
"width": 26,
"height": 26,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "#404040"
"fill": "#374151"
}
]
},
@ -1077,21 +996,27 @@
{
"type": "frame",
"id": "dYND0",
"name": "backCr",
"name": "closeCr",
"width": 44,
"height": 44,
"fill": "#00000000",
"fill": "#FFFFFF",
"cornerRadius": 14,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E7E5E4"
},
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "nh3Nf",
"width": 28,
"height": 28,
"iconFontName": "chevron-left",
"width": 26,
"height": 26,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "#404040"
"fill": "#374151"
}
]
},

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'core/auth/auth_service.dart';
import 'core/funymee_route_observer.dart';
import 'core/theme/app_colors.dart';
import 'core/theme/app_theme.dart';
import 'features/shell/main_screen.dart';
@ -56,6 +57,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
title: widget.title,
debugShowCheckedModeBanner: false,
theme: buildFunyMeeTheme(),
navigatorObservers: [funymeeRouteObserver],
home: const MainScreen(),
builder: (context, child) {
return Stack(

View File

@ -70,7 +70,10 @@ class AppAuthCallbacks implements AuthServiceCallbacks {
@override
void onCommonInfoLoaded(CommonInfoResponse data) {
if (data.credits != null) UserState.setCredits(data.credits!);
if (data.avatar != null) UserState.setAvatar(data.avatar!);
if (data.avatar != null) {
final t = data.avatar!.trim();
UserState.setAvatar(t.isEmpty ? null : t);
}
if (data.userName != null) UserState.setUserName(data.userName!);
}

View File

@ -0,0 +1,5 @@
import 'package:flutter/material.dart';
/// [MaterialApp.navigatorObservers]
final RouteObserver<ModalRoute<dynamic>> funymeeRouteObserver =
RouteObserver<ModalRoute<dynamic>>();

View File

@ -24,7 +24,10 @@ class UserState {
}) {
if (userId != null) UserState.userId.value = userId;
if (credits != null) UserState.credits.value = credits;
if (avatar != null) UserState.avatar.value = avatar;
if (avatar != null) {
final t = avatar.trim();
UserState.avatar.value = t.isEmpty ? null : t;
}
if (userName != null) UserState.userName.value = userName;
}

View File

@ -127,11 +127,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
if (widget.isRootTab)
const SizedBox(width: 44)
else
PencilRoundBackButton(
onPressed: () => Navigator.of(context).pop(),
PencilRoundCloseButton(
onPressed: () => Navigator.maybePop(context),
),
Expanded(
child: Row(

View File

@ -8,12 +8,15 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
import '../../core/funymee_route_observer.dart';
import '../../core/open_purchase_store.dart';
import '../../core/user/user_state.dart';
import '../../core/video_file_cache.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
import '../generate/generate_screen.dart';
import '../history/history_screen.dart';
import '../profile/profile_screen.dart';
/// Create Now **480p ** [GenerateScreen] 480p
int _homeCostDisplay480p(ExtConfigItem? t) {
@ -64,6 +67,29 @@ class _HomeScreenState extends State<HomeScreen> {
int _lastCategoryTabBarCount = 0;
Widget _topNavIconButton({
required IconData icon,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: const Color(0x99C4C4C4),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Icon(icon, size: 20, color: const Color(0xFF374151)),
),
),
);
}
List<ExtConfigItem> _visibleExtItems(ExtConfigData? ext) =>
ext?.items.where((e) => e.isUsableOnHome).toList() ?? [];
@ -378,6 +404,21 @@ class _HomeScreenState extends State<HomeScreen> {
children: [
SizedBox(
height: 56,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_topNavIconButton(
icon: Icons.history_rounded,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const HistoryScreen(),
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -390,6 +431,20 @@ class _HomeScreenState extends State<HomeScreen> {
);
},
),
const SizedBox(width: 10),
_topNavIconButton(
icon: Icons.person_rounded,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ProfileScreen(),
),
);
},
),
],
),
),
],
),
),
@ -726,9 +781,17 @@ class _HomeItemVideoBackground extends StatefulWidget {
_HomeItemVideoBackgroundState();
}
class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground>
with WidgetsBindingObserver, RouteAware {
VideoPlayerController? _controller;
bool _failed = false;
bool _shouldActuallyPlay = false;
bool _appInForeground = true;
/// [Navigator.push] [ModalRoute.isCurrent] rebuild [RouteAware]
bool _coveredByPushedRoute = false;
ModalRoute<dynamic>? _routeAwareSubscription;
/// ExoPlayer 416UnrecognizedInputFormat
static const int _maxOpenRetries = 6;
@ -744,6 +807,9 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
ImageStream? _coverImageStream;
ImageStreamListener? _coverImageListener;
/// [build] [addPostFrameCallback]
bool _playbackGateCallbackPending = false;
String get _playUrl {
final v = widget.item.videoUrl?.trim();
if (v != null && v.isNotEmpty) return v;
@ -753,11 +819,49 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final lifecycle = WidgetsBinding.instance.lifecycleState;
_appInForeground =
lifecycle == null || lifecycle == AppLifecycleState.resumed;
if (widget.isActive) {
_preloadCoverThenStartVideo();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appInForeground = state == AppLifecycleState.resumed;
unawaited(_syncPlaybackGate());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null && route != _routeAwareSubscription) {
funymeeRouteObserver.unsubscribe(this);
funymeeRouteObserver.subscribe(this, route);
_routeAwareSubscription = route;
}
}
@override
void didPushNext() {
_coveredByPushedRoute = true;
unawaited(_syncPlaybackGate());
}
@override
void didPopNext() {
_coveredByPushedRoute = false;
unawaited(_syncPlaybackGate());
// pop [ModalRoute.isCurrent] true
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_syncPlaybackGate());
});
}
@override
void didUpdateWidget(_HomeItemVideoBackground oldWidget) {
super.didUpdateWidget(oldWidget);
@ -866,6 +970,31 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
}
}
bool _computeShouldActuallyPlay() {
if (!mounted || !widget.isActive || !_appInForeground) return false;
if (_coveredByPushedRoute) return false;
// [RouteAware.didPushNext]/[didPopNext]
// [ModalRoute.isCurrent]pop false rebuild resume
final tickerEnabled = TickerMode.valuesOf(context).enabled;
return tickerEnabled;
}
Future<void> _syncPlaybackGate() async {
final c = _controller;
if (c == null) return;
if (!c.value.isInitialized) return;
final shouldPlay = _computeShouldActuallyPlay();
if (shouldPlay == _shouldActuallyPlay) return;
_shouldActuallyPlay = shouldPlay;
try {
if (shouldPlay) {
await c.play();
} else {
await c.pause();
}
} catch (_) {}
}
/// [VideoPlayerController.file]
/// [initialize] + [play] [downloadFile] HTML/ UnrecognizedInputFormat
Future<void> _startPlaybackAsync() async {
@ -926,8 +1055,13 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
_openRetries = 0;
controller.setLooping(true);
_shouldActuallyPlay = _computeShouldActuallyPlay();
try {
if (_shouldActuallyPlay) {
await controller.play();
} else {
await controller.pause();
}
} catch (_) {
if (mounted) _scheduleRecoverFromError();
return;
@ -982,6 +1116,9 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
@override
void dispose() {
funymeeRouteObserver.unsubscribe(this);
_routeAwareSubscription = null;
WidgetsBinding.instance.removeObserver(this);
_retryTimer?.cancel();
_removeCoverListener();
_disposePlayback();
@ -1025,6 +1162,15 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
@override
Widget build(BuildContext context) {
final shouldPlayNow = _computeShouldActuallyPlay();
if (shouldPlayNow != _shouldActuallyPlay && !_playbackGateCallbackPending) {
_playbackGateCallbackPending = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_playbackGateCallbackPending = false;
if (!mounted) return;
unawaited(_syncPlaybackGate());
});
}
if (!widget.isActive) {
return SizedBox.expand(
child: Stack(

View File

@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
@ -9,7 +10,6 @@ import '../../core/ext_config_document_urls.dart';
import '../../core/user/user_state.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
import '../purchase/purchase_screen.dart';
import '../web/app_web_view_screen.dart';
import 'delete_account_flow.dart';
@ -51,21 +51,10 @@ class _ProfileScreenState extends State<ProfileScreen> {
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
child: SizedBox(
height: 56,
child: widget.isRootTab
? Center(
child: Text(
'Profile',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: PencilTheme.ink,
),
),
)
: Align(
child: Align(
alignment: Alignment.centerRight,
child: PencilRoundCloseButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.maybePop(context),
),
),
),
@ -100,12 +89,27 @@ class _ProfileScreenState extends State<ProfileScreen> {
builder: (context, url, _) {
if (url != null && url.isNotEmpty) {
return ClipOval(
child: Image.network(
url,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
width: 100,
height: 100,
errorBuilder: (_, _, _) =>
memCacheWidth: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme
.profileAvatarIcon
.withValues(alpha: 0.5),
),
),
),
errorWidget: (context, url, error) =>
_avatarFallback(),
),
);

View File

@ -1,18 +1,8 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/theme/app_colors.dart';
import '../history/history_screen.dart';
import '../home/home_screen.dart';
import '../profile/profile_screen.dart';
/// + / [Shadow]
const List<Shadow> _bottomNavItemShadows = [
Shadow(offset: Offset(0, 1), blurRadius: 3, color: Color(0x72000000)),
Shadow(offset: Offset(0, 0), blurRadius: 8, color: Color(0x40000000)),
];
/// Root shell: bottom tabs **Home**, **History**, **Profile** (English labels).
/// Root shell: home only (no bottom tab bar).
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@ -21,88 +11,10 @@ class MainScreen extends StatefulWidget {
}
class _MainScreenState extends State<MainScreen> {
int _index = 0;
@override
Widget build(BuildContext context) {
final parentNav = NavigationBarTheme.of(context);
return Scaffold(
// All tabs: body extends under the bottom bar (faint gradient + [NavigationBar]).
extendBody: true,
body: IndexedStack(
index: _index,
children: [
const HomeScreen(),
HistoryScreen(isRootTab: true, isTabSelected: _index == 1),
const ProfileScreen(isRootTab: true),
],
),
bottomNavigationBar: Stack(
alignment: Alignment.bottomCenter,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
AppColors.surface.withValues(alpha: 0.40),
AppColors.surface.withValues(alpha: 0.15),
AppColors.surface.withValues(alpha: 0),
],
stops: const [0.0, 0.48, 1.0],
),
),
),
),
NavigationBarTheme(
data: parentNav.copyWith(
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return GoogleFonts.inter(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.primary : AppColors.onSurface,
shadows: _bottomNavItemShadows,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? AppColors.primary : AppColors.onSurface,
shadows: _bottomNavItemShadows,
);
}),
),
child: NavigationBar(
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history_rounded),
label: 'History',
),
NavigationDestination(
icon: Icon(Icons.person_outline_rounded),
selectedIcon: Icon(Icons.person_rounded),
label: 'Profile',
),
],
),
),
],
),
body: const HomeScreen(),
);
}
}

View File

@ -100,18 +100,29 @@ class PencilGlassCreditsPill extends StatelessWidget {
}
}
/// bi8Au Create Now `desgin/funymee_home.pen` [aHMps]
/// bi8Au Create Now `desgin/funymee_home.pen` [aHMps]
class PencilCreateNowButton extends StatelessWidget {
const PencilCreateNowButton({super.key, required this.onPressed});
final VoidCallback onPressed;
static const double _w = 212;
static const double _h = 50;
/// `aHMps` createBtn
static const double _w = 186;
static const double _h = 42;
/// `aHMps` opacity
static const double _opacity = 0.88;
/// `aHMps` gap padding pen `padding` 22 `(42-24)/2` 24×24
static const double _gap = 10;
static const EdgeInsets _padding =
EdgeInsets.symmetric(horizontal: 22, vertical: 9);
@override
Widget build(BuildContext context) {
return Material(
return Opacity(
opacity: _opacity,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
@ -144,16 +155,51 @@ class PencilCreateNowButton extends StatelessWidget {
child: SizedBox(
width: _w,
height: _h,
child: Center(
child: Text(
child: Padding(
padding: _padding,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Color(0x99F59E0B),
width: 1.5,
),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
offset: Offset(0, 2),
blurRadius: 6,
),
],
),
child: const SizedBox(
width: 24,
height: 24,
child: Icon(
Icons.add_rounded,
size: 12,
color: Color(0xFFB45309),
),
),
),
const SizedBox(width: _gap),
Text(
'Create Now',
style: GoogleFonts.inter(
fontSize: 18,
fontSize: 16,
fontWeight: FontWeight.w800,
color: PencilTheme.stone900,
letterSpacing: 0.4,
),
),
],
),
),
),
),
),

View File

@ -2,7 +2,7 @@ name: funymee_ai
description: "FunyMee AI Application."
publish_to: 'none'
version: 1.0.14+14
version: 1.0.15+15
environment:
sdk: ^3.11.1