diff --git a/.gitignore b/.gitignore index 3820a95..76bf4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local Android signing (debug / release) +/android/key.properties +/android/debug.keystore diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1cad45d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "FunyMee", + "request": "launch", + "type": "dart" + }, + { + "name": "FunyMee (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "FunyMee (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 15417ab..14c35b1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val hasCustomDebugSigning = keystorePropertiesFile.exists() +if (hasCustomDebugSigning) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.funymeeai.app" compileSdk = flutter.compileSdkVersion @@ -30,11 +40,39 @@ android { versionName = flutter.versionName } + signingConfigs { + getByName("debug") { + if (hasCustomDebugSigning) { + val storeRel = keystoreProperties.getProperty("debug.storeFile") + ?: error("key.properties: missing debug.storeFile") + storeFile = rootProject.file(storeRel) + storePassword = keystoreProperties.getProperty("debug.storePassword") + ?: error("key.properties: missing debug.storePassword") + keyAlias = keystoreProperties.getProperty("debug.keyAlias") + ?: error("key.properties: missing debug.keyAlias") + keyPassword = keystoreProperties.getProperty("debug.keyPassword") + ?: error("key.properties: missing debug.keyPassword") + } + } + + val releaseStoreFile = keystoreProperties.getProperty("release.storeFile")?.trim() + if (!releaseStoreFile.isNullOrEmpty()) { + create("release") { + storeFile = file(releaseStoreFile) + storePassword = keystoreProperties.getProperty("release.storePassword") + ?: error("key.properties: missing release.storePassword") + keyAlias = keystoreProperties.getProperty("release.keyAlias") + ?: error("key.properties: missing release.keyAlias") + keyPassword = keystoreProperties.getProperty("release.keyPassword") + ?: error("key.properties: missing release.keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.findByName("release") + ?: signingConfigs.getByName("debug") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cd712d5..1456c3b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,10 @@ + + + + FacebookAdvertiserIDCollectionEnabled + NSCameraUsageDescription + FunyMee needs camera access to take photos for generation. + NSPhotoLibraryUsageDescription + FunyMee needs photo library access to choose images for generation. diff --git a/lib/app.dart b/lib/app.dart index 501e92c..e976897 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,6 +1,12 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'core/auth/auth_service.dart'; import 'core/theme/app_colors.dart'; +import 'core/theme/app_theme.dart'; +import 'features/shell/main_screen.dart'; class App extends StatelessWidget { const App({super.key, required this.title}); @@ -12,15 +18,110 @@ class App extends StatelessWidget { return MaterialApp( title: title, debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: AppColors.primary, - brightness: Brightness.dark, - ), - useMaterial3: true, - scaffoldBackgroundColor: AppColors.background, - ), - home: Scaffold(body: Center(child: Text(title))), + theme: buildFunyMeeTheme(), + home: const MainScreen(), + builder: (context, child) { + return Stack( + fit: StackFit.expand, + children: [ + SafeArea( + top: false, + left: false, + right: false, + bottom: false, + child: child ?? const SizedBox.shrink(), + ), + ValueListenableBuilder( + valueListenable: AuthService.isLoginComplete, + builder: (context, done, _) { + if (done) return const SizedBox.shrink(); + return const _StartupLoginOverlay(); + }, + ), + ], + ); + }, + ); + } +} + +/// 对齐 app_client:首帧即进入壳层,登录完成前遮罩 + 久等网络提示。 +class _StartupLoginOverlay extends StatefulWidget { + const _StartupLoginOverlay(); + + @override + State<_StartupLoginOverlay> createState() => _StartupLoginOverlayState(); +} + +class _StartupLoginOverlayState extends State<_StartupLoginOverlay> { + static const _longWaitAfter = Duration(seconds: 22); + Timer? _longWaitTimer; + bool _showNetworkHint = false; + + @override + void initState() { + super.initState(); + _longWaitTimer = Timer(_longWaitAfter, () { + if (mounted) setState(() => _showNetworkHint = true); + }); + } + + @override + void dispose() { + _longWaitTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: AbsorbPointer( + child: Container( + color: Colors.black.withValues(alpha: 0.22), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: AppColors.primary), + const SizedBox(height: 16), + Text( + 'Signing in…', + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.92), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (_showNetworkHint) ...[ + const SizedBox(height: 22), + Text( + 'Network is slow or unavailable', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.96), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + Text( + 'Check Wi‑Fi or mobile data. If the connection is fine, the server may be busy—try again shortly.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.78), + fontSize: 14, + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + ), + ), ); } } diff --git a/lib/core/app_env.dart b/lib/core/app_env.dart new file mode 100644 index 0000000..9a4757c --- /dev/null +++ b/lib/core/app_env.dart @@ -0,0 +1,9 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/foundation.dart'; + +/// `ImageApi.getMyTasks` / `getProgress` 等使用的 `app` 渠道标识。 +String currentBackendAppType() { + return defaultTargetPlatform == TargetPlatform.iOS + ? ClientBootstrap.skin.backendAppTypeIOS + : ClientBootstrap.skin.backendAppTypeAndroid; +} diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index f46dfef..a23e8ef 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -52,10 +52,12 @@ class AppAuthCallbacks implements AuthServiceCallbacks { @override void onLoginSuccess(FastLoginResponse data) { - if (data.userId != null) UserState.setUserId(data.userId!); - if (data.credits != null) UserState.setCredits(data.credits!); - if (data.avatar != null) UserState.setAvatar(data.avatar!); - if (data.userName != null) UserState.setUserName(data.userName!); + UserState.applyLogin( + userId: data.userId, + credits: data.credits, + avatar: data.avatar, + userName: data.userName, + ); } @override @@ -81,4 +83,8 @@ class AuthService { } static Future get loginComplete => FrameworkAuthService.loginComplete; + + /// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。 + static ValueNotifier get isLoginComplete => + FrameworkAuthService.isLoginComplete; } diff --git a/lib/core/ext_config_document_urls.dart b/lib/core/ext_config_document_urls.dart new file mode 100644 index 0000000..1070323 --- /dev/null +++ b/lib/core/ext_config_document_urls.dart @@ -0,0 +1,47 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; + +/// 从 `common_info.extConfig`([ExtConfigRuntime])与 `skin_config.extConfig.defaults` 解析文档类 URL。 +/// 用于用户协议、隐私政策及后续其他 H5 页面。 +abstract final class ExtConfigDocumentUrls { + ExtConfigDocumentUrls._(); + + /// 用户协议(wire 键由 skin 的 `agreementUrl` → `agreement` 等映射)。 + static String? get agreementUrl => + _resolve((d) => d.agreementUrl); + + /// 隐私政策(wire 键由 skin 的 `privacyUrl` → `privacy` 等映射)。 + static String? get privacyUrl => + _resolve((d) => d.privacyUrl); + + /// 通用:优先 [ExtConfigRuntime.data],再回退到换皮默认 [AppConfig.extConfigDefaults]。 + static String? resolve(String? Function(ExtConfigData d) pick) => _resolve(pick); + + static String? _resolve(String? Function(ExtConfigData d) pick) { + final runtime = ExtConfigRuntime.data.value; + if (runtime != null) { + final u = pick(runtime)?.trim(); + if (u != null && u.isNotEmpty) return u; + } + final skin = _fromSkinDefaults(); + if (skin != null) { + final u = pick(skin)?.trim(); + if (u != null && u.isNotEmpty) return u; + } + return null; + } + + static ExtConfigData? _fromSkinDefaults() { + final cfg = ApiClient.instance.config; + final raw = cfg.extConfigDefaults; + final schema = cfg.extConfigKeySchema; + if (raw == null || raw.isEmpty) return null; + try { + return ExtConfigData.fromJson( + Map.from(raw), + schema: schema, + ); + } catch (_) { + return null; + } + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 51dc632..66ee678 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +/// FunyMee 画板主强调色参考 §15(#c99304)。 class AppColors { - static const Color primary = Color(0xFF6366F1); - static const Color secondary = Color(0xFF8B5CF6); - static const Color background = Color(0xFF0F0F23); - static const Color surface = Color(0xFF1A1A2E); - static const Color error = Color(0xFFEF4444); - static const Color onPrimary = Colors.white; - static const Color onBackground = Colors.white; - static const Color onSurface = Colors.white; + static const Color primary = Color(0xFFC99304); + static const Color primaryDim = Color(0xFF9A7010); + static const Color secondary = Color(0xFFFFD966); + static const Color background = Color(0xFF0D0D0F); + static const Color surface = Color(0xFF1A1A1E); + static const Color surfaceVariant = Color(0xFF2A2A30); + static const Color error = Color(0xFFE85D4C); + static const Color onPrimary = Color(0xFF1A1200); + static const Color onBackground = Color(0xFFF5F0E6); + static const Color onSurface = Color(0xFFE8E3D9); + static const Color muted = Color(0xFF8A8578); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..0902112 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'app_colors.dart'; + +ThemeData buildFunyMeeTheme() { + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: AppColors.background, + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + onPrimary: AppColors.onPrimary, + secondary: AppColors.secondary, + surface: AppColors.surface, + error: AppColors.error, + onSurface: AppColors.onSurface, + ), + ); + + return base.copyWith( + textTheme: GoogleFonts.interTextTheme(base.textTheme).apply( + bodyColor: AppColors.onBackground, + displayColor: AppColors.onBackground, + ), + cardTheme: const CardThemeData(clipBehavior: Clip.antiAlias), + dialogTheme: const DialogThemeData(clipBehavior: Clip.antiAlias), + bottomSheetTheme: const BottomSheetThemeData( + clipBehavior: Clip.antiAlias, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: AppColors.onBackground, + centerTitle: true, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.surface.withValues(alpha: 0.94), + indicatorColor: AppColors.primary.withValues(alpha: 0.25), + labelTextStyle: WidgetStateProperty.all( + GoogleFonts.inter(fontSize: 12, color: AppColors.onSurface), + ), + iconTheme: WidgetStateProperty.all( + const IconThemeData(color: AppColors.onSurface), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: AppColors.surfaceVariant, + contentTextStyle: GoogleFonts.inter(color: AppColors.onSurface), + ), + ); +} diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart index 4573210..938f02b 100644 --- a/lib/core/user/user_state.dart +++ b/lib/core/user/user_state.dart @@ -1,19 +1,38 @@ +import 'package:flutter/foundation.dart'; + +/// 全局用户快照;积分等用 [ValueNotifier] 驱动 UI。 class UserState { - static String? _userId; - static int _credits = 0; - static String? _avatar; - static String? _userName; - static String? _countryCode; + UserState._(); - static String? get userId => _userId; - static int get credits => _credits; - static String? get avatar => _avatar; - static String? get userName => _userName; - static String? get countryCode => _countryCode; + static final ValueNotifier userId = ValueNotifier(null); + static final ValueNotifier credits = ValueNotifier(0); + static final ValueNotifier avatar = ValueNotifier(null); + static final ValueNotifier userName = ValueNotifier(null); + static final ValueNotifier countryCode = ValueNotifier(null); - static void setUserId(String id) => _userId = id; - static void setCredits(int credits) => _credits = credits; - static void setAvatar(String avatar) => _avatar = avatar; - static void setUserName(String name) => _userName = name; - static void setCountryCode(String code) => _countryCode = code; + static void setUserId(String? id) => userId.value = id; + static void setCredits(int v) => credits.value = v; + static void setAvatar(String? v) => avatar.value = v; + static void setUserName(String? v) => userName.value = v; + static void setCountryCode(String? v) => countryCode.value = v; + + static void applyLogin({ + String? userId, + int? credits, + String? avatar, + String? userName, + }) { + if (userId != null) UserState.userId.value = userId; + if (credits != null) UserState.credits.value = credits; + if (avatar != null) UserState.avatar.value = avatar; + if (userName != null) UserState.userName.value = userName; + } + + static void clear() { + userId.value = null; + credits.value = 0; + avatar.value = null; + userName.value = null; + countryCode.value = null; + } } diff --git a/lib/design/pencil_theme.dart b/lib/design/pencil_theme.dart new file mode 100644 index 0000000..937e8fd --- /dev/null +++ b/lib/design/pencil_theme.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +/// 自 `desgin/funymee_home.pen` 抽取的色值与尺寸(390×844 基准)。 +abstract final class PencilTheme { + /// FunyMee Home (bi8Au) 玻璃按钮填充 #c4c4c499 + static const Color homeGlassFill = Color(0x99C4C4C4); + + static const Color homeTextPrimary = Colors.white; + static const Color homeTabDivider = Color(0x66FFFFFF); + static const Color gemYellow = Color(0xFFFFD60A); + + /// Create Now 磨砂 pill + static const Color createPillFill = Color(0x4DFFFFFF); + static const Color createPlusDisc = Color(0xFFFFD60A); + + /// 黄→白渐变页(suXxr / WBRp4 / EYsUi / 5J8Po) + static const LinearGradient yellowWhitePageGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFDE047), + Color(0xFFFCFCFC), + Color(0xFFFFFFFF), + ], + stops: [0, 0.3,1], + ); + + static const Color ink = Color(0xFF171717); + static const Color inkMuted = Color(0xFF404040); + static const Color inkSoft = Color(0xFF525252); + static const Color underlineGold = Color(0xFFC99304); + static const Color stone900 = Color(0xFF1C1917); + static const Color stone600 = Color(0xFF57534E); + static const Color stone700 = Color(0xFF44403C); + + static const Color expiryBg = Color(0xFFFFFBEB); + static const Color expiryBorder = Color(0xFFFDE68A); + static const Color expiryHead = Color(0xFF92400E); + static const Color expiryBody = Color(0xFF78350F); + + static const Color cardThumbBg = Color(0xFFF5F5F4); + static const Color downloadPillBorder = Color(0xFFD4D4D4); + static const Color downloadPillInk = Color(0xFF171717); + + static const Color profileAvatarRing = Color(0xFFFBBF24); + static const Color profileAvatarIcon = Color(0xFFCA8A04); + static const Color profileCredits = Color(0xFFB45309); + + static const Color genHintBorder = Color(0xFFFDE68A); + static const Color genHintTitle = Color(0xFF44403C); + static const Color genSlotBorder = Color(0xFFF5D08A); + static const Color genNavBackStroke = Color(0xFFE7E5E4); + + /// 设计宽度用于按比例缩放(可选)。 + static const double designWidth = 390; +} diff --git a/lib/features/generate/generate_progress_screen.dart b/lib/features/generate/generate_progress_screen.dart new file mode 100644 index 0000000..3abc4d8 --- /dev/null +++ b/lib/features/generate/generate_progress_screen.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../core/app_env.dart'; +import '../../core/user/user_state.dart'; +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; +import 'generate_result_screen.dart'; + +/// `YoZaK` 生成中 — 与 EYsUi 同款顶栏结构,标题「生成中」。 +class GenerateProgressScreen extends StatefulWidget { + const GenerateProgressScreen({ + super.key, + required this.taskId, + this.localPreviewPath, + }); + + final String taskId; + final String? localPreviewPath; + + @override + State createState() => _GenerateProgressScreenState(); +} + +class _GenerateProgressScreenState extends State { + Timer? _timer; + String _status = ''; + int _progress = 0; + String? _resultUrl; + String? _error; + bool _finished = false; + + @override + void initState() { + super.initState(); + _poll(); + _timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll()); + } + + Future _poll() async { + if (_error != null || _finished) return; + final uid = UserState.userId.value; + final res = await ImageApi.getProgress( + app: currentBackendAppType(), + taskId: widget.taskId, + userId: uid, + ); + if (!mounted) return; + if (!res.isSuccess || res.data == null) { + setState(() => _error = res.msg.isNotEmpty ? res.msg : 'Progress error'); + return; + } + final p = res.data!; + setState(() { + _status = p.status ?? ''; + _progress = p.progress ?? 0; + _resultUrl = p.resultUrl; + }); + + if (_isTerminal(_status) || _hasUsableResult(_resultUrl)) { + _timer?.cancel(); + if (_isSuccess(_status) || _hasUsableResult(_resultUrl)) { + _finished = true; + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => GenerateResultScreen( + taskId: widget.taskId, + resultUrl: _resultUrl ?? '', + ), + ), + ); + } else if (_isFailure(_status)) { + setState(() => _error ??= 'Task failed ($_status)'); + } + } + } + + bool _hasUsableResult(String? url) { + if (url == null || url.isEmpty) return false; + return url.startsWith('http://') || url.startsWith('https://'); + } + + bool _isTerminal(String s) { + final t = s.toLowerCase(); + return t == 'success' || + t == 'completed' || + t == 'complete' || + t == 'failed' || + t == 'failure' || + t == 'error' || + t == 'cancelled' || + t == 'canceled'; + } + + bool _isSuccess(String s) { + final t = s.toLowerCase(); + return t == 'success' || t == 'completed' || t == 'complete'; + } + + bool _isFailure(String s) { + final t = s.toLowerCase(); + return t == 'failed' || + t == 'failure' || + t == 'error' || + t == 'cancelled' || + t == 'canceled'; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + child: SizedBox( + height: 56, + child: Row( + children: [ + PencilRoundBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Center( + child: Text( + 'Generating', + style: GoogleFonts.inter( + fontSize: 19, + fontWeight: FontWeight.w700, + fontStyle: FontStyle.italic, + color: PencilTheme.stone900, + ), + ), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + if (widget.localPreviewPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 1, + child: Image.file( + File(widget.localPreviewPath!), + fit: BoxFit.cover, + ), + ), + ) + else + const SizedBox(height: 48), + const SizedBox(height: 24), + if (_error != null) + Text( + _error!, + style: GoogleFonts.inter(color: Colors.red), + textAlign: TextAlign.center, + ) + else ...[ + LinearProgressIndicator( + value: _progress > 0 ? _progress / 100 : null, + color: PencilTheme.underlineGold, + ), + const SizedBox(height: 16), + Text( + _status.isEmpty ? 'Processing…' : _status, + style: GoogleFonts.inter(color: PencilTheme.stone600), + ), + Text( + '$_progress%', + style: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/generate/generate_result_screen.dart b/lib/features/generate/generate_result_screen.dart new file mode 100644 index 0000000..076ed65 --- /dev/null +++ b/lib/features/generate/generate_result_screen.dart @@ -0,0 +1,109 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; +import '../report/report_screen.dart'; + +class GenerateResultScreen extends StatelessWidget { + const GenerateResultScreen({ + super.key, + required this.taskId, + required this.resultUrl, + }); + + final String taskId; + final String resultUrl; + + bool get _hasUrl => + resultUrl.startsWith('http://') || resultUrl.startsWith('https://'); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + child: SizedBox( + height: 56, + child: Row( + children: [ + PencilRoundBackButton( + onPressed: () { + Navigator.of(context) + .popUntil((r) => r.isFirst); + }, + ), + Expanded( + child: Center( + child: Text( + 'Done', + style: GoogleFonts.inter( + fontSize: 19, + fontWeight: FontWeight.w700, + fontStyle: FontStyle.italic, + color: PencilTheme.stone900, + ), + ), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + if (_hasUrl) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AspectRatio( + aspectRatio: 3 / 4, + child: CachedNetworkImage( + imageUrl: resultUrl, + fit: BoxFit.cover, + progressIndicatorBuilder: (_, _, _) => const Center( + child: CircularProgressIndicator(), + ), + errorWidget: (_, _, _) => + const Icon(Icons.broken_image), + ), + ), + ) + else + Text( + 'The result is not ready yet. Check History later.\nTask: $taskId', + style: GoogleFonts.inter(color: PencilTheme.stone600), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReportScreen(taskId: taskId), + ), + ); + }, + icon: const Icon(Icons.flag_outlined), + label: const Text('Report / feedback'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/generate/generate_screen.dart b/lib/features/generate/generate_screen.dart new file mode 100644 index 0000000..497d40c --- /dev/null +++ b/lib/features/generate/generate_screen.dart @@ -0,0 +1,671 @@ +import 'dart:io'; + +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'; +import 'package:image_picker/image_picker.dart'; + +import '../../core/app_env.dart'; +import '../../core/user/user_state.dart'; +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; +import 'generate_progress_screen.dart'; + +/// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。 +/// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。 +/// [template] 来自首页选中的 [ExtConfigItem]。 +class GenerateScreen extends StatefulWidget { + const GenerateScreen({super.key, this.template}); + + final ExtConfigItem? template; + + @override + State createState() => _GenerateScreenState(); +} + +class _GenerateScreenState extends State { + final _picker = ImagePicker(); + File? _picked; + File? _picked2; + String _heatmap = '720p'; + bool _busy = false; + + static const double _slotW = 112; + static const double _slotH = 108; + static const double _previewH = 359; + static const double _previewOuterR = 20; + static const double _previewBorderW = 1.5; + + File? get _primaryFile => _picked ?? _picked2; + + /// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。 + bool get _needTwoImages => widget.template?.imgNeed == 2; + + Future _pickSlot(int slot) async { + if (!mounted) return; + final source = await _showPickImageSourceSheet(context); + if (source == null || !mounted) return; + final x = await _picker.pickImage( + source: source, + imageQuality: 92, + ); + if (x == null || !mounted) return; + setState(() { + if (slot == 0) { + _picked = File(x.path); + } else { + _picked2 = File(x.path); + } + }); + } + + Future _showPickImageSourceSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: Color(0x20000000), + blurRadius: 16, + offset: Offset(0, -4), + ), + ], + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: PencilTheme.genNavBackStroke, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + ListTile( + leading: Icon( + Icons.camera_alt_outlined, + color: PencilTheme.profileAvatarIcon, + ), + title: Text( + 'Camera', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: PencilTheme.stone900, + ), + ), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ListTile( + leading: Icon( + Icons.photo_library_outlined, + color: PencilTheme.profileAvatarIcon, + ), + title: Text( + 'Photo library', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: PencilTheme.stone900, + ), + ), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + const Divider(height: 1), + ListTile( + title: Text( + 'Cancel', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + ), + ), + onTap: () => Navigator.pop(ctx), + ), + ], + ), + ), + ); + }, + ); + } + + Future _start() async { + final uid = UserState.userId.value; + if (uid == null || uid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please sign in first.')), + ); + return; + } + if (_needTwoImages) { + if (_picked == null || _picked2 == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select two images first.')), + ); + return; + } + } else { + final file = _primaryFile; + if (file == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select an image first.')), + ); + return; + } + } + + setState(() => _busy = true); + try { + final ImagePresignedUploadCreateTaskResult result; + if (_needTwoImages) { + result = await ImagePresignedUploadCreateTaskFlow.runTwoSourceFiles( + sourceFile1: _picked!, + sourceFile2: _picked2!, + userId: uid, + heatmap: _heatmap, + cipher: '', + compressFirst: true, + compressOptions: const CompressImageForUploadOptions( + maxSide: 1024, + jpegQuality: 75, + ), + saveLocalUploadCover: true, + ); + } else { + result = await ImagePresignedUploadCreateTaskFlow.run( + sourceFile: _primaryFile!, + userId: uid, + heatmap: _heatmap, + cipher: '', + compressFirst: true, + compressOptions: const CompressImageForUploadOptions( + maxSide: 1024, + jpegQuality: 75, + ), + saveLocalUploadCover: true, + ); + } + + final taskId = result.createResponse.taskId; + if (taskId == null || taskId.isEmpty) { + throw StateError('No task id'); + } + + await UserAccountRefresh.fetchAndNotify( + app: currentBackendAppType(), + userId: uid, + onAccount: (a) { + if (a.credits != null) UserState.setCredits(a.credits!); + }, + ); + + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => GenerateProgressScreen( + taskId: taskId, + localPreviewPath: result.fileUsedForUpload.path, + ), + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$e')), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + int get _estimatedCost { + final c = widget.template?.cost ?? 0; + return c > 0 ? c : 20; + } + + @override + Widget build(BuildContext context) { + final credits = UserState.credits.value; + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + child: SizedBox( + height: 56, + child: Row( + children: [ + PencilRoundBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Center( + child: Text( + 'Generate', + style: GoogleFonts.inter( + fontSize: 19, + fontWeight: FontWeight.w700, + fontStyle: FontStyle.italic, + color: PencilTheme.stone900, + ), + ), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _hintBox(), + ), + const SizedBox(height: 8), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _needTwoImages ? _leftTwoSlotsColumn() : _leftSingleSlotColumn(), + const SizedBox(width: 14), + _equalsColumn(), + const SizedBox(width: 14), + Expanded(child: _effectPreviewPanel()), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _resoChip( + '480p', + _heatmap == '480p', + () => setState(() => _heatmap = '480p'), + ), + const SizedBox(width: 12), + _resoChip( + '720p', + _heatmap == '720p', + () => setState(() => _heatmap = '720p'), + ), + ], + ), + const SizedBox(height: 12), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: PencilTheme.underlineGold, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(54), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _busy ? null : _start, + child: _busy + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + 'Start', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Est. cost · $_estimatedCost credits', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + ), + ), + const SizedBox(height: 8), + Text( + 'Balance · ${credits.toStringAsFixed(2)}', + style: GoogleFonts.inter( + fontSize: 12, + color: PencilTheme.inkSoft, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + /// 设计稿 Lcol:114 宽,双槽 112×108,中间 32 高加号。 + Widget _leftTwoSlotsColumn() { + return SizedBox( + width: 114, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _imageSlot(slotIndex: 0, label: 'Image 1'), + const SizedBox(height: 10), + SizedBox( + height: 32, + child: Center( + child: Icon( + Icons.add, + size: 24, + color: PencilTheme.profileCredits, + ), + ), + ), + const SizedBox(height: 10), + _imageSlot(slotIndex: 1, label: 'Image 2'), + ], + ), + ); + } + + /// 单图:仅 `img_need != 2` 或视频菜单开启时;槽位尺寸与设计一致。 + Widget _leftSingleSlotColumn() { + return SizedBox( + width: 114, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _imageSlot(slotIndex: 0, label: 'Image 1'), + ], + ), + ); + } + + Widget _equalsColumn() { + return SizedBox( + width: 32, + child: Center( + child: Text( + '=', + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w800, + color: PencilTheme.profileCredits, + ), + ), + ), + ); + } + + /// 设计稿 prevBx:高 359,圆角 20,描边 #FBBF24。 + /// 描边叠在图片之上(否则子组件会先盖住 decoration 内缘的线)。 + Widget _effectPreviewPanel() { + final t = widget.template; + final url = t?.image.trim() ?? ''; + final fix = t?.imageFix?.trim(); + + final innerR = _previewOuterR - _previewBorderW; + + return SizedBox( + height: _previewH, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_previewOuterR), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + clipBehavior: Clip.none, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(innerR), + child: _buildPreviewImageLayer(url, fix), + ), + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_previewOuterR), + border: Border.all( + color: PencilTheme.profileAvatarRing, + width: _previewBorderW, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPreviewImageLayer(String url, String? fix) { + if (url.isNotEmpty) { + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + width: double.infinity, + height: _previewH, + placeholder: (_, _) => _previewPlaceholder(loading: true), + errorWidget: (_, _, _) { + if (fix != null && fix.isNotEmpty) { + return CachedNetworkImage( + imageUrl: fix, + fit: BoxFit.cover, + width: double.infinity, + height: _previewH, + errorWidget: (_, _, _) => _previewPlaceholder(), + ); + } + return _previewPlaceholder(); + }, + ); + } + if (fix != null && fix.isNotEmpty) { + return CachedNetworkImage( + imageUrl: fix, + fit: BoxFit.cover, + width: double.infinity, + height: _previewH, + errorWidget: (_, _, _) => _previewPlaceholder(), + ); + } + return _previewPlaceholder(); + } + + Widget _previewPlaceholder({bool loading = false}) { + return Container( + color: PencilTheme.cardThumbBg, + alignment: Alignment.center, + child: loading + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + color: PencilTheme.profileAvatarIcon.withValues(alpha: 0.8), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome_outlined, + size: 40, + color: PencilTheme.stone600.withValues(alpha: 0.5), + ), + const SizedBox(height: 8), + Text( + 'Preview', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: PencilTheme.stone600.withValues(alpha: 0.75), + ), + ), + ], + ), + ); + } + + Widget _imageSlot({required int slotIndex, required String label}) { + final file = slotIndex == 0 ? _picked : _picked2; + + return SizedBox( + width: _slotW, + height: _slotH, + child: Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide( + color: PencilTheme.genSlotBorder, + width: 1.5, + ), + ), + child: InkWell( + onTap: () => _pickSlot(slotIndex), + borderRadius: BorderRadius.circular(16), + child: file == null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + color: PencilTheme.profileAvatarIcon, + size: 26, + ), + const SizedBox(height: 6), + Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: PencilTheme.stone600, + ), + ), + ], + ) + : ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.file(file, fit: BoxFit.cover), + ), + ), + ), + ); + } + + Widget _hintBox() { + return Container( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: PencilTheme.genHintBorder), + boxShadow: [ + BoxShadow( + color: const Color(0x30CA8A04), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.auto_awesome, + size: 18, color: PencilTheme.profileAvatarIcon), + const SizedBox(width: 8), + Text( + 'Upload tips', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: PencilTheme.genHintTitle, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Upload JPG or PNG (≤ 5 MB each, up to 2). You can use the camera or photo library. Use clear, front-facing photos when possible.', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + height: 1.45, + color: PencilTheme.stone600, + ), + ), + ], + ), + ); + } + + Widget _resoChip(String t, bool on, VoidCallback fn) { + return Material( + color: on ? PencilTheme.underlineGold.withValues(alpha: 0.2) : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: on ? PencilTheme.underlineGold : PencilTheme.genNavBackStroke, + ), + ), + child: InkWell( + onTap: fn, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Text( + t, + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: PencilTheme.stone900, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/history/credit_record_tab.dart b/lib/features/history/credit_record_tab.dart new file mode 100644 index 0000000..6475ef9 --- /dev/null +++ b/lib/features/history/credit_record_tab.dart @@ -0,0 +1,124 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +import '../../design/pencil_theme.dart'; + +/// WBRp4「Credit Record」内容区样式。 +class CreditRecordTab extends StatefulWidget { + const CreditRecordTab({super.key}); + + @override + State createState() => _CreditRecordTabState(); +} + +class _CreditRecordTabState extends State { + bool _loading = true; + String? _error; + List _records = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + final res = await UserApi.getCreditsPage(page: '1', size: '30', type: '1'); + if (!mounted) return; + if (!res.isSuccess || res.data == null) { + setState(() { + _loading = false; + _error = res.msg.isNotEmpty ? res.msg : 'Failed'; + }); + return; + } + setState(() { + _loading = false; + _records = res.data!.records ?? []; + }); + } + + String _formatTime(int? t) { + if (t == null) return '—'; + var ms = t; + if (t < 2000000000) ms = t * 1000; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + return DateFormat('yyyy-MM-dd HH:mm').format(dt); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + TextButton(onPressed: _load, child: const Text('Retry')), + ], + ), + ); + } + if (_records.isEmpty) { + return Center( + child: Text('No records.', + style: GoogleFonts.inter(color: PencilTheme.inkSoft)), + ); + } + return RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 28), + itemCount: _records.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) { + final r = _records[i]; + final c = r.credits ?? 0; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PencilTheme.genHintBorder), + boxShadow: [ + BoxShadow( + color: const Color(0x30CA8A04), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${c > 0 ? '+' : ''}$c credits', + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + ), + ), + Text( + _formatTime(r.createTime), + style: GoogleFonts.inter( + fontSize: 13, + color: PencilTheme.stone600, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart new file mode 100644 index 0000000..8aab737 --- /dev/null +++ b/lib/features/history/history_screen.dart @@ -0,0 +1,234 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../core/app_env.dart'; +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; +import 'credit_record_tab.dart'; +import 'widgets/history_grid_card.dart'; + +/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。 +class HistoryScreen extends StatefulWidget { + const HistoryScreen({super.key}); + + @override + State createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + int _tab = 0; + bool _loading = true; + String? _error; + List _items = []; + Map _localCovers = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + final res = await ImageApi.getMyTasks( + app: currentBackendAppType(), + page: '1', + pageSize: '30', + ); + if (!mounted) return; + if (!res.isSuccess || res.data == null) { + setState(() { + _loading = false; + _error = res.msg.isNotEmpty ? res.msg : 'Failed to load'; + }); + return; + } + final tasks = res.data!.tasks ?? []; + final locals = await ImageTaskHistory.localCoverPathsForMyTaskItems(tasks); + setState(() { + _loading = false; + _items = tasks; + _localCovers = locals; + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: SizedBox( + height: 58, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + PencilRoundBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _headerTab('My History', 0), + const SizedBox(width: 26), + _headerTab('Credit Record', 1), + ], + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + ), + Expanded( + child: _tab == 0 ? _myHistoryBody() : const CreditRecordTab(), + ), + ], + ), + ), + ), + ); + } + + Widget _headerTab(String label, int index) { + final selected = _tab == index; + return InkWell( + onTap: () => setState(() => _tab = index), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + fontStyle: FontStyle.italic, + color: selected ? PencilTheme.ink : PencilTheme.inkSoft, + ), + ), + const SizedBox(height: 8), + if (selected) + Container( + width: 50, + height: 4, + decoration: BoxDecoration( + color: PencilTheme.underlineGold, + borderRadius: BorderRadius.circular(2), + ), + ) + else + const SizedBox(height: 4), + ], + ), + ); + } + + Widget _myHistoryBody() { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + TextButton(onPressed: _load, child: const Text('Retry')), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _load, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + sliver: SliverToBoxAdapter(child: _expiryNotice()), + ), + if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Text('No tasks yet.', + style: GoogleFonts.inter(color: PencilTheme.inkSoft)), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 28), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 14, + crossAxisSpacing: 14, + childAspectRatio: 171 / 182, + ), + delegate: SliverChildBuilderDelegate( + (context, i) { + final t = _items[i]; + final id = t.taskId ?? ''; + return HistoryGridCard( + item: t, + localCoverPath: + id.isEmpty ? null : _localCovers[id], + onDownload: () {}, + ); + }, + childCount: _items.length, + ), + ), + ), + ], + ), + ); + } + + Widget _expiryNotice() { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: PencilTheme.expiryBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: PencilTheme.expiryBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '24-hour expiry', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: PencilTheme.expiryHead, + ), + ), + const SizedBox(height: 4), + Text( + 'Each item is kept for 24 hours after creation. Download before it expires.', + style: GoogleFonts.inter( + fontSize: 11, + height: 1.35, + color: PencilTheme.expiryBody, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/history/widgets/history_grid_card.dart b/lib/features/history/widgets/history_grid_card.dart new file mode 100644 index 0000000..4305060 --- /dev/null +++ b/lib/features/history/widgets/history_grid_card.dart @@ -0,0 +1,142 @@ +import 'dart:io'; + +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'; + +import '../../../design/pencil_theme.dart'; + +/// WBRp4 单张卡片:171×182 比例,圆角 20,Download pill。 +class HistoryGridCard extends StatelessWidget { + const HistoryGridCard({ + super.key, + required this.item, + this.localCoverPath, + this.onDownload, + }); + + final MyTaskItem item; + final String? localCoverPath; + final VoidCallback? onDownload; + + @override + Widget build(BuildContext context) { + final url = item.resultUrl?.trim() ?? ''; + final created = item.createTime ?? '—'; + final remainder = _remainderLabel(item.createTime); + + return LayoutBuilder( + builder: (context, c) { + final w = c.maxWidth; + final h = w * (182 / 171); + return SizedBox( + width: w, + height: h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + width: w, + height: h, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: url.isNotEmpty + ? CachedNetworkImage(imageUrl: url, fit: BoxFit.cover) + : localCoverPath != null + ? Image.file(File(localCoverPath!), fit: BoxFit.cover) + : Container(color: PencilTheme.cardThumbBg), + ), + ), + Positioned( + left: 8, + top: 10, + right: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + created, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + color: PencilTheme.inkMuted, + ), + ), + Text( + remainder, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: PencilTheme.underlineGold, + ), + ), + ], + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + side: const BorderSide(color: PencilTheme.downloadPillBorder), + ), + child: InkWell( + onTap: onDownload, + borderRadius: BorderRadius.circular(21), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Download', + style: TextStyle( + fontFamily: 'BonheurRoyale', + fontSize: 12, + color: PencilTheme.downloadPillInk, + ), + ), + const SizedBox(width: 4), + Icon(Icons.download_rounded, + size: 10, color: PencilTheme.downloadPillInk), + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +String _remainderLabel(String? createTimeRaw) { + if (createTimeRaw == null || createTimeRaw.isEmpty) return '—'; + DateTime? created; + final asInt = int.tryParse(createTimeRaw); + if (asInt != null) { + var ms = asInt; + if (asInt < 2000000000) ms = asInt * 1000; + created = DateTime.fromMillisecondsSinceEpoch(ms); + } else { + created = DateTime.tryParse(createTimeRaw); + } + if (created == null) return '—'; + final deadline = created.add(const Duration(hours: 24)); + final left = deadline.difference(DateTime.now()); + if (left.isNegative) return 'Expired'; + final h = left.inHours; + final m = left.inMinutes.remainder(60); + return '${h}h ${m.toString().padLeft(2, '0')}m left'; +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart new file mode 100644 index 0000000..b46683f --- /dev/null +++ b/lib/features/home/home_screen.dart @@ -0,0 +1,1181 @@ +import 'dart:async'; + +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:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:video_player/video_player.dart'; + +import '../../core/auth/auth_service.dart'; +import '../../core/user/user_state.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'; + +/// 首页横向 [PageView] 的一页:对应某个顶部分类 [tabIndex],[item] 为空表示该分类暂无模板占位。 +class _FlatHomePage { + const _FlatHomePage({required this.tabIndex, this.item}); + + final int tabIndex; + final ExtConfigItem? item; +} + +/// `bi8Au` FunyMee Home — 底层 `assets/images/home_background.png`,模板图为全屏 [PageView] 叠在其上。 +/// - `go_run`([ExtConfigData.showVideoMenu])为 `true` 时:顶栏为**分类** Tab;多分类时用**单层** [PageView], +/// 将各分类模板**展平**为连续页,横滑可连贯切换分类(整页动画),避免嵌套横向 PageView 的手势冲突。 +/// - 非视频模式:[ExtConfigData.items] 每一项对应一页。 +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + /// 展平后 [PageView] 的当前页下标(跨分类连续)。 + int _selectedIndex = 0; + final PageController _pageController = PageController(); + + final ScrollController _categoryTabScrollController = ScrollController(); + + /// 顶部分类 Tab 每项的 key,用于 [Scrollable.ensureVisible] 把选中项滚到中间。 + final List _categoryTabItemKeys = []; + + /// 上次已尝试居中的分类下标,避免重复调度。 + int _lastCenteredCategoryTabIndex = -1; + + int _lastCategoryTabBarCount = 0; + + List _visibleExtItems(ExtConfigData? ext) => + ext?.items.where((e) => e.title.trim().isNotEmpty).toList() ?? []; + + /// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items)。 + List _templateItemsForTab( + ExtConfigData? ext, + VideoHomeSnapshot video, + int tabIndex, + ) { + final base = _visibleExtItems(ext); + if (ext?.showVideoMenu != true) return base; + if (video.tabs.isEmpty) return base; + final ti = tabIndex.clamp(0, video.tabs.length - 1); + final tab = video.tabs[ti]; + if (tab.isImages) return base; + final id = tab.categoryId!; + return video.networkItemsByCategoryId[id] ?? []; + } + + /// 展平为单层 [PageView] 的页序列:多分类时连续滑动即可跨分类;空分类占一页占位。 + List<_FlatHomePage> _buildFlatPages( + ExtConfigData? ext, + VideoHomeSnapshot video, + ) { + final base = _visibleExtItems(ext); + if (ext?.showVideoMenu != true || video.tabs.isEmpty) { + if (base.isEmpty) return []; + return [for (final item in base) _FlatHomePage(tabIndex: 0, item: item)]; + } + final out = <_FlatHomePage>[]; + for (var t = 0; t < video.tabs.length; t++) { + final items = _templateItemsForTab(ext, video, t); + if (items.isEmpty) { + out.add(_FlatHomePage(tabIndex: t, item: null)); + } else { + for (final item in items) { + out.add(_FlatHomePage(tabIndex: t, item: item)); + } + } + } + return out; + } + + static int _firstFlatIndexForTab(List<_FlatHomePage> flat, int tabIndex) { + for (var i = 0; i < flat.length; i++) { + if (flat[i].tabIndex == tabIndex) return i; + } + return 0; + } + + bool _currentNetworkTabLoading(VideoHomeSnapshot video) { + if (video.tabs.isEmpty) return false; + final ti = VideoHomeRuntime.selectedTabIndex.value + .clamp(0, video.tabs.length - 1); + final tab = video.tabs[ti]; + if (tab.isImages) return false; + final id = tab.categoryId!; + return video.loadingCategoryIds.contains(id) && + !video.networkItemsByCategoryId.containsKey(id); + } + + /// `go_run` / `need_wait` → [ExtConfigData.showVideoMenu];仅在为 `true` 时展示顶部分类 Tab 栏。 + bool _showTopTabBar(ExtConfigData? ext) => ext?.showVideoMenu == true; + + void _ensureCategoryTabKeys(int count) { + while (_categoryTabItemKeys.length < count) { + _categoryTabItemKeys.add(GlobalKey()); + } + while (_categoryTabItemKeys.length > count) { + _categoryTabItemKeys.removeLast(); + } + } + + /// 将指定下标的顶部分类 Tab 尽量滚到横向列表中间(与 PageView 切换分类联动)。 + void _scrollCategoryTabToCenter(int index) { + if (index < 0 || index >= _categoryTabItemKeys.length) return; + final ctx = _categoryTabItemKeys[index].currentContext; + if (ctx == null) return; + Scrollable.ensureVisible( + ctx, + alignment: 0.5, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + } + + /// 视频模式顶部分类 Tab(与中间区内容独立,无任务时也保持显示)。 + Widget _buildCategoryTabRow(VideoHomeSnapshot video) { + final catIdx = video.tabs.isEmpty + ? 0 + : VideoHomeRuntime.selectedTabIndex.value + .clamp(0, video.tabs.length - 1); + if (video.tabs.length != _lastCategoryTabBarCount) { + _lastCategoryTabBarCount = video.tabs.length; + _lastCenteredCategoryTabIndex = -1; + } + _ensureCategoryTabKeys(video.tabs.length); + if (_lastCenteredCategoryTabIndex != catIdx) { + _lastCenteredCategoryTabIndex = catIdx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollCategoryTabToCenter(catIdx); + }); + } + return Padding( + padding: const EdgeInsets.only(top: 6, bottom: 12), + child: SingleChildScrollView( + controller: _categoryTabScrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < video.tabs.length; i++) ...[ + if (i > 0) ...[ + Text( + '|', + style: GoogleFonts.inter( + fontSize: 14, + color: PencilTheme.homeTabDivider, + ), + ), + const SizedBox(width: 14), + ], + GestureDetector( + key: _categoryTabItemKeys[i], + behavior: HitTestBehavior.opaque, + onTap: () => _onCategoryTabTap(i), + child: Text( + video.tabs[i].label, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: i == catIdx + ? PencilTheme.underlineGold + : PencilTheme.homeTextPrimary, + ), + ), + ), + if (i < video.tabs.length - 1) const SizedBox(width: 14), + ], + ], + ), + ), + ); + } + + void _clampFlatPage(int flatLength) { + if (flatLength == 0) return; + if (_selectedIndex >= flatLength) { + final next = flatLength - 1; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedIndex = next); + if (_pageController.hasClients) { + _pageController.jumpToPage(next); + } + }); + } + } + + void _onFlatPageChanged(int i, List<_FlatHomePage> flat) { + if (i < 0 || i >= flat.length) return; + final t = flat[i].tabIndex; + if (VideoHomeRuntime.selectedTabIndex.value != t) { + VideoHomeRuntime.selectedTabIndex.value = t; + unawaited(VideoHomeRuntime.ensureTabItems(t)); + } + setState(() => _selectedIndex = i); + } + + void _onCategoryTabTap(int tabIndex) { + if (VideoHomeRuntime.selectedTabIndex.value == tabIndex) return; + final ext = ExtConfigRuntime.data.value; + final video = VideoHomeRuntime.snapshot.value; + final flat = _buildFlatPages(ext, video); + if (flat.isEmpty) return; + final idx = + _firstFlatIndexForTab(flat, tabIndex).clamp(0, flat.length - 1); + VideoHomeRuntime.selectedTabIndex.value = tabIndex; + unawaited(VideoHomeRuntime.ensureTabItems(tabIndex)); + setState(() => _selectedIndex = idx); + if (_pageController.hasClients) { + _pageController.animateToPage( + idx, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + } + } + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _pageController.dispose(); + _categoryTabScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/home_background.png', + fit: BoxFit.cover, + errorBuilder: (_, _, _) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF2A2318), + Color(0xFF141210), + ], + ), + ), + ); + }, + ), + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: ExtConfigRuntime.data, + builder: (context, ext, _) { + return ValueListenableBuilder( + valueListenable: VideoHomeRuntime.snapshot, + builder: (context, video, _) { + return ValueListenableBuilder( + valueListenable: VideoHomeRuntime.selectedTabIndex, + builder: (context, _, _) { + return ValueListenableBuilder( + valueListenable: + ExtConfigRuntime.commonInfoSucceeded, + builder: (context, ok, _) { + final flat = _buildFlatPages(ext, video); + _clampFlatPage(flat.length); + if (ok == null) { + return const SizedBox.shrink(); + } + final videoTabs = + ext?.showVideoMenu == true && + video.tabs.isNotEmpty; + if (flat.isEmpty) { + if (videoTabs) { + return const ColoredBox( + color: Colors.transparent, + child: SizedBox.expand(), + ); + } + return const SizedBox.shrink(); + } + return PageView.builder( + controller: _pageController, + itemCount: flat.length, + onPageChanged: (i) { + final e = ExtConfigRuntime.data.value; + final v = VideoHomeRuntime.snapshot.value; + _onFlatPageChanged(i, _buildFlatPages(e, v)); + }, + itemBuilder: (context, i) { + final fp = flat[i]; + if (fp.item == null) { + return const ColoredBox( + color: Colors.transparent, + child: SizedBox.expand(), + ); + } + return AnimatedBuilder( + animation: _pageController, + builder: (context, _) { + final page = _pageController.hasClients + ? (_pageController.page ?? + _selectedIndex.toDouble()) + : _selectedIndex.toDouble(); + final videoActive = page.round() == i; + return _HomeItemPageContent( + item: fp.item!, + videoActive: videoActive, + ); + }, + ); + }, + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 34, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PencilGlassSquareButton( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const HistoryScreen(), + ), + ); + }, + child: const Icon(Icons.history_rounded, + color: Colors.white, size: 22), + ), + Row( + children: [ + ValueListenableBuilder( + valueListenable: UserState.credits, + builder: (_, credits, _) { + return PencilGlassCreditsPill( + amountText: credits.toStringAsFixed(2), + ); + }, + ), + const SizedBox(width: 10), + PencilGlassSquareButton( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ProfileScreen(), + ), + ); + }, + child: const Icon(Icons.settings_rounded, + color: Colors.white, size: 22), + ), + ], + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: ExtConfigRuntime.commonInfoSucceeded, + builder: (context, ok, _) { + if (ok != false) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.orangeAccent.withValues(alpha: 0.6), + ), + ), + child: Row( + children: [ + Icon(Icons.cloud_off_rounded, + color: Colors.orange.shade200, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Config failed to load. Some features may be unavailable. Check the network and restart the app.', + style: GoogleFonts.inter( + fontSize: 12, + height: 1.35, + color: Colors.white.withValues(alpha: 0.92), + ), + ), + ), + ], + ), + ), + ); + }, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: ExtConfigRuntime.data, + builder: (context, ext, _) { + return ValueListenableBuilder( + valueListenable: VideoHomeRuntime.snapshot, + builder: (context, video, _) { + return ValueListenableBuilder( + valueListenable: + VideoHomeRuntime.selectedTabIndex, + builder: (context, _, _) { + return ValueListenableBuilder( + valueListenable: + ExtConfigRuntime.commonInfoSucceeded, + builder: (context, ok, _) { + final flat = _buildFlatPages(ext, video); + _clampFlatPage(flat.length); + final showTabs = _showTopTabBar(ext); + final showCategoryTabs = + showTabs && video.tabs.isNotEmpty; + + if (ok == null) { + return ValueListenableBuilder( + valueListenable: AuthService.isLoginComplete, + builder: (context, loginDone, _) { + // [App] startup overlay already shows a spinner; hide + // this duplicate until the overlay is dismissed. + if (!loginDone) { + return const SizedBox.shrink(); + } + return Center( + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: PencilTheme.homeTextPrimary, + ), + ), + const SizedBox(width: 10), + Text( + 'Loading config…', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ], + ), + ); + }, + ); + } + + if (flat.isEmpty) { + Widget loadingRow(String text) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: PencilTheme.homeTextPrimary, + ), + ), + const SizedBox(width: 10), + Text( + text, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ], + ); + } + + if (ok == true && + ext?.showVideoMenu == true && + video.loading) { + if (showCategoryTabs) { + return Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + _buildCategoryTabRow(video), + Expanded( + child: IgnorePointer( + child: Center( + child: loadingRow( + 'Loading video categories…', + ), + ), + ), + ), + ], + ); + } + return Center( + child: loadingRow( + 'Loading video categories…', + ), + ); + } + if (ok == true && + ext?.showVideoMenu == true && + showCategoryTabs && + _currentNetworkTabLoading(video)) { + return Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + _buildCategoryTabRow(video), + Expanded( + child: IgnorePointer( + child: Center( + child: loadingRow( + 'Loading category templates…', + ), + ), + ), + ), + ], + ); + } + if (showCategoryTabs) { + return Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + _buildCategoryTabRow(video), + Expanded( + child: IgnorePointer( + child: Center( + child: Text( + ok == false + ? 'No templates (config not ready)' + : 'No templates', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ), + ), + ), + ], + ); + } + return Center( + child: Text( + ok == false + ? 'No templates (config not ready)' + : 'No templates', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showCategoryTabs) + _buildCategoryTabRow(video), + Expanded( + child: IgnorePointer( + child: SizedBox.expand(), + ), + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + Center( + child: ValueListenableBuilder( + valueListenable: ExtConfigRuntime.data, + builder: (context, ext, _) { + return ValueListenableBuilder( + valueListenable: VideoHomeRuntime.snapshot, + builder: (context, video, _) { + return AnimatedBuilder( + animation: _pageController, + builder: (context, _) { + final flat = _buildFlatPages(ext, video); + final safe = flat.isEmpty + ? 0 + : (_pageController.hasClients + ? _pageController.page + ?.round() ?? + _selectedIndex + : _selectedIndex) + .clamp(0, flat.length - 1); + final template = flat.isEmpty + ? null + : flat[safe].item; + final cost = template?.cost ?? 0; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (cost > 0) ...[ + Text( + '$cost credits', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary + .withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 12), + ], + PencilCreateNowButton( + onPressed: () { + final t = template; + if (t == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GenerateScreen( + template: t, + ), + ), + ); + }, + ), + ], + ); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// 全屏循环播放 [ExtConfigItem.videoUrl](或 `image` 为视频 URL);失败则回退为静态图。 +/// +/// [isActive] 为 false 时释放 [VideoPlayerController],仅保留封面,避免 PageView 多路解码卡死。 +class _HomeItemVideoBackground extends StatefulWidget { + const _HomeItemVideoBackground({ + required this.item, + this.isActive = true, + }); + + final ExtConfigItem item; + + /// 当前项是否在 [PageView] 可视页(与 [_pageController.page] 对齐)。 + final bool isActive; + + @override + State<_HomeItemVideoBackground> createState() => + _HomeItemVideoBackgroundState(); +} + +class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> { + VideoPlayerController? _controller; + bool _failed = false; + + /// 含 ExoPlayer 错误(416、UnrecognizedInputFormat、坏缓存等)时的自动重试次数。 + static const int _maxOpenRetries = 6; + int _openRetries = 0; + + /// 上一轮已判定磁盘缓存不可播:跳过后续 [getFileFromCache],避免反复读坏文件卡住。 + bool _forceNetworkOnly = false; + + Timer? _retryTimer; + bool _recovering = false; + + /// 封面解码完成监听(先封面后视频,避免长时间空白)。 + ImageStream? _coverImageStream; + ImageStreamListener? _coverImageListener; + + static final CacheManager _videoCacheManager = DefaultCacheManager(); + + String get _playUrl { + final v = widget.item.videoUrl?.trim(); + if (v != null && v.isNotEmpty) return v; + return widget.item.image.trim(); + } + + @override + void initState() { + super.initState(); + if (widget.isActive) { + _preloadCoverThenStartVideo(); + } + } + + @override + void didUpdateWidget(_HomeItemVideoBackground oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive != widget.isActive) { + if (!widget.isActive) { + _retryTimer?.cancel(); + _retryTimer = null; + _recovering = false; + _openRetries = 0; + _removeCoverListener(); + _disposePlayback(); + if (mounted) setState(() {}); + return; + } + _failed = false; + _forceNetworkOnly = false; + _preloadCoverThenStartVideo(); + return; + } + final oldUrl = _videoUrlForItem(oldWidget.item); + final newUrl = _videoUrlForItem(widget.item); + if (oldUrl != newUrl) { + _openRetries = 0; + _failed = false; + _recovering = false; + _forceNetworkOnly = false; + _removeCoverListener(); + _disposePlayback(); + if (widget.isActive) { + _preloadCoverThenStartVideo(); + } + } + } + + static String _videoUrlForItem(ExtConfigItem item) { + final v = item.videoUrl?.trim(); + if (v != null && v.isNotEmpty) return v; + return item.image.trim(); + } + + /// 与 [_HomeItemPageContent] 一致:主图 + 兜底图。 + static String _coverUrlForItem(ExtConfigItem item) { + final u = item.image.trim(); + if (u.isNotEmpty) return u; + return item.imageFix?.trim() ?? ''; + } + + void _removeCoverListener() { + final stream = _coverImageStream; + final listener = _coverImageListener; + if (stream != null && listener != null) { + stream.removeListener(listener); + } + _coverImageStream = null; + _coverImageListener = null; + } + + void _preloadCoverThenStartVideo() { + if (!widget.isActive) return; + _removeCoverListener(); + final coverUrl = _coverUrlForItem(widget.item); + if (coverUrl.isEmpty) { + unawaited(_startPlaybackAsync()); + return; + } + final u = Uri.tryParse(coverUrl); + if (u == null || !(u.isScheme('http') || u.isScheme('https'))) { + unawaited(_startPlaybackAsync()); + return; + } + final provider = CachedNetworkImageProvider(coverUrl); + final stream = provider.resolve(const ImageConfiguration()); + _coverImageStream = stream; + final listener = ImageStreamListener( + (ImageInfo image, bool synchronousCall) { + _removeCoverListener(); + unawaited(_startPlaybackAsync()); + }, + onError: (Object exception, StackTrace? stackTrace) { + _removeCoverListener(); + unawaited(_startPlaybackAsync()); + }, + ); + _coverImageListener = listener; + stream.addListener(listener); + } + + /// 先 pause 再 dispose,避免 ExoPlayer 已释放后仍收到 [pause]/setPlayWhenReady(dead thread)。 + static Future _pauseThenDispose(VideoPlayerController c) async { + try { + await c.pause(); + } catch (_) {} + try { + await c.dispose(); + } catch (_) {} + } + + void _disposePlayback() { + _retryTimer?.cancel(); + _retryTimer = null; + final c = _controller; + if (c != null) { + c.removeListener(_onVideoValueChanged); + _controller = null; + unawaited(_pauseThenDispose(c)); + } + } + + /// 磁盘已有有效缓存则 [VideoPlayerController.file];否则网络流式播放。 + /// 仅在 [initialize] + [play] 成功后再 [downloadFile],避免并行下载把 HTML/错包写入缓存导致 UnrecognizedInputFormat。 + Future _startPlaybackAsync() async { + if (!widget.isActive) return; + final playUrl = _playUrl; + final uri = Uri.tryParse(playUrl); + if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) { + if (mounted) setState(() => _failed = true); + return; + } + _disposePlayback(); + + VideoPlayerController? controller; + try { + if (!_forceNetworkOnly) { + final cached = await _videoCacheManager.getFileFromCache(playUrl); + if (cached != null && + cached.validTill.isAfter(DateTime.now()) && + await cached.file.exists()) { + controller = VideoPlayerController.file(cached.file); + } + } + controller ??= VideoPlayerController.networkUrl(uri); + } catch (_) { + controller = VideoPlayerController.networkUrl(uri); + } + + if (!mounted || playUrl != _playUrl || !widget.isActive) { + unawaited(_pauseThenDispose(controller)); + return; + } + + controller.addListener(_onVideoValueChanged); + _controller = controller; + + try { + await controller.initialize().timeout( + const Duration(seconds: 20), + onTimeout: () => throw TimeoutException('video init', const Duration(seconds: 20)), + ); + } catch (_) { + if (mounted) _scheduleRecoverFromError(); + return; + } + + if (!mounted || + _controller != controller || + playUrl != _playUrl || + !widget.isActive) return; + if (controller.value.hasError) { + if (mounted) _scheduleRecoverFromError(); + return; + } + + _openRetries = 0; + controller.setLooping(true); + try { + await controller.play(); + } catch (_) { + if (mounted) _scheduleRecoverFromError(); + return; + } + + if (!mounted || + _controller != controller || + playUrl != _playUrl || + !widget.isActive) return; + if (controller.value.hasError) { + if (mounted) _scheduleRecoverFromError(); + return; + } + + _forceNetworkOnly = false; + unawaited(_videoCacheManager.downloadFile(playUrl)); + setState(() {}); + } + + void _onVideoValueChanged() { + final c = _controller; + if (c == null || !mounted || _recovering) return; + if (!c.value.hasError) return; + c.removeListener(_onVideoValueChanged); + _scheduleRecoverFromError(); + } + + void _scheduleRecoverFromError() { + if (!mounted || _failed || !widget.isActive) return; + if (_recovering) return; + if (_openRetries >= _maxOpenRetries) { + setState(() => _failed = true); + _disposePlayback(); + return; + } + _recovering = true; + _openRetries += 1; + final url = _playUrl; + unawaited(_videoCacheManager.removeFile(url)); + _forceNetworkOnly = true; + _disposePlayback(); + setState(() {}); + + _retryTimer?.cancel(); + _retryTimer = Timer(const Duration(milliseconds: 220), () { + _retryTimer = null; + if (!mounted) return; + _recovering = false; + unawaited(_startPlaybackAsync()); + }); + } + + @override + void dispose() { + _retryTimer?.cancel(); + _removeCoverListener(); + _disposePlayback(); + super.dispose(); + } + + Widget _coverPlaceholder() { + return ColoredBox( + color: Colors.white.withValues(alpha: 0.12), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + color: PencilTheme.homeTextPrimary.withValues(alpha: 0.7), + ), + ), + ), + ); + } + + Widget _buildCoverLayer() { + final coverUrl = _coverUrlForItem(widget.item); + if (coverUrl.isEmpty) return _coverPlaceholder(); + final u = Uri.tryParse(coverUrl); + if (u == null || !(u.isScheme('http') || u.isScheme('https'))) { + return _coverPlaceholder(); + } + return CachedNetworkImage( + imageUrl: coverUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, _) => _coverPlaceholder(), + errorWidget: (_, _, _) => _coverPlaceholder(), + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + ); + } + + @override + Widget build(BuildContext context) { + if (!widget.isActive) { + return SizedBox.expand( + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: _buildCoverLayer()), + ], + ), + ); + } + if (_failed) { + return _HomeItemPageContent(item: widget.item, forceImage: true); + } + final c = _controller; + return SizedBox.expand( + child: LayoutBuilder( + builder: (context, constraints) { + final cw = constraints.maxWidth; + final ch = constraints.maxHeight; + if (c != null && c.value.isInitialized) { + final w = c.value.size.width; + final h = c.value.size.height; + if (w > 0 && + h > 0 && + cw.isFinite && + ch.isFinite && + cw > 0 && + ch > 0) { + // 视口固定为 cw×ch;FittedBox.cover 铺满裁切,避免 Transform.scale 只改绘制不改布局导致「画中画」。 + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: _buildCoverLayer()), + Positioned.fill( + child: ClipRect( + child: SizedBox( + width: cw, + height: ch, + child: FittedBox( + fit: BoxFit.cover, + alignment: Alignment.center, + clipBehavior: Clip.none, + child: SizedBox( + width: w, + height: h, + child: VideoPlayer(c), + ), + ), + ), + ), + ), + ], + ); + } + } + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: _buildCoverLayer()), + ], + ); + }, + ), + ); + } +} + +/// 单个 extConfig item:全屏铺满(与底层背景同尺寸),无圆角。 +class _HomeItemPageContent extends StatelessWidget { + const _HomeItemPageContent({ + super.key, + required this.item, + this.forceImage = false, + this.videoActive = true, + }); + + final ExtConfigItem item; + + /// 为 true 时跳过视频播放(用于视频解码失败回退为封面图)。 + final bool forceImage; + + /// 为 false 时不在 [PageView] 当前可视页,视频应释放解码器。 + final bool videoActive; + + @override + Widget build(BuildContext context) { + if (!forceImage && item.isVideoItem) { + return _HomeItemVideoBackground( + item: item, + isActive: videoActive, + ); + } + final imageUrl = item.image.trim(); + final fixUrl = item.imageFix?.trim(); + + if (imageUrl.isNotEmpty) { + return SizedBox.expand( + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (_, _) => Container( + color: Colors.white.withValues(alpha: 0.12), + alignment: Alignment.center, + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + color: + PencilTheme.homeTextPrimary.withValues(alpha: 0.7), + ), + ), + ), + errorWidget: (_, _, _) { + if (fixUrl != null && fixUrl.isNotEmpty) { + return CachedNetworkImage( + imageUrl: fixUrl, + fit: BoxFit.cover, + errorWidget: (_, _, _) => _placeholder(), + ); + } + return _placeholder(); + }, + ), + ); + } + if (fixUrl != null && fixUrl.isNotEmpty) { + return SizedBox.expand( + child: CachedNetworkImage( + imageUrl: fixUrl, + fit: BoxFit.cover, + errorWidget: (_, _, _) => _placeholder(), + ), + ); + } + return _placeholder(); + } + + Widget _placeholder() { + return SizedBox.expand( + child: ColoredBox( + color: Colors.white.withValues(alpha: 0.1), + child: Center( + child: Icon( + Icons.image_outlined, + size: 48, + color: PencilTheme.homeTextPrimary.withValues(alpha: 0.45), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/delete_account_flow.dart b/lib/features/profile/delete_account_flow.dart new file mode 100644 index 0000000..c25a00a --- /dev/null +++ b/lib/features/profile/delete_account_flow.dart @@ -0,0 +1,469 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../design/pencil_theme.dart'; + +/// 设计:`desgin/funymee_home.pen`「注销账户 · 步骤1」「注销账户 · 步骤2 二次验证」。 +/// 返回 `true` 表示用户在第二步确认注销(可继续调接口)。 +Future showDeleteAccountConfirmationFlow(BuildContext context) async { + while (true) { + final step1Ok = await showDialog( + context: context, + barrierDismissible: false, + barrierColor: _kScrim, + builder: (ctx) => const _DeleteAccountStep1Dialog(), + ); + if (!context.mounted) return false; + if (step1Ok != true) return false; + + final step2 = await showDialog<_DeleteStep2Result>( + context: context, + barrierDismissible: false, + barrierColor: _kScrim, + builder: (ctx) => const _DeleteAccountStep2Dialog(), + ); + if (!context.mounted) return false; + if (step2 == _DeleteStep2Result.confirmed) return true; + if (step2 == _DeleteStep2Result.backToStep1) continue; + return false; + } +} + +enum _DeleteStep2Result { backToStep1, confirmed } + +// --- 设计 token(与 .pen 一致)--- + +const _kModalWidth = 350.0; +const _kScrim = Color(0xB31C1917); +const _kModalBorder = Color(0xFFFECACA); +const _kDanger = Color(0xFFDC2626); +const _kDangerIconBg = Color(0xFFFEE2E2); +const _kTitle = PencilTheme.stone900; +const _kBody = Color(0xFF57534E); +const _kChkLabel = PencilTheme.stone700; +const _kCancelFill = Color(0xFFF5F5F4); +const _kCancelStroke = Color(0xFFE7E5E4); +const _kPillBg = Color(0xFFFEF3C7); +const _kPillInk = Color(0xFFB45309); +const _kShieldBg = Color(0xFFFFEDD5); +const _kShieldIcon = Color(0xFFC2410C); +const _kInputFill = Color(0xFFFAFAF9); +const _kInputStroke = Color(0xFFD6D3D1); +const _kHintInfo = Color(0xFF78716C); + +class _DeleteAccountStep1Dialog extends StatefulWidget { + const _DeleteAccountStep1Dialog(); + + @override + State<_DeleteAccountStep1Dialog> createState() => + _DeleteAccountStep1DialogState(); +} + +class _DeleteAccountStep1DialogState extends State<_DeleteAccountStep1Dialog> { + bool _ack = false; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kModalWidth), + child: Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _kModalBorder), + boxShadow: const [ + BoxShadow( + color: Color(0x18000000), + blurRadius: 28, + offset: Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: _kDangerIconBg, + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.warning_amber_rounded, + size: 28, + color: _kDanger, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Delete account', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w700, + color: _kTitle, + ), + ), + const SizedBox(height: 16), + Text( + 'This cannot be undone. Your account, creations, credits, and all related data will be permanently removed.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.45, + color: _kBody, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: _ack, + onChanged: (v) => + setState(() => _ack = v ?? false), + activeColor: _kDanger, + side: const BorderSide(color: _kDanger, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'I understand the consequences and accept the risk of losing my data.', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.4, + color: _kChkLabel, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _DialogSecondaryButton( + label: 'Cancel', + onTap: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DialogPrimaryButton( + label: 'Continue', + enabled: _ack, + onTap: _ack + ? () => Navigator.of(context).pop(true) + : null, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _DeleteAccountStep2Dialog extends StatefulWidget { + const _DeleteAccountStep2Dialog(); + + @override + State<_DeleteAccountStep2Dialog> createState() => + _DeleteAccountStep2DialogState(); +} + +class _DeleteAccountStep2DialogState extends State<_DeleteAccountStep2Dialog> { + static const _phrase = 'PERMANENTLY DELETE'; + + final _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _canConfirm => _controller.text == _phrase; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kModalWidth), + child: Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _kModalBorder), + boxShadow: const [ + BoxShadow( + color: Color(0x18000000), + blurRadius: 28, + offset: Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _kPillBg, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Verification', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _kPillInk, + ), + ), + ), + ), + const SizedBox(height: 14), + Center( + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _kShieldBg, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.gpp_maybe_rounded, + size: 26, + color: _kShieldIcon, + ), + ), + ), + const SizedBox(height: 14), + Text( + 'Final confirmation', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 19, + fontWeight: FontWeight.w700, + color: _kTitle, + ), + ), + const SizedBox(height: 14), + Text( + 'Type PERMANENTLY DELETE below exactly as shown. Wrong text cannot be submitted.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + height: 1.45, + color: _kBody, + ), + ), + const SizedBox(height: 14), + SizedBox( + height: 48, + child: TextField( + controller: _controller, + textAlignVertical: TextAlignVertical.center, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _kTitle, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + filled: true, + fillColor: _kInputFill, + hintText: _phrase, + hintStyle: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _kBody.withValues(alpha: 0.45), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _kInputStroke), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _kInputStroke), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _kDanger, width: 1.5), + ), + ), + ), + ), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline_rounded, + size: 16, + color: PencilTheme.underlineGold, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'The button stays disabled until the phrase matches exactly.', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.35, + color: _kHintInfo, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _DialogSecondaryButton( + label: 'Back', + onTap: () => Navigator.of(context) + .pop(_DeleteStep2Result.backToStep1), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DialogPrimaryButton( + label: 'Delete account', + enabled: _canConfirm, + onTap: _canConfirm + ? () => Navigator.of(context) + .pop(_DeleteStep2Result.confirmed) + : null, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _DialogSecondaryButton extends StatelessWidget { + const _DialogSecondaryButton({ + required this.label, + required this.onTap, + }); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: _kCancelFill, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _kCancelStroke), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _kChkLabel, + ), + ), + ), + ), + ); + } +} + +class _DialogPrimaryButton extends StatelessWidget { + const _DialogPrimaryButton({ + required this.label, + required this.enabled, + required this.onTap, + }); + + final String label; + final bool enabled; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: enabled ? _kDanger : _kDanger.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: double.infinity, + height: 48, + child: Center( + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..e3f7e05 --- /dev/null +++ b/lib/features/profile/profile_screen.dart @@ -0,0 +1,320 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../core/app_env.dart'; +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'; + +/// `5J8Po` 个人中心。 +class ProfileScreen extends StatefulWidget { + const ProfileScreen({super.key}); + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + String _version = '…'; + + @override + void initState() { + super.initState(); + PackageInfo.fromPlatform().then((p) { + if (mounted) setState(() => _version = p.version); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + child: SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerRight, + child: PencilRoundCloseButton( + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 28), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: PencilTheme.profileAvatarRing, + width: 2, + ), + ), + child: ValueListenableBuilder( + valueListenable: UserState.avatar, + builder: (context, url, _) { + if (url != null && url.isNotEmpty) { + return ClipOval( + child: Image.network( + url, + fit: BoxFit.cover, + width: 100, + height: 100, + errorBuilder: (_, _, _) => + _avatarFallback(), + ), + ); + } + return _avatarFallback(); + }, + ), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: UserState.userId, + builder: (context, id, _) { + return Text( + 'ID:${id ?? '—'}', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + ), + ); + }, + ), + const SizedBox(height: 4), + ValueListenableBuilder( + valueListenable: UserState.credits, + builder: (context, c, _) { + return Text( + 'Credits · ${_formatCredits(c)}', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: PencilTheme.profileCredits, + ), + ); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: _menuCard(context), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PurchaseScreen(), + ), + ); + }, + child: Text( + 'Buy credits', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: PencilTheme.underlineGold, + ), + ), + ), + TextButton( + onPressed: () async { + await UserAccountRefresh.fetchAndNotify( + app: currentBackendAppType(), + userId: UserState.userId.value, + onAccount: (a) { + if (a.credits != null) { + UserState.setCredits(a.credits!); + } + if (a.avatar != null) { + UserState.setAvatar(a.avatar!); + } + if (a.userName != null) { + UserState.setUserName(a.userName!); + } + }, + onFailure: (m) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(m))); + } + }, + ); + }, + child: Text( + 'Refresh', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _avatarFallback() { + return Icon(Icons.person_rounded, + size: 44, color: PencilTheme.profileAvatarIcon); + } + + String _formatCredits(int c) { + final s = c.toString(); + if (s.length <= 3) return s; + return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; + } + + void _openAppWebView( + BuildContext context, { + required String title, + required String? url, + }) { + if (url == null || url.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Link not configured')), + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AppWebViewScreen( + title: title, + initialUrl: url.trim(), + ), + ), + ); + } + + Widget _menuCard(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PencilTheme.genHintBorder), + boxShadow: [ + BoxShadow( + color: const Color(0x30CA8A04), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + children: [ + _row( + 'Terms of Service', + trailing: Icons.chevron_right_rounded, + onTap: () => _openAppWebView( + context, + title: 'Terms of Service', + url: ExtConfigDocumentUrls.agreementUrl, + ), + ), + _divider(), + _row( + 'Privacy Policy', + trailing: Icons.chevron_right_rounded, + onTap: () => _openAppWebView( + context, + title: 'Privacy Policy', + url: ExtConfigDocumentUrls.privacyUrl, + ), + ), + _divider(), + _row('Version', value: 'v$_version'), + _divider(), + _row('Delete account', + danger: true, + trailing: Icons.chevron_right_rounded, + onTap: () => _delete(context)), + ], + ), + ); + } + + Widget _row(String title, + {IconData? trailing, String? value, bool danger = false, VoidCallback? onTap}) { + return ListTile( + onTap: onTap, + title: Text( + title, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: danger ? const Color(0xFFDC2626) : PencilTheme.stone700, + ), + ), + trailing: value != null + ? Text(value, + style: GoogleFonts.inter( + fontSize: 14, + color: const Color(0xFF78716C))) + : Icon(trailing, + color: danger ? const Color(0xFFFCA292) : const Color(0xFFA8A29E)), + ); + } + + Widget _divider() => + Container(height: 1, color: const Color(0xFFF5F5F4)); + + Future _delete(BuildContext context) async { + final ok = await showDeleteAccountConfirmationFlow(context); + if (ok != true || !context.mounted) return; + final res = await UserApi.deleteAccount( + app: currentBackendAppType(), + userId: UserState.userId.value, + ); + if (!context.mounted) return; + if (res.isSuccess) { + ApiClient.instance.setUserToken(null); + UserState.clear(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Account deleted. Please restart the app and sign in again.')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(res.msg)), + ); + } + } +} diff --git a/lib/features/purchase/purchase_screen.dart b/lib/features/purchase/purchase_screen.dart new file mode 100644 index 0000000..f224139 --- /dev/null +++ b/lib/features/purchase/purchase_screen.dart @@ -0,0 +1,593 @@ +import 'dart:io'; + +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../core/app_env.dart'; +import '../../core/user/user_state.dart'; +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; + +/// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。 +/// 商品来自 [PaymentFlowCatalog.loadStoreActivities];Android 走 [NativeIapCoordinator.purchaseGooglePlay]。 +class PurchaseScreen extends StatefulWidget { + const PurchaseScreen({super.key}); + + @override + State createState() => _PurchaseScreenState(); +} + +class _PurchaseScreenState extends State { + List _products = []; + bool _loading = true; + String? _loadError; + bool _paying = false; + int? _selectedIndex; + + @override + void initState() { + super.initState(); + // Defer network + setState until after first frame so the route can paint and + // the main isolate stays responsive (avoids input ANR when opening this screen). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadProducts(isInitial: true); + }); + } + + Future _loadProducts({bool isInitial = false}) async { + if (!isInitial) { + setState(() { + _loading = true; + _loadError = null; + }); + } + final res = await PaymentFlowCatalog.loadStoreActivities(); + if (!mounted) return; + if (!res.isSuccess || res.data == null) { + setState(() { + _loading = false; + _loadError = res.msg.isNotEmpty ? res.msg : 'Failed to load products'; + _products = []; + }); + return; + } + final list = res.data!.productList ?? []; + setState(() { + _loading = false; + _products = list; + }); + } + + Future _onBuy(PaymentProductItem item, int index) async { + final uid = UserState.userId.value; + if (uid == null || uid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please sign in first.')), + ); + return; + } + final aid = item.activityId; + final pid = item.productId; + if (aid == null || aid.isEmpty || pid == null || pid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Product data is incomplete.')), + ); + return; + } + + if (!Platform.isAndroid) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'In-app purchases on iOS use the App Store flow. This build only wires Google Play on Android.', + ), + ), + ); + return; + } + + setState(() { + _paying = true; + _selectedIndex = index; + }); + + final sink = _PurchaseSink( + context: context, + onRefresh: () { + if (mounted) setState(() => _paying = false); + }, + onSuccess: () { + if (mounted) setState(() {}); + }, + ); + + await NativeIapCoordinator.purchaseGooglePlay( + sink: sink, + userId: uid, + activityId: aid, + storeProductId: pid, + createPaymentApp: currentBackendAppType(), + ); + + if (mounted) { + setState(() { + _paying = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), + child: Row( + children: [ + PencilRoundCloseButton( + onPressed: () => Navigator.of(context).pop(), + ), + const Spacer(), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 6), + child: Text( + 'Purchase Point', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'BonheurRoyale', + fontSize: 44, + height: 1.05, + color: const Color(0xFF5C3D2E), + ), + ), + ), + ValueListenableBuilder( + valueListenable: UserState.credits, + builder: (_, credits, _) { + return _CreditHeaderSection( + creditsText: credits.toStringAsFixed(2), + ); + }, + ), + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator( + color: PencilTheme.underlineGold, + ), + ) + : _loadError != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _loadError!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: PencilTheme.stone600, + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _loadProducts, + child: Text( + 'Retry', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: PencilTheme.underlineGold, + ), + ), + ), + ], + ), + ), + ) + : _products.isEmpty + ? Center( + child: Text( + 'No products available', + style: GoogleFonts.inter( + color: PencilTheme.stone600, + ), + ), + ) + : _ProductGrid( + products: _products, + paying: _paying, + selectedIndex: _selectedIndex, + onTap: _onBuy, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PurchaseSink implements PaymentSettlementSink { + _PurchaseSink({ + required this.context, + required this.onRefresh, + required this.onSuccess, + }); + + final BuildContext context; + final VoidCallback onRefresh; + final VoidCallback onSuccess; + + @override + void onPaymentSettled(PaymentSettlement settlement) { + onRefresh(); + if (!context.mounted) return; + + switch (settlement.type) { + case PaymentFlowOutcomeType.success: + UserAccountRefresh.fetchAndNotify( + app: currentBackendAppType(), + userId: UserState.userId.value, + onAccount: (a) { + if (a.credits != null) UserState.setCredits(a.credits!); + }, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settlement.message ?? 'Payment successful', + style: GoogleFonts.inter(), + ), + ), + ); + onSuccess(); + break; + case PaymentFlowOutcomeType.failure: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settlement.message ?? 'Payment failed', + style: GoogleFonts.inter(), + ), + ), + ); + break; + case PaymentFlowOutcomeType.cancelled: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settlement.message ?? 'Cancelled', + style: GoogleFonts.inter(), + ), + ), + ); + break; + case PaymentFlowOutcomeType.timeout: + case PaymentFlowOutcomeType.nativePendingHostVerification: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settlement.message ?? 'Payment pending', + style: GoogleFonts.inter(), + ), + ), + ); + break; + } + } +} + +class _CreditHeaderSection extends StatelessWidget { + const _CreditHeaderSection({required this.creditsText}); + + final String creditsText; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Explicit height: as a non-flex child above [Expanded], this Column gets + // unbounded max height; [Stack] must have bounded constraints. + Container( + width: double.infinity, + height: 120, + decoration: const BoxDecoration( + color: Color(0xFFFCE952), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(22), + topRight: Radius.circular(22), + ), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 17, + top: 11, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFAE238), + Color(0xFFF5BE5D), + ], + ), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Text( + 'Credit :', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + top: 56, + child: Text( + creditsText, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 36, + fontWeight: FontWeight.w800, + color: const Color(0xFF1F2937), + ), + ), + ), + ], + ), + ), + Image.asset( + 'assets/images/xiabiao.png', + width: 60, + height: 17, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const SizedBox(height: 17), + ), + ], + ), + ); + } +} + +class _ProductGrid extends StatelessWidget { + const _ProductGrid({ + required this.products, + required this.paying, + required this.selectedIndex, + required this.onTap, + }); + + final List products; + final bool paying; + final int? selectedIndex; + final void Function(PaymentProductItem item, int index) onTap; + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 28), + itemCount: (products.length + 1) ~/ 2, + itemBuilder: (context, row) { + final i0 = row * 2; + final i1 = i0 + 1; + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ProductCard( + item: products[i0], + index: i0, + paying: paying, + selected: selectedIndex == i0, + onTap: onTap, + ), + ), + const SizedBox(width: 15), + Expanded( + child: i1 < products.length + ? _ProductCard( + item: products[i1], + index: i1, + paying: paying, + selected: selectedIndex == i1, + onTap: onTap, + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + }, + ); + } +} + +class _ProductCard extends StatelessWidget { + const _ProductCard({ + required this.item, + required this.index, + required this.paying, + required this.selected, + required this.onTap, + }); + + final PaymentProductItem item; + final int index; + final bool paying; + final bool selected; + final void Function(PaymentProductItem item, int index) onTap; + + static final _money = RegExp(r'[\d.]+'); + + static int? _discountPercent(String? actual, String? origin) { + final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? ''); + final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? ''); + if (a == null || o == null || o <= 0 || a >= o) return null; + return ((1 - a / o) * 100).round(); + } + + @override + Widget build(BuildContext context) { + final rawTitle = item.title; + final title = (rawTitle != null && rawTitle.trim().isNotEmpty) + ? rawTitle + : 'Credit'; + final actual = item.actualAmount ?? '—'; + final origin = item.originAmount; + final bonus = item.bonus; + final pct = _discountPercent(item.actualAmount, item.originAmount); + + return Material( + color: const Color(0xE6FEE56A), + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: paying ? null : () => onTap(item, index), + borderRadius: BorderRadius.circular(10), + child: SizedBox( + height: 125, + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + ), + ), + const SizedBox(height: 8), + Text( + actual, + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w800, + color: const Color(0xFF0A0A0A), + ), + ), + if (origin != null && origin.isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0x80FFE3E3), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + origin, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFFDB6525), + decoration: TextDecoration.lineThrough, + decorationColor: const Color(0xFFDB6525), + ), + ), + ), + ], + if (bonus != null && bonus > 0) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: Text( + '+$bonus Bonus', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: PencilTheme.stone600, + ), + ), + ), + ], + ], + ), + ), + if (pct != null && pct > 0) + Positioned( + right: -4, + top: -8, + child: SizedBox( + width: 60, + height: 33, + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + 'assets/images/credit_tag.png', + fit: BoxFit.fill, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '$pct% Off', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + fontStyle: FontStyle.italic, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + if (paying && selected) + Positioned.fill( + child: Container( + alignment: Alignment.center, + color: Colors.white24, + child: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/report/report_screen.dart b/lib/features/report/report_screen.dart new file mode 100644 index 0000000..26e8cb7 --- /dev/null +++ b/lib/features/report/report_screen.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../design/pencil_theme.dart'; +import '../../widgets/pencil_chrome.dart'; + +class ReportScreen extends StatefulWidget { + const ReportScreen({super.key, required this.taskId}); + + final String taskId; + + @override + State createState() => _ReportScreenState(); +} + +class _ReportScreenState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + child: SizedBox( + height: 56, + child: Row( + children: [ + PencilRoundBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Center( + child: Text( + 'Report', + style: GoogleFonts.inter( + fontSize: 19, + fontWeight: FontWeight.w700, + fontStyle: FontStyle.italic, + color: PencilTheme.stone900, + ), + ), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Task: ${widget.taskId}', + style: GoogleFonts.inter(color: PencilTheme.stone600), + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + maxLines: 5, + decoration: InputDecoration( + hintText: 'Describe the issue…', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: PencilTheme.genNavBackStroke), + ), + ), + ), + const SizedBox(height: 24), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: PencilTheme.underlineGold, + foregroundColor: Colors.white, + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Submit wired in FeedbackApi (see §13).', + ), + ), + ); + Navigator.of(context).pop(); + }, + child: const Text('Submit'), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/shell/main_screen.dart b/lib/features/shell/main_screen.dart new file mode 100644 index 0000000..04bff35 --- /dev/null +++ b/lib/features/shell/main_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../home/home_screen.dart'; + +/// 根壳:设计稿首页无底部 Tab,子页由首页按钮/路由 [Navigator.push] 进入。 +class MainScreen extends StatelessWidget { + const MainScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const HomeScreen(); + } +} diff --git a/lib/features/web/app_web_view_screen.dart b/lib/features/web/app_web_view_screen.dart new file mode 100644 index 0000000..7778c6b --- /dev/null +++ b/lib/features/web/app_web_view_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../design/pencil_theme.dart'; + +/// 通用应用内 Web 页(任意 HTTPS/HTTP 链接),供用户协议、隐私政策及其他 H5 复用。 +class AppWebViewScreen extends StatefulWidget { + const AppWebViewScreen({ + super.key, + required this.title, + required this.initialUrl, + }); + + final String title; + + /// 完整 URL(含 `http`/`https`)。 + final String initialUrl; + + @override + State createState() => _AppWebViewScreenState(); +} + +class _AppWebViewScreenState extends State { + WebViewController? _controller; + bool _loading = true; + String? _loadError; + + @override + void initState() { + super.initState(); + final uri = _parseHttpUrl(widget.initialUrl); + if (uri == null) { + _loadError = 'Invalid link'; + _loading = false; + return; + } + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) { + if (mounted) setState(() => _loading = true); + }, + onPageFinished: (_) { + if (mounted) setState(() => _loading = false); + }, + onWebResourceError: (WebResourceError e) { + if (mounted) { + setState(() { + _loading = false; + _loadError = e.description.isNotEmpty + ? e.description + : 'Failed to load page'; + }); + } + }, + ), + ) + ..loadRequest(uri); + } + + static Uri? _parseHttpUrl(String raw) { + final u = Uri.tryParse(raw.trim()); + if (u == null || !u.hasScheme) return null; + if (u.scheme != 'http' && u.scheme != 'https') return null; + return u; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + foregroundColor: PencilTheme.stone900, + title: Text( + widget.title, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w600, + color: PencilTheme.stone900, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loadError != null && _controller == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _loadError!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 15, + color: PencilTheme.stone600, + ), + ), + ), + ); + } + final c = _controller; + if (c == null) { + return const SizedBox.shrink(); + } + return Stack( + fit: StackFit.expand, + children: [ + WebViewWidget(controller: c), + if (_loading) + const ColoredBox( + color: Color(0x0F000000), + child: Center( + child: CircularProgressIndicator( + color: PencilTheme.underlineGold, + ), + ), + ), + if (_loadError != null && _controller != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: const Color(0xFFFFF7ED), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + _loadError!, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF9A3412), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 9d9d3fb..74daf93 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:client_proxy_framework/client_proxy_framework.dart'; @@ -5,13 +7,15 @@ import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'app.dart'; import 'core/auth/auth_service.dart'; -void main() async { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); await ClientBootstrap.initFromAsset('assets/skin_config.json'); await ClientBootstrap.initAnalytics(); await AnalyticsService.initAttribution(); + await ensureDeviceMemoryProfileInitialized(); + SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, @@ -19,6 +23,7 @@ void main() async { ), ); + // 与 app_client 一致:先首帧再异步登录,由 [App] 内遮罩等待 [loginComplete] runApp(App(title: ClientBootstrap.skin.appName)); - AuthService.init(); + unawaited(AuthService.init()); } diff --git a/lib/widgets/pencil_chrome.dart b/lib/widgets/pencil_chrome.dart new file mode 100644 index 0000000..3ac68b7 --- /dev/null +++ b/lib/widgets/pencil_chrome.dart @@ -0,0 +1,182 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../design/pencil_theme.dart'; + +/// bi8Au 玻璃方钮 35×35 / blur 半径约 20。 +class PencilGlassSquareButton extends StatelessWidget { + const PencilGlassSquareButton({ + super.key, + required this.child, + required this.onTap, + this.size = 35, + this.borderRadius = 8, + }); + + final Widget child; + final VoidCallback onTap; + final double size; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Material( + color: PencilTheme.homeGlassFill, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: size, + height: size, + child: Center(child: child), + ), + ), + ), + ), + ); + } +} + +/// bi8Au 积分胶囊:竖向 padding 由外层控制,横向 padding 约 14。 +class PencilGlassCreditsPill extends StatelessWidget { + const PencilGlassCreditsPill({ + super.key, + required this.amountText, + }); + + final String amountText; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + color: PencilTheme.homeGlassFill, + height: 35, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.diamond_rounded, + size: 18, color: PencilTheme.gemYellow), + const SizedBox(width: 8), + Text( + amountText, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// bi8Au Create Now:宽 186 高 40,pill blur 28。 +class PencilCreateNowButton extends StatelessWidget { + const PencilCreateNowButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(999), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 28, sigmaY: 28), + child: Material( + color: PencilTheme.createPillFill, + child: InkWell( + onTap: onPressed, + child: SizedBox( + width: 186, + height: 40, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 25, + height: 25, + decoration: const BoxDecoration( + color: PencilTheme.createPlusDisc, + shape: BoxShape.circle, + ), + child: const Icon(Icons.add, size: 14, color: Colors.black), + ), + const SizedBox(width: 14), + Text( + 'Create Now', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: PencilTheme.homeTextPrimary, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// EYsUi / WBRp4 返回钮(无底色、无描边)。 +class PencilRoundBackButton extends StatelessWidget { + const PencilRoundBackButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: const SizedBox( + width: 44, + height: 44, + child: Icon(Icons.chevron_left_rounded, + size: 26, color: Color(0xFF374151)), + ), + ), + ); + } +} + +/// 5J8Po 关闭钮(无底色、无描边)。 +class PencilRoundCloseButton extends StatelessWidget { + const PencilRoundCloseButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: const SizedBox( + width: 44, + height: 44, + child: Icon(Icons.close_rounded, size: 24, color: Color(0xFF374151)), + ), + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..64a0ece 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..a630fa9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 26ca44d..8845aa7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,21 @@ import FlutterMacOS import Foundation import device_info_plus +import file_selector_macos import in_app_purchase_storekit +import package_info_plus import shared_preferences_foundation +import sqflite_darwin +import video_player_avfoundation +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..f206fbf --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,62 @@ +PODS: + - device_info_plus (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - in_app_purchase_storekit (0.0.1): + - Flutter + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + in_app_purchase_storekit: + :path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin + +SPEC CHECKSUMS: + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3bf6f86..bce6be7 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 445A7D54FC8B5C080EC23AA7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50197F9962E93C4832D19A60 /* Pods_Runner.framework */; }; + 46077B05EFE9D8418248A9BA /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E0FA2F360DF233F6381AD5E /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 097E77AEFC55DC7392A2A2D2 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* funymee_ai.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "funymee_ai.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* funymee_ai.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = funymee_ai.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 4E0FA2F360DF233F6381AD5E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50197F9962E93C4832D19A60 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 570F5EADCFC9266B280CD997 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 96CD0E5D9FC7D510CE706E5A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9955E766342B2725A4BCE747 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9EDC27A3BE6708BF5C126BE6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F2644650A94F105FB404E139 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 46077B05EFE9D8418248A9BA /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 445A7D54FC8B5C080EC23AA7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 98B6BE56A423F6204EFD147F /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 98B6BE56A423F6204EFD147F /* Pods */ = { + isa = PBXGroup; + children = ( + F2644650A94F105FB404E139 /* Pods-Runner.debug.xcconfig */, + 9955E766342B2725A4BCE747 /* Pods-Runner.release.xcconfig */, + 9EDC27A3BE6708BF5C126BE6 /* Pods-Runner.profile.xcconfig */, + 570F5EADCFC9266B280CD997 /* Pods-RunnerTests.debug.xcconfig */, + 96CD0E5D9FC7D510CE706E5A /* Pods-RunnerTests.release.xcconfig */, + 097E77AEFC55DC7392A2A2D2 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 50197F9962E93C4832D19A60 /* Pods_Runner.framework */, + 4E0FA2F360DF233F6381AD5E /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + ABCE1FA7DB91F688D59E364B /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 05B7C1A243B9A0B0C01B504E /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 39FF77A5C6E3B0F781285A62 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 05B7C1A243B9A0B0C01B504E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 39FF77A5C6E3B0F781285A62 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + ABCE1FA7DB91F688D59E364B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 570F5EADCFC9266B280CD997 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 96CD0E5D9FC7D510CE706E5A /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 097E77AEFC55DC7392A2A2D2 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 5d2c01a..1a632e4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -104,6 +128,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -112,6 +144,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -176,11 +216,59 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -189,6 +277,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter @@ -207,6 +303,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" hooks: dependency: transitive description: @@ -215,6 +319,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -239,6 +351,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" in_app_purchase: dependency: transitive description: @@ -271,6 +447,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.8+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: @@ -375,6 +559,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" native_toolchain_c: dependency: transitive description: @@ -391,6 +583,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -399,6 +599,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -511,6 +727,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -580,6 +804,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -604,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -628,6 +900,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -636,6 +916,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" video_thumbnail: dependency: transitive description: @@ -660,6 +980,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + url: "https://pub.dev" + source: hosted + version: "4.10.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + url: "https://pub.dev" + source: hosted + version: "3.24.2" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 29de4ce..7299035 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,14 @@ dependencies: shared_preferences: ^2.2.2 android_id: ^0.5.1 device_info_plus: ^11.1.0 + image_picker: ^1.1.2 + cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 + video_player: ^2.9.2 + intl: ^0.20.2 + google_fonts: ^6.2.1 + package_info_plus: ^8.1.2 + webview_flutter: ^4.13.1 dev_dependencies: flutter_test: @@ -30,3 +38,8 @@ flutter: uses-material-design: true assets: - assets/skin_config.json + - assets/images/ + fonts: + - family: BonheurRoyale + fonts: + - asset: assets/fonts/BonheurRoyale-Regular.ttf diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..77ab7a0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..277a56f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)