diff --git a/mobile/lib/app/config/app_config.dart b/mobile/lib/app/config/app_config.dart index 187ba63d..fe43f874 100644 --- a/mobile/lib/app/config/app_config.dart +++ b/mobile/lib/app/config/app_config.dart @@ -4,7 +4,7 @@ class AppConfig { static const String appName = 'AI合同平台'; static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.0.2.2:8000/api/v1', + defaultValue: 'http://192.168.3.73:8000/api/v1', ); static const String environment = String.fromEnvironment( 'APP_ENV', diff --git a/mobile/lib/app/shell/app_shell.dart b/mobile/lib/app/shell/app_shell.dart index 136944ff..4e3be238 100644 --- a/mobile/lib/app/shell/app_shell.dart +++ b/mobile/lib/app/shell/app_shell.dart @@ -212,12 +212,12 @@ class _AiContractAppShellState extends State return Scaffold( appBar: AppBar( - toolbarHeight: 66, + toolbarHeight: 64, systemOverlayStyle: SystemUiOverlayStyle.light, titleSpacing: 16, title: Row( children: [ - const BrandMark(size: 34), + const BrandMark(size: 32), const SizedBox(width: 12), Expanded( child: Column( @@ -225,45 +225,77 @@ class _AiContractAppShellState extends State mainAxisSize: MainAxisSize.min, children: [ Text( - _currentTab.title, + '移动工作台', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, - fontSize: 17, - fontWeight: FontWeight.w800, + fontSize: 16, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), - Text( - '${session!.userDisplayName} · ${session.role}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xBFE8FBDC), - fontSize: 12, - fontWeight: FontWeight.w600, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + session!.role, + style: const TextStyle( + color: Color(0xBFE8FBDC), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Text( + ' · ', + style: const TextStyle( + color: Color(0x80E8FBDC), + fontSize: 11, + ), + ), + Text( + session.userDisplayName, + style: const TextStyle( + color: Color(0xBFE8FBDC), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], ), ], ), ), Container( padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.07), + color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(999), border: - Border.all(color: Colors.white.withValues(alpha: 0.08)), + Border.all(color: Colors.white.withValues(alpha: 0.12)), ), - child: const Text( - 'Live', - style: TextStyle( - color: BrandAssets.accentLime, - fontSize: 11, - fontWeight: FontWeight.w800, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: BrandAssets.accentLime, + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(width: 4), + const Text( + 'Live', + style: TextStyle( + color: BrandAssets.accentLime, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], ), ), ], @@ -275,38 +307,9 @@ class _AiContractAppShellState extends State child: _buildCurrentScreen(), ), ), - bottomNavigationBar: Container( - decoration: const BoxDecoration( - color: BrandAssets.surface, - border: Border(top: BorderSide(color: BrandAssets.line)), - boxShadow: [ - BoxShadow( - color: Color(0x140F172A), - blurRadius: 22, - offset: Offset(0, -8), - ), - ], - ), - child: SafeArea( - top: false, - child: NavigationBar( - height: 68, - selectedIndex: AppTab.values.indexOf(_currentTab), - onDestinationSelected: (index) { - _selectTab(AppTab.values[index]); - }, - destinations: AppTab.values - .map( - (item) => NavigationDestination( - icon: Icon(item.icon), - selectedIcon: - Icon(item.icon, color: BrandAssets.surfaceInk), - label: item.label, - ), - ) - .toList(growable: false), - ), - ), + bottomNavigationBar: _BottomNavBar( + currentTab: _currentTab, + onTabSelected: _selectTab, ), ); }, @@ -355,3 +358,120 @@ class _AiContractAppShellState extends State } } } + +class _BottomNavBar extends StatelessWidget { + const _BottomNavBar({ + required this.currentTab, + required this.onTabSelected, + }); + + final AppTab currentTab; + final ValueChanged onTabSelected; + + @override + Widget build(BuildContext context) { + final currentIndex = AppTab.values.indexOf(currentTab); + + return Container( + decoration: BoxDecoration( + gradient: BrandAssets.navGradient, + border: const Border(top: BorderSide(color: BrandAssets.lineLight, width: 1)), + boxShadow: const [ + BoxShadow( + color: Color(0x180F172A), + blurRadius: 28, + offset: Offset(0, -12), + ), + BoxShadow( + color: Color(0x080F172A), + blurRadius: 8, + offset: Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: AppTab.values.asMap().entries.map((entry) { + final index = entry.key; + final tab = entry.value; + final isSelected = index == currentIndex; + + return _NavButton( + tab: tab, + isSelected: isSelected, + onTap: () => onTabSelected(tab), + ); + }).toList(growable: false), + ), + ), + ), + ); + } +} + +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.tab, + required this.isSelected, + required this.onTap, + }); + + final AppTab tab; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = isSelected ? BrandAssets.brand : BrandAssets.muted; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: BrandAssets.durationSm, + curve: BrandAssets.curveSmooth, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: BrandAssets.durationSm, + curve: BrandAssets.curveSmooth, + decoration: isSelected + ? BoxDecoration( + color: BrandAssets.brand.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + ) + : null, + padding: EdgeInsets.all(isSelected ? 8 : 2), + child: Icon( + tab.icon, + color: color, + size: isSelected ? 22 : 20, + ), + ), + const SizedBox(height: 3), + AnimatedDefaultTextStyle( + duration: BrandAssets.durationSm, + curve: BrandAssets.curveSmooth, + style: TextStyle( + color: isSelected ? BrandAssets.brand : BrandAssets.muted, + fontSize: 10, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + child: Text( + tab.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/app/theme/app_theme.dart b/mobile/lib/app/theme/app_theme.dart index 30ec6dcb..c61b0256 100644 --- a/mobile/lib/app/theme/app_theme.dart +++ b/mobile/lib/app/theme/app_theme.dart @@ -6,19 +6,26 @@ class AppTheme { const AppTheme._(); static ThemeData light() { - const textColor = BrandAssets.ink; + const textColor = BrandAssets.textPrimary; final colorScheme = ColorScheme.fromSeed( seedColor: BrandAssets.brand, brightness: Brightness.light, ).copyWith( primary: BrandAssets.brand, + primaryContainer: BrandAssets.brandLight, secondary: BrandAssets.mint, + secondaryContainer: BrandAssets.mintLight, tertiary: BrandAssets.accentAmber, + tertiaryContainer: BrandAssets.accentAmberLight, surface: BrandAssets.surface, surfaceContainerHighest: BrandAssets.surfaceSoft, - outline: BrandAssets.line, - error: BrandAssets.accentCoral, + surfaceTint: BrandAssets.brandGlow, + outline: BrandAssets.lineStrong, + outlineVariant: BrandAssets.line, + error: BrandAssets.error, + errorContainer: BrandAssets.accentCoralLight, + onErrorContainer: BrandAssets.ink, ); return ThemeData( @@ -31,25 +38,28 @@ class AppTheme { foregroundColor: Colors.white, elevation: 0, scrolledUnderElevation: 0, + toolbarHeight: 66, + titleSpacing: 16, ), cardTheme: CardThemeData( color: BrandAssets.surface, elevation: 0, - shadowColor: const Color(0x0E0F172A), + shadowColor: const Color(0x120F172A), surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), - side: const BorderSide(color: BrandAssets.line), + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + side: const BorderSide(color: BrandAssets.cardBorder, width: 1), ), + clipBehavior: Clip.antiAlias, ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: BrandAssets.brand, foregroundColor: Colors.white, - disabledBackgroundColor: const Color(0xFFD8E1DE), + disabledBackgroundColor: const Color(0xFFE0E8E4), disabledForegroundColor: BrandAssets.muted, - minimumSize: const Size(48, 46), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), ), @@ -58,118 +68,381 @@ class AppTheme { letterSpacing: 0, fontSize: 14, ), + elevation: 0, ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: BrandAssets.ink, - minimumSize: const Size(48, 46), - side: const BorderSide(color: BrandAssets.lineStrong), + minimumSize: const Size(48, 48), + side: const BorderSide(color: BrandAssets.lineStrong, width: 1.5), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), ), - textStyle: const TextStyle(fontWeight: FontWeight.w800), + textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( foregroundColor: BrandAssets.primaryDark, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), ), - textStyle: const TextStyle(fontWeight: FontWeight.w800), + textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), ), textTheme: const TextTheme( - titleLarge: TextStyle( + displayLarge: TextStyle( + color: textColor, + fontSize: 32, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.1, + ), + displayMedium: TextStyle( + color: textColor, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.15, + ), + displaySmall: TextStyle( color: textColor, fontSize: 24, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, - height: 1.15, + height: 1.2, + ), + headlineLarge: TextStyle( + color: textColor, + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + height: 1.2, + ), + headlineMedium: TextStyle( + color: textColor, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + height: 1.25, + ), + headlineSmall: TextStyle( + color: textColor, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.3, + ), + titleLarge: TextStyle( + color: textColor, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + height: 1.2, ), titleMedium: TextStyle( color: textColor, fontSize: 17, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.1, + height: 1.35, ), titleSmall: TextStyle( color: textColor, fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: -0.1, + height: 1.4, ), bodyLarge: TextStyle( color: textColor, fontSize: 15, + fontWeight: FontWeight.w500, height: 1.5, ), bodyMedium: TextStyle( - color: BrandAssets.mutedStrong, - fontSize: 13.5, + color: BrandAssets.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w400, height: 1.5, ), bodySmall: TextStyle( color: BrandAssets.muted, fontSize: 12, - height: 1.4, + fontWeight: FontWeight.w400, + height: 1.45, ), labelLarge: TextStyle( color: textColor, - fontSize: 12.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.3, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), labelMedium: TextStyle( color: BrandAssets.muted, fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + labelSmall: TextStyle( + color: BrandAssets.muted, + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, ), ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: BrandAssets.surface, - hintStyle: const TextStyle(color: BrandAssets.muted), - contentPadding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + hoverColor: BrandAssets.surfaceSoft, + hintStyle: const TextStyle( + color: BrandAssets.textHint, + fontWeight: FontWeight.w400, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), - borderSide: const BorderSide(color: BrandAssets.line), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + borderSide: const BorderSide(color: BrandAssets.line, width: 1), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), - borderSide: const BorderSide(color: BrandAssets.line), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + borderSide: const BorderSide(color: BrandAssets.line, width: 1), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius), - borderSide: const BorderSide(color: BrandAssets.brand, width: 1.5), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + borderSide: const BorderSide(color: BrandAssets.brand, width: 2), ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + borderSide: const BorderSide(color: BrandAssets.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + borderSide: const BorderSide(color: BrandAssets.error, width: 2), + ), + labelStyle: const TextStyle( + color: BrandAssets.textSecondary, + fontWeight: FontWeight.w500, + ), + floatingLabelStyle: const TextStyle( + color: BrandAssets.brand, + fontWeight: FontWeight.w600, + ), + prefixIconColor: BrandAssets.muted, + suffixIconColor: BrandAssets.muted, ), - navigationBarTheme: const NavigationBarThemeData( + navigationBarTheme: NavigationBarThemeData( backgroundColor: BrandAssets.surface, - indicatorColor: Color(0xFFE8FBDC), + indicatorColor: BrandAssets.brand.withValues(alpha: 0.15), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + ), surfaceTintColor: Colors.transparent, elevation: 0, + shadowColor: const Color(0x140F172A), labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + height: 68, + labelTextStyle: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.selected)) { + return const TextStyle( + color: BrandAssets.primaryDark, + fontWeight: FontWeight.w700, + fontSize: 12, + ); + } + return const TextStyle( + color: BrandAssets.muted, + fontWeight: FontWeight.w500, + fontSize: 12, + ); + }, + ), + iconTheme: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData( + color: BrandAssets.brand, + size: 24, + ); + } + return const IconThemeData( + color: BrandAssets.muted, + size: 22, + ); + }, + ), ), segmentedButtonTheme: SegmentedButtonThemeData( style: ButtonStyle( padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 14, vertical: 12), + EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), shape: WidgetStatePropertyAll( RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BrandAssets.radius)), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall)), ), side: const WidgetStatePropertyAll( - BorderSide(color: BrandAssets.lineStrong), + BorderSide(color: BrandAssets.lineStrong, width: 1.5), ), textStyle: const WidgetStatePropertyAll( - TextStyle(fontWeight: FontWeight.w800), + TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.selected)) { + return BrandAssets.brand; + } + return BrandAssets.surface; + }, + ), + foregroundColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.selected)) { + return Colors.white; + } + return BrandAssets.ink; + }, ), ), ), + chipTheme: ChipThemeData( + backgroundColor: BrandAssets.surfaceSoft, + selectedColor: BrandAssets.brand.withValues(alpha: 0.12), + disabledColor: BrandAssets.canvasDark, + side: const BorderSide(color: BrandAssets.line), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusPill), + ), + labelStyle: const TextStyle( + color: BrandAssets.ink, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + secondaryLabelStyle: const TextStyle( + color: BrandAssets.primaryDark, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: BrandAssets.brand, + foregroundColor: Colors.white, + elevation: 4, + focusElevation: 6, + hoverElevation: 6, + highlightElevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + ), + smallSizeConstraints: const BoxConstraints.tightFor( + width: 48, + height: 48, + ), + largeSizeConstraints: const BoxConstraints.tightFor( + width: 64, + height: 64, + ), + ), + dividerTheme: const DividerThemeData( + color: BrandAssets.divider, + thickness: 1, + space: 1, + ), + listTileTheme: ListTileThemeData( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + ), + tileColor: Colors.transparent, + selectedTileColor: BrandAssets.brand.withValues(alpha: 0.08), + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: BrandAssets.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(BrandAssets.radiusXL), + ), + ), + elevation: 0, + showDragHandle: true, + dragHandleColor: BrandAssets.lineStrong, + dragHandleSize: const Size(36, 4), + ), + dialogTheme: DialogThemeData( + backgroundColor: BrandAssets.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusLarge), + ), + elevation: 0, + titleTextStyle: const TextStyle( + color: BrandAssets.ink, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + contentTextStyle: const TextStyle( + color: BrandAssets.textSecondary, + fontSize: 14, + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: BrandAssets.ink, + contentTextStyle: const TextStyle(color: Colors.white, fontSize: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + ), + behavior: SnackBarBehavior.floating, + elevation: 0, + ), + tabBarTheme: TabBarThemeData( + labelColor: BrandAssets.brand, + unselectedLabelColor: BrandAssets.muted, + labelStyle: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + indicator: BoxDecoration( + color: BrandAssets.brand.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(BrandAssets.radiusTight), + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: BrandAssets.line, + ), + datePickerTheme: DatePickerThemeData( + backgroundColor: BrandAssets.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusLarge), + ), + headerBackgroundColor: BrandAssets.brand, + headerForegroundColor: Colors.white, + weekdayStyle: const TextStyle( + color: BrandAssets.muted, + fontWeight: FontWeight.w500, + ), + dayStyle: const TextStyle( + color: BrandAssets.ink, + fontWeight: FontWeight.w400, + ), + todayBackgroundColor: WidgetStateProperty.resolveWith( + (states) => BrandAssets.brand.withValues(alpha: 0.12), + ), + todayForegroundColor: WidgetStateProperty.resolveWith( + (states) => BrandAssets.brand, + ), + dayBackgroundColor: WidgetStateProperty.resolveWith( + (states) => BrandAssets.surface, + ), + rangeSelectionBackgroundColor: BrandAssets.brand.withValues(alpha: 0.1), + rangePickerBackgroundColor: BrandAssets.surfaceSoft, + ), ); } -} +} \ No newline at end of file diff --git a/mobile/lib/app/theme/brand_assets.dart b/mobile/lib/app/theme/brand_assets.dart index 782e85d3..eae4c78f 100644 --- a/mobile/lib/app/theme/brand_assets.dart +++ b/mobile/lib/app/theme/brand_assets.dart @@ -3,56 +3,114 @@ import 'package:flutter/material.dart'; class BrandAssets { const BrandAssets._(); + // ===== Brand Assets Paths ===== static const String brandMarkAsset = 'assets/branding/brand-mark.png'; static const String loginBannerAsset = 'assets/branding/login-banner.png'; static const String workbenchHeroAsset = 'assets/images/workbench-hero.png'; - static const Color ink = Color(0xFF111620); - static const Color inkSoft = Color(0xFF273244); - static const Color deepTeal = Color(0xFF0A725F); - static const Color brand = Color(0xFF12A884); - static const Color primaryDark = Color(0xFF0A725F); - static const Color accentCyan = Color(0xFF1B8FC7); - static const Color accentCoral = Color(0xFFCF4E4E); - static const Color accentAmber = Color(0xFFB9761E); - static const Color accentLime = Color(0xFFA4EB72); - static const Color mint = Color(0xFF31C5DA); - static const Color softMint = Color(0xFFE8FBDC); - static const Color canvas = Color(0xFFF6F8F7); + // ===== Primary Color Palette ===== + // Deep teal - authority, trust, legal + static const Color ink = Color(0xFF0F1724); + static const Color inkSoft = Color(0xFF1E2D3D); + static const Color inkLight = Color(0xFF37475F); + + // Brand green - growth, clarity, AI + static const Color brand = Color(0xFF00D68F); + static const Color brandDark = Color(0xFF00A66B); + static const Color brandLight = Color(0xFF33EAAA); + static const Color brandGlow = Color(0xFF4DFFB3); + + // Primary dark - navigation, emphasis + static const Color primaryDark = Color(0xFF0A6E5C); + static const Color deepTeal = Color(0xFF065547); + + // ===== Accent Colors ===== + // Cyan - digital, clarity, processing + static const Color accentCyan = Color(0xFF00B4D8); + static const Color accentCyanLight = Color(0xFF48D1E5); + + // Coral - alerts, risks, attention + static const Color accentCoral = Color(0xFFFF6B6B); + static const Color accentCoralLight = Color(0xFFFFA07A); + + // Amber - warmth, documents, advice + static const Color accentAmber = Color(0xFFFFB020); + static const Color accentAmberLight = Color(0xFFFFD060); + + // Lime - success, approved, ready + static const Color accentLime = Color(0xFF7DFFB3); + static const Color accentLimeDim = Color(0xFFA4EB72); + + // Mint - secondary, fresh, minimal + static const Color mint = Color(0xFF00E5CC); + static const Color mintLight = Color(0xFF80F0E5); + static const Color softMint = Color(0xFFE8FBF4); + + // ===== Surface System ===== + static const Color canvas = Color(0xFFF4F7F6); + static const Color canvasDark = Color(0xFFE8EDED); static const Color surface = Color(0xFFFFFFFF); - static const Color surfaceSoft = Color(0xFFF6F9F8); - static const Color surfaceTinted = Color(0xFFEDF9F5); - static const Color surfaceInk = Color(0xFF0C1718); + static const Color surfaceSoft = Color(0xFFF8FAF9); + static const Color surfaceTinted = Color(0xFFF0FDF8); + static const Color surfaceInk = Color(0xFF0B1A1C); + static const Color surfaceGlass = Color(0xE6FFFFFF); + + // ===== Border & Divider System ===== static const Color line = Color(0x1A182230); - static const Color lineStrong = Color(0x29182230); - static const Color muted = Color(0xFF5A6575); - static const Color mutedStrong = Color(0xFF344255); + static const Color lineStrong = Color(0x33182230); + static const Color lineLight = Color(0x0D182230); + static const Color cardBorder = Color(0x20182230); + static const Color divider = Color(0x0F182230); - // Border colors - visible on canvas/surface - static const Color cardBorder = Color(0x26182230); - static const Color divider = Color(0x0D182230); + // ===== Text Colors ===== + static const Color muted = Color(0xFF6B7785); + static const Color mutedStrong = Color(0xFF4A5568); + static const Color textPrimary = Color(0xFF0F1724); + static const Color textSecondary = Color(0xFF4A5568); + static const Color textHint = Color(0xFF9CA3AF); - // Radius scale - static const double radiusTight = 4; - static const double radiusSmall = 8; - static const double radiusMedium = 12; - static const double radiusLarge = 16; + // ===== Status Colors ===== + static const Color success = Color(0xFF00D68F); + static const Color warning = Color(0xFFFFB020); + static const Color error = Color(0xFFFF6B6B); + static const Color info = Color(0xFF00B4D8); + + // ===== Radius Scale ===== + static const double radiusTight = 6; + static const double radiusSmall = 10; + static const double radiusMedium = 14; + static const double radiusLarge = 18; + static const double radiusXL = 24; static const double radiusPill = 999; static const double radius = radiusSmall; // backward compatibility - // Spacing scale + // ===== Spacing Scale ===== + static const double spaceXXs = 2; static const double spaceXs = 4; static const double spaceSm = 8; static const double spaceMd = 12; static const double spaceLg = 16; static const double spaceXl = 24; - static const double spaceXxl = 32; + static const double spaceXXl = 32; + static const double spaceXXXl = 48; + // ===== Gradient System ===== static const LinearGradient heroGradient = LinearGradient( colors: [ - Color(0xFF0D191C), - Color(0xFF0D342C), - Color(0xFF191620), + Color(0xFF061820), + Color(0xFF0A3A30), + Color(0xFF12101A), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.5, 1.0], + ); + + static const LinearGradient primaryGradient = LinearGradient( + colors: [ + brandDark, + brand, + brandLight, ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -60,33 +118,178 @@ class BrandAssets { static const LinearGradient surfaceGradient = LinearGradient( colors: [ - Color(0xF2FFFFFF), - Color(0xDFF8FBFA), + Color(0xFFFFFFFF), + Color(0xFFF8FAF9), + Color(0xFFF4F7F6), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ); - static const BoxShadow cardShadow = BoxShadow( - color: Color(0x0A0F172A), + static const LinearGradient glassGradient = LinearGradient( + colors: [ + Color(0xE6FFFFFF), + Color(0xCCFFFFFF), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + static const LinearGradient inkGradient = LinearGradient( + colors: [ + ink, + inkSoft, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + static const LinearGradient appBarGradient = LinearGradient( + colors: [ + Color(0xFF0B1A1C), + Color(0xFF0A2520), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + static const LinearGradient navGradient = LinearGradient( + colors: [ + Color(0xFFFFFFFF), + Color(0xF8FFFFFF), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + static final RadialGradient brandGlowGradient = RadialGradient( + colors: [ + brandGlow, + brand.withValues(alpha: 0.4), + Colors.transparent, + ], + radius: 0.8, + ); + + // ===== Shadow System ===== + static const BoxShadow shadowXS = BoxShadow( + color: Color(0x080F172A), + blurRadius: 4, + offset: Offset(0, 2), + ); + + static const BoxShadow shadowSm = BoxShadow( + color: Color(0x0C0F172A), + blurRadius: 8, + offset: Offset(0, 4), + ); + + static const BoxShadow shadowMd = BoxShadow( + color: Color(0x120F172A), blurRadius: 16, + offset: Offset(0, 8), + ); + + static const BoxShadow shadowLg = BoxShadow( + color: Color(0x180F172A), + blurRadius: 24, + offset: Offset(0, 12), + ); + + static const BoxShadow shadowXL = BoxShadow( + color: Color(0x200F172A), + blurRadius: 32, + offset: Offset(0, 16), + ); + + static const BoxShadow cardShadow = BoxShadow( + color: Color(0x0C0F172A), + blurRadius: 12, offset: Offset(0, 6), ); static const BoxShadow panelShadow = BoxShadow( - color: Color(0x140F172A), - blurRadius: 32, + color: Color(0x160F172A), + blurRadius: 24, offset: Offset(0, 12), ); + + static const BoxShadow brandShadow = BoxShadow( + color: Color(0x3000D68F), + blurRadius: 20, + offset: Offset(0, 8), + ); + + static const BoxShadow glowShadow = BoxShadow( + color: Color(0x404DFFB3), + blurRadius: 32, + offset: Offset(0, 0), + ); + + // ===== Animation Durations ===== + static const Duration durationXS = Duration(milliseconds: 100); + static const Duration durationSm = Duration(milliseconds: 180); + static const Duration durationMd = Duration(milliseconds: 280); + static const Duration durationLg = Duration(milliseconds: 420); + static const Duration durationXL = Duration(milliseconds: 600); + + // ===== Animation Curves ===== + static const Curve curveEase = Curves.easeOut; + static const Curve curveSmooth = Curves.easeOutCubic; + static const Curve curveBounce = Curves.easeOutBack; + static const Curve curveSnap = Curves.easeInOutQuart; + + // ===== Decorations ===== + static BoxDecoration cardDecoration({ + Color? color, + bool hasShadow = true, + bool hasBorder = true, + }) { + return BoxDecoration( + color: color ?? surface, + borderRadius: BorderRadius.circular(radiusMedium), + border: hasBorder ? Border.all(color: cardBorder, width: 1) : null, + boxShadow: hasShadow ? const [cardShadow] : null, + ); + } + + static BoxDecoration glassDecoration({ + double radius = radiusMedium, + }) { + return BoxDecoration( + gradient: glassGradient, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: Colors.white.withValues(alpha: 0.2)), + boxShadow: const [shadowSm], + ); + } + + static BoxDecoration chipDecoration({ + required Color accentColor, + bool filled = false, + }) { + return BoxDecoration( + color: filled + ? accentColor + : accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(radiusPill), + border: filled + ? null + : Border.all(color: accentColor.withValues(alpha: 0.24)), + ); + } } +// ===== Brand Mark Widget ===== class BrandMark extends StatelessWidget { const BrandMark({ super.key, this.size = 52, + this.showGlow = true, }); final double size; + final bool showGlow; @override Widget build(BuildContext context) { @@ -94,14 +297,21 @@ class BrandMark extends StatelessWidget { width: size, height: size, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(size * 0.2), - boxShadow: const [ - BoxShadow( - color: Color(0x2431C5DA), - blurRadius: 18, - offset: Offset(0, 9), - ), - ], + borderRadius: BorderRadius.circular(size * 0.22), + boxShadow: showGlow + ? const [ + BoxShadow( + color: Color(0x2833EAAA), + blurRadius: 16, + offset: Offset(0, 6), + ), + BoxShadow( + color: Color(0x144DFFB3), + blurRadius: 28, + offset: Offset(0, 12), + ), + ] + : null, ), clipBehavior: Clip.antiAlias, child: Image.asset( @@ -112,6 +322,7 @@ class BrandMark extends StatelessWidget { } } +// ===== Brand Hero Band Widget ===== class BrandHeroBand extends StatelessWidget { const BrandHeroBand({ super.key, @@ -123,6 +334,7 @@ class BrandHeroBand extends StatelessWidget { this.actionIcon, this.onAction, this.showHeroImage = false, + this.variant = HeroVariant.standard, }); final String title; @@ -133,6 +345,7 @@ class BrandHeroBand extends StatelessWidget { final IconData? actionIcon; final VoidCallback? onAction; final bool showHeroImage; + final HeroVariant variant; @override Widget build(BuildContext context) { @@ -151,56 +364,74 @@ class BrandHeroBand extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const BrandMark(size: 40), - const SizedBox(width: 12), + BrandMark(size: 42, showGlow: variant == HeroVariant.featured), + const SizedBox(width: 14), Expanded( - child: Text( - 'AI Contract Workbench', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: BrandAssets.primaryDark, - fontWeight: FontWeight.w800, - letterSpacing: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Contract Workbench', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: BrandAssets.primaryDark, + fontWeight: FontWeight.w800, + letterSpacing: 0, + ), + ), + const SizedBox(height: 2), + Text( + '移动端 · 真机调试', + style: TextStyle( + color: BrandAssets.muted, + fontSize: 11, + fontWeight: FontWeight.w500, ), + ), + ], ), ), if (trailing != null) trailing!, ], ), - const SizedBox(height: 16), + const SizedBox(height: 18), Text( title, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: BrandAssets.ink, - letterSpacing: 0, + letterSpacing: -0.5, ), ), if (subtitle.isNotEmpty) ...[ - const SizedBox(height: 4), + const SizedBox(height: 6), Text( subtitle, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: BrandAssets.mutedStrong, + ), ), ], if (badges.isNotEmpty) ...[ - const SizedBox(height: 14), + const SizedBox(height: 16), Wrap( - spacing: 6, - runSpacing: 6, + spacing: 8, + runSpacing: 8, children: badges .map( (item) => Container( padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), + horizontal: 12, vertical: 6), decoration: BoxDecoration( color: BrandAssets.surfaceTinted, borderRadius: BorderRadius.circular(BrandAssets.radiusPill), - border: Border.all(color: BrandAssets.brand.withValues(alpha: 0.2)), + border: Border.all( + color: BrandAssets.brand.withValues(alpha: 0.18), + ), ), child: Text( item, style: const TextStyle( - color: Color(0xFF0B614F), - fontSize: 11.5, + color: BrandAssets.primaryDark, + fontSize: 12, fontWeight: FontWeight.w600, ), ), @@ -210,7 +441,7 @@ class BrandHeroBand extends StatelessWidget { ), ], if (showHeroImage) ...[ - const SizedBox(height: 14), + const SizedBox(height: 18), ClipRRect( borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), child: AspectRatio( @@ -222,10 +453,10 @@ class BrandHeroBand extends StatelessWidget { ), ), ], - const SizedBox(height: 14), + const SizedBox(height: 16), Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( gradient: BrandAssets.heroGradient, borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), @@ -235,23 +466,55 @@ class BrandHeroBand extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - const Expanded(child: _HeroSignal(label: '提交', value: 'Review', color: BrandAssets.accentLime)), + const Expanded( + child: _HeroSignal( + label: '提交', + value: 'Review', + color: BrandAssets.accentLime, + icon: Icons.upload_outlined, + ), + ), const _HeroDivider(), - const Expanded(child: _HeroSignal(label: '分析', value: 'Risk', color: BrandAssets.mint)), + const Expanded( + child: _HeroSignal( + label: '分析', + value: 'Risk', + color: BrandAssets.mint, + icon: Icons.analytics_outlined, + ), + ), const _HeroDivider(), - const Expanded(child: _HeroSignal(label: '流转', value: 'Report', color: BrandAssets.accentAmber)), + const Expanded( + child: _HeroSignal( + label: '流转', + value: 'Report', + color: BrandAssets.accentAmber, + icon: Icons.share_outlined, + ), + ), ], ), ), ), if (onAction != null && actionLabel != null) ...[ - const SizedBox(height: 12), + const SizedBox(height: 14), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: onAction, icon: Icon(actionIcon ?? Icons.arrow_forward_rounded), label: Text(actionLabel!), + style: FilledButton.styleFrom( + backgroundColor: BrandAssets.brand, + foregroundColor: Colors.white, + elevation: 0, + shadowColor: BrandAssets.brandGlow, + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + ), + ), ), ), ], @@ -261,6 +524,8 @@ class BrandHeroBand extends StatelessWidget { } } +enum HeroVariant { standard, featured, minimal } + class _HeroDivider extends StatelessWidget { const _HeroDivider(); @@ -268,49 +533,9 @@ class _HeroDivider extends StatelessWidget { Widget build(BuildContext context) { return Container( width: 1, - height: 28, - color: Colors.white.withValues(alpha: 0.12), - ); - } -} - -class BrandChip extends StatelessWidget { - const BrandChip({ - super.key, - required this.label, - this.color = BrandAssets.brand, - this.icon, - }); - - final String label; - final Color color; - final IconData? icon; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.2)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 14, color: color), - const SizedBox(width: 5), - ], - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: color, - fontWeight: FontWeight.w800, - ), - ), - ], - ), + height: 36, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Colors.white.withValues(alpha: 0.08), ); } } @@ -320,48 +545,63 @@ class _HeroSignal extends StatelessWidget { required this.label, required this.value, required this.color, + this.icon, }); final String label; final String value; final Color color; + final IconData? icon; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( label, style: TextStyle( - color: Colors.white.withValues(alpha: 0.64), + color: Colors.white.withValues(alpha: 0.56), fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + ], Container( - width: 7, - height: 7, + width: 6, + height: 6, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 0), + ), + ], ), ), - const SizedBox(width: 6), + const SizedBox(width: 8), Flexible( child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w800, + fontSize: 13, + fontWeight: FontWeight.w700, letterSpacing: 0, ), ), @@ -373,3 +613,99 @@ class _HeroSignal extends StatelessWidget { ); } } + +// ===== Brand Chip Widget ===== +class BrandChip extends StatelessWidget { + const BrandChip({ + super.key, + required this.label, + this.color = BrandAssets.brand, + this.icon, + this.filled = false, + this.size = ChipSize.medium, + }); + + final String label; + final Color color; + final IconData? icon; + final bool filled; + final ChipSize size; + + @override + Widget build(BuildContext context) { + final fontSize = size == ChipSize.small ? 11.0 : 12.5; + final padding = size == ChipSize.small + ? const EdgeInsets.symmetric(horizontal: 10, vertical: 5) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 7); + + return Container( + padding: padding, + decoration: BrandAssets.chipDecoration( + accentColor: color, + filled: filled, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: fontSize + 2, color: filled ? Colors.white : color), + const SizedBox(width: 6), + ], + Text( + label, + style: TextStyle( + color: filled ? Colors.white : color, + fontSize: fontSize, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ], + ), + ); + } +} + +enum ChipSize { small, medium, large } + +// ===== Status Indicator Widget ===== +class StatusIndicator extends StatelessWidget { + const StatusIndicator({ + super.key, + required this.status, + this.size = 10, + }); + + final StatusType status; + final double size; + + @override + Widget build(BuildContext context) { + final color = switch (status) { + StatusType.success => BrandAssets.success, + StatusType.warning => BrandAssets.warning, + StatusType.error => BrandAssets.error, + StatusType.info => BrandAssets.info, + StatusType.processing => BrandAssets.accentCyan, + StatusType.pending => BrandAssets.muted, + }; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: size * 0.8, + offset: const Offset(0, 0), + ), + ], + ), + ); + } +} + +enum StatusType { success, warning, error, info, processing, pending } \ No newline at end of file diff --git a/mobile/lib/features/auth/presentation/login_screen.dart b/mobile/lib/features/auth/presentation/login_screen.dart index 2742ab0f..67519214 100644 --- a/mobile/lib/features/auth/presentation/login_screen.dart +++ b/mobile/lib/features/auth/presentation/login_screen.dart @@ -15,7 +15,8 @@ class LoginScreen extends StatefulWidget { State createState() => _LoginScreenState(); } -class _LoginScreenState extends State { +class _LoginScreenState extends State + with SingleTickerProviderStateMixin { final ScrollController _scrollController = ScrollController(); final GlobalKey _accountFieldKey = GlobalKey(); final GlobalKey _passwordFieldKey = GlobalKey(); @@ -28,6 +29,9 @@ class _LoginScreenState extends State { bool _obscurePassword = true; String? _localError; + late final AnimationController _animationController; + late final Animation _fadeAnimation; + late final Animation _slideAnimation; @override void initState() { @@ -37,6 +41,29 @@ class _LoginScreenState extends State { _accountFocusNode = FocusNode(); _passwordFocusNode = FocusNode(); + _animationController = AnimationController( + vsync: this, + duration: BrandAssets.durationXL, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.6, curve: Curves.easeOut), + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 0.8, curve: Curves.easeOutCubic), + )); + + _animationController.forward(); + _accountFocusNode.addListener(() { if (_accountFocusNode.hasFocus) { _ensureFieldVisible(_accountFieldKey); @@ -56,6 +83,7 @@ class _LoginScreenState extends State { _passwordController.dispose(); _accountFocusNode.dispose(); _passwordFocusNode.dispose(); + _animationController.dispose(); super.dispose(); } @@ -117,134 +145,151 @@ class _LoginScreenState extends State { final isCompact = media.orientation == Orientation.landscape || media.size.height < 760; - return AnimatedPadding( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: bottomInset), - child: ListView( - controller: _scrollController, - padding: EdgeInsets.fromLTRB( - 16, - 16, - 16, - 28 + (isCompact ? 120 : 48), + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: AnimatedPadding( + duration: BrandAssets.durationSm, + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomInset), + child: Scaffold( + backgroundColor: BrandAssets.surfaceInk, + body: ListView( + controller: _scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _HeroSection(isCompact: isCompact), + _LoginCard( + accountController: _accountController, + passwordController: _passwordController, + accountFocusNode: _accountFocusNode, + passwordFocusNode: _passwordFocusNode, + obscurePassword: _obscurePassword, + localError: _localError, + controller: widget.controller, + onToggleObscure: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + _focusField(_passwordFocusNode, _passwordFieldKey); + }, + onSubmit: _submit, + focusField: _focusField, + accountFieldKey: _accountFieldKey, + passwordFieldKey: _passwordFieldKey, + submitButtonKey: _submitButtonKey, + ), + const SizedBox(height: 32), + ], + ), + ), ), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + ), + ); + } +} + +class _HeroSection extends StatelessWidget { + const _HeroSection({required this.isCompact}); + + final bool isCompact; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(24, 48, 24, 40), + decoration: const BoxDecoration( + gradient: BrandAssets.heroGradient, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(BrandAssets.radiusLarge), - child: Image.asset( - BrandAssets.loginBannerAsset, - fit: BoxFit.cover, + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x2833EAAA), + blurRadius: 16, + offset: Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + BrandAssets.brandMarkAsset, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Contract Platform', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + '移动工作台', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 36), + Text( + '你好,\n欢迎回来', + style: TextStyle( + color: Colors.white, + fontSize: isCompact ? 28 : 34, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.15, ), ), const SizedBox(height: 16), - BrandHeroBand( - title: 'AI合同移动工作台', - subtitle: isCompact ? '继续处理审查、问答和合同生成。' : '把合同审查、风险摘要、法律问答和报告处理压缩进一个移动工作台。', - badges: isCompact - ? const ['合同审查', 'AI工具'] - : const ['合同审查', '风险摘要', '报告流转'], + Text( + '使用你的企业账号登录,继续审查、\n起草和管理工作台任务。', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.56), + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.6, + ), ), - const SizedBox(height: 12), - AppSectionCard( - title: '账号登录', - subtitle: '使用企业账号登录', - child: AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - KeyedSubtree( - key: _accountFieldKey, - child: TextField( - controller: _accountController, - focusNode: _accountFocusNode, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email - ], - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - onTap: () => - _focusField(_accountFocusNode, _accountFieldKey), - onSubmitted: (_) => - _focusField(_passwordFocusNode, _passwordFieldKey), - decoration: const InputDecoration( - labelText: '账号', - hintText: '输入邮箱或登录账号', - prefixIcon: Icon(Icons.account_circle_outlined), - ), - ), - ), - const SizedBox(height: 12), - KeyedSubtree( - key: _passwordFieldKey, - child: TextField( - controller: _passwordController, - focusNode: _passwordFocusNode, - autofillHints: const [AutofillHints.password], - textInputAction: TextInputAction.done, - onTap: () => - _focusField(_passwordFocusNode, _passwordFieldKey), - onSubmitted: (_) => _submit(), - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: '密码', - hintText: '输入登录密码', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - _focusField(_passwordFocusNode, _passwordFieldKey); - }, - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - KeyedSubtree( - key: _submitButtonKey, - child: ValueListenableBuilder( - valueListenable: widget.controller.isLoading, - builder: (context, isLoading, _) { - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: isLoading ? null : _submit, - icon: isLoading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : const Icon(Icons.login), - label: Text(isLoading ? '登录中' : '登录'), - ), - ); - }, - ), - ), - const SizedBox(height: 12), - ValueListenableBuilder( - valueListenable: widget.controller.errorMessage, - builder: (context, message, _) { - final error = _localError ?? message; - if (error == null || error.isEmpty) { - return const SizedBox.shrink(); - } - return _LoginError(message: error); - }, - ), + const SizedBox(height: 24), + Container( + height: 4, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + BrandAssets.brand, + BrandAssets.mint, + BrandAssets.accentCyan, ], ), + borderRadius: BorderRadius.circular(2), ), ), ], @@ -253,6 +298,152 @@ class _LoginScreenState extends State { } } +class _LoginCard extends StatelessWidget { + const _LoginCard({ + required this.accountController, + required this.passwordController, + required this.accountFocusNode, + required this.passwordFocusNode, + required this.obscurePassword, + required this.localError, + required this.controller, + required this.onToggleObscure, + required this.onSubmit, + required this.focusField, + required this.accountFieldKey, + required this.passwordFieldKey, + required this.submitButtonKey, + }); + + final TextEditingController accountController; + final TextEditingController passwordController; + final FocusNode accountFocusNode; + final FocusNode passwordFocusNode; + final bool obscurePassword; + final String? localError; + final AuthController controller; + final VoidCallback onToggleObscure; + final VoidCallback onSubmit; + final void Function(FocusNode, GlobalKey) focusField; + final GlobalKey accountFieldKey; + final GlobalKey passwordFieldKey; + final GlobalKey submitButtonKey; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: AppSectionCard( + title: '账号登录', + subtitle: '使用企业账号登录系统', + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KeyedSubtree( + key: accountFieldKey, + child: TextField( + controller: accountController, + focusNode: accountFocusNode, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email + ], + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + onTap: () => focusField(accountFocusNode, accountFieldKey), + onSubmitted: (_) => + focusField(passwordFocusNode, passwordFieldKey), + decoration: const InputDecoration( + labelText: '账号', + hintText: '输入邮箱或登录账号', + prefixIcon: Icon(Icons.account_circle_outlined), + ), + ), + ), + const SizedBox(height: 16), + KeyedSubtree( + key: passwordFieldKey, + child: TextField( + controller: passwordController, + focusNode: passwordFocusNode, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onTap: () => focusField(passwordFocusNode, passwordFieldKey), + onSubmitted: (_) => onSubmit(), + obscureText: obscurePassword, + decoration: InputDecoration( + labelText: '密码', + hintText: '输入登录密码', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + onPressed: onToggleObscure, + icon: Icon( + obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + ), + const SizedBox(height: 24), + KeyedSubtree( + key: submitButtonKey, + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, _) { + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isLoading ? null : onSubmit, + icon: isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white), + ) + : const Icon(Icons.login), + label: Text(isLoading ? '登录中' : '登录'), + style: FilledButton.styleFrom( + backgroundColor: BrandAssets.brand, + foregroundColor: Colors.white, + minimumSize: const Size(48, 50), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + BrandAssets.radiusSmall), + ), + shadowColor: BrandAssets.brandGlow, + elevation: 0, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: controller.errorMessage, + builder: (context, message, _) { + final error = localError ?? message; + if (error == null || error.isEmpty) { + return const SizedBox.shrink(); + } + return _LoginError(message: error); + }, + ), + ], + ), + ), + ), + ); + } +} + class _LoginError extends StatelessWidget { const _LoginError({required this.message}); @@ -262,15 +453,35 @@ class _LoginError extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), + color: BrandAssets.accentCoral.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + border: Border.all( + color: BrandAssets.accentCoral.withValues(alpha: 0.2), + ), ), - child: Text( - message, - style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.error_outline_rounded, + color: BrandAssets.accentCoral, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + color: BrandAssets.accentCoral, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), ); } -} +} \ No newline at end of file diff --git a/mobile/lib/features/dashboard/presentation/dashboard_screen.dart b/mobile/lib/features/dashboard/presentation/dashboard_screen.dart index 18d3ae67..0beeb12d 100644 --- a/mobile/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/mobile/lib/features/dashboard/presentation/dashboard_screen.dart @@ -62,7 +62,7 @@ class _DashboardScreenState extends State { items: items, onStartReview: widget.onStartReview, ), - const SizedBox(height: 16), + const SizedBox(height: BrandAssets.spaceXl), _QuickActions( onStartReview: widget.onStartReview, onOpenHistory: widget.onOpenHistory, @@ -71,7 +71,8 @@ class _DashboardScreenState extends State { onOpenContracts: widget.onOpenContracts, onOpenDocuments: widget.onOpenDocuments, ), - _RecentReviews( + const SizedBox(height: BrandAssets.spaceXl), + _RecentReviews( items: items, onOpenHistory: widget.onOpenHistory, onOpenReviewDetail: widget.onOpenReviewDetail, @@ -107,23 +108,154 @@ class _HeroPanel extends StatelessWidget { final highRiskCount = items.where((item) => item.riskLevel == 'high').length; - return BrandHeroBand( - title: '你好,${session?.userDisplayName ?? '用户'}', - subtitle: '${session?.role ?? '-'} · ${session?.username ?? '-'}', - badges: [ - '${items.length} 条最近审查', - '$activeCount 条处理中', - '$highRiskCount 条高风险', - ], - trailing: const BrandChip( - label: '移动工作台', - icon: Icons.phone_iphone_rounded, - color: BrandAssets.primaryDark, + return Container( + width: double.infinity, + padding: const EdgeInsets.all(BrandAssets.spaceLg), + decoration: BoxDecoration( + gradient: BrandAssets.surfaceGradient, + borderRadius: BorderRadius.circular(BrandAssets.radiusLarge), + border: Border.all(color: BrandAssets.cardBorder), + boxShadow: const [BrandAssets.cardShadow], ), - showHeroImage: true, - actionLabel: '发起合同审查', - actionIcon: Icons.upload_file_outlined, - onAction: onStartReview, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Welcome text + Text( + '你好,${session?.userDisplayName ?? '用户'}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + '欢迎回来,今天需要处理 ${items.length} 条审查任务', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: BrandAssets.muted, + fontSize: 13, + ), + ), + const SizedBox(height: BrandAssets.spaceLg), + // Stats row + Container( + width: double.infinity, + padding: const EdgeInsets.all(BrandAssets.spaceLg), + decoration: BoxDecoration( + gradient: BrandAssets.heroGradient, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + boxShadow: const [BrandAssets.panelShadow], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _MetricItem( + icon: Icons.description_outlined, + label: '待审查', + value: '${items.where((i) => i.status == 'pending').length}', + color: BrandAssets.accentLime, + ), + _MetricDivider(), + _MetricItem( + icon: Icons.hourglass_top_outlined, + label: '处理中', + value: '${items.where((i) => i.status == 'processing').length}', + color: BrandAssets.mint, + ), + _MetricDivider(), + _MetricItem( + icon: Icons.check_circle_outline, + label: '已完成', + value: '${items.where((i) => i.status == 'completed').length}', + color: BrandAssets.accentAmber, + ), + ], + ), + ), + const SizedBox(height: BrandAssets.spaceLg), + // Quick action button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onStartReview, + icon: const Icon(Icons.upload_file_outlined, size: 20), + label: const Text('发起合同审查'), + style: FilledButton.styleFrom( + backgroundColor: BrandAssets.brand, + foregroundColor: Colors.white, + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _MetricItem extends StatelessWidget { + const _MetricItem({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + final IconData icon; + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.56), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _MetricDivider extends StatelessWidget { + const _MetricDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Colors.white.withValues(alpha: 0.08), ); } } @@ -147,71 +279,157 @@ class _QuickActions extends StatelessWidget { @override Widget build(BuildContext context) { - return AppSectionCard( - title: '今日动作', - subtitle: '高频入口', - child: Column( - children: [ - _ActionTile( - icon: Icons.description_outlined, - title: '发起审查', - subtitle: '上传合同或粘贴文本', - onTap: onStartReview, - accentColor: BrandAssets.brand, - primary: true, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionHeader( + title: '今日动作', + subtitle: '常用功能入口', + ), + const SizedBox(height: BrandAssets.spaceLg), + AppActionTile( + icon: Icons.description_outlined, + title: '发起审查', + subtitle: '上传合同或粘贴文本进行审查', + onTap: onStartReview, + accentColor: BrandAssets.brand, + primary: true, + ), + const SizedBox(height: BrandAssets.spaceLg), + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth < 360 ? 2 : 3; + // Lower aspect ratio = taller cards to fit content without overflow + final aspectRatio = constraints.maxWidth < 360 ? 0.95 : 1.05; + + return GridView.count( + shrinkWrap: true, + crossAxisCount: crossAxisCount, + mainAxisSpacing: BrandAssets.spaceSm, + crossAxisSpacing: BrandAssets.spaceSm, + childAspectRatio: aspectRatio, + physics: const NeverScrollableScrollPhysics(), + children: [ + _ActionCard( + icon: Icons.history_outlined, + title: '审查记录', + subtitle: '状态与风险结果', + onTap: onOpenHistory, + accentColor: BrandAssets.accentCyan, + ), + _ActionCard( + icon: Icons.gavel_outlined, + title: '法律问答', + subtitle: '围绕条款追问', + onTap: onOpenLegalChat, + accentColor: BrandAssets.accentAmber, + ), + _ActionCard( + icon: Icons.edit_note_outlined, + title: '合同生成', + subtitle: '模板与历史', + onTap: onOpenContracts, + accentColor: BrandAssets.primaryDark, + ), + _ActionCard( + icon: Icons.post_add_outlined, + title: '文书起草', + subtitle: '动态要素草稿', + onTap: onOpenDocuments, + accentColor: BrandAssets.mint, + ), + _ActionCard( + icon: Icons.person_outline, + title: '账户信息', + subtitle: '角色与会话', + onTap: onOpenProfile, + accentColor: BrandAssets.accentCoral, + ), + ], + ); + }, + ), + ], + ); + } +} + +class _ActionCard extends StatelessWidget { + const _ActionCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + required this.accentColor, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + final Color accentColor; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + child: Ink( + decoration: BoxDecoration( + color: BrandAssets.surface, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all(color: BrandAssets.cardBorder), + boxShadow: const [BrandAssets.shadowSm], ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (context, constraints) { - final aspectRatio = constraints.maxWidth < 360 ? 1.15 : 1.4; - return GridView.count( - shrinkWrap: true, - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: aspectRatio, - physics: const NeverScrollableScrollPhysics(), - children: [ - _ActionTile( - icon: Icons.history_outlined, - title: '审查记录', - subtitle: '查看状态与风险结果', - onTap: onOpenHistory, - accentColor: BrandAssets.accentCyan, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all( + color: accentColor.withValues(alpha: 0.2), + width: 1.5, + ), ), - _ActionTile( - icon: Icons.gavel_outlined, - title: '法律问答', - subtitle: '围绕条款继续追问', - onTap: onOpenLegalChat, - accentColor: BrandAssets.accentAmber, + child: Icon(icon, color: accentColor, size: 20), + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: BrandAssets.muted, + fontSize: 10, + fontWeight: FontWeight.w400, + height: 1.2, ), - _ActionTile( - icon: Icons.edit_note_outlined, - title: '合同生成', - subtitle: '模板、预览与历史', - onTap: onOpenContracts, - accentColor: BrandAssets.primaryDark, - ), - _ActionTile( - icon: Icons.post_add_outlined, - title: '文书起草', - subtitle: '动态要素与草稿', - onTap: onOpenDocuments, - accentColor: BrandAssets.accentAmber, - ), - _ActionTile( - icon: Icons.person_outline, - title: '账户信息', - subtitle: '角色、租户与会话', - onTap: onOpenProfile, - accentColor: BrandAssets.accentCoral, - ), - ], - ); - }, + ), + ], + ), ), - ], + ), ), ); } @@ -234,22 +452,33 @@ class _RecentReviews extends StatelessWidget { Widget build(BuildContext context) { final latestItems = items.take(3).toList(growable: false); - return AppSectionCard( - title: '最近审查', - subtitle: '优先回看刚完成或仍在处理中的任务。', - trailing: IconButton( - onPressed: onRefresh, - tooltip: '刷新', - icon: const Icon(Icons.refresh), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (latestItems.isEmpty) - const Text('暂无审查记录。') - else - for (final item in latestItems) ...[ - _ReviewPreview( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionHeader( + title: '最近审查', + subtitle: '优先回看刚完成或处理中的任务', + trailing: IconButton( + onPressed: onRefresh, + tooltip: '刷新', + icon: const Icon(Icons.refresh_rounded), + style: IconButton.styleFrom( + foregroundColor: BrandAssets.muted, + ), + ), + ), + const SizedBox(height: BrandAssets.spaceLg), + if (latestItems.isEmpty) + _EmptyState(onStartReview: onOpenHistory) + else + ...latestItems.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index < latestItems.length - 1 ? BrandAssets.spaceMd : 0, + ), + child: _ReviewPreview( item: item, onTap: () { final handler = onOpenReviewDetail; @@ -258,152 +487,19 @@ class _RecentReviews extends StatelessWidget { } }, ), - if (item != latestItems.last) const Divider(height: 24), - ], - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: onOpenHistory, - icon: const Icon(Icons.arrow_forward), - label: const Text('查看全部记录'), + ); + }), + const SizedBox(height: BrandAssets.spaceLg), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onOpenHistory, + icon: const Icon(Icons.arrow_forward_rounded, size: 18), + label: const Text('查看全部记录'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(48, 48), ), ), - ], - ), - ); - } -} - -class _ActionTile extends StatelessWidget { - const _ActionTile({ - required this.icon, - required this.title, - required this.subtitle, - required this.onTap, - required this.accentColor, - this.primary = false, - }); - - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onTap; - final Color accentColor; - final bool primary; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(BrandAssets.radius), - child: Container( - padding: EdgeInsets.all(primary ? 18 : 14), - decoration: BoxDecoration( - color: primary ? BrandAssets.surfaceInk : BrandAssets.surface, - borderRadius: BorderRadius.circular(BrandAssets.radius), - border: Border.all( - color: primary ? Colors.transparent : BrandAssets.line), - boxShadow: - primary ? const [BrandAssets.panelShadow] : null, - ), - child: primary - ? Row( - children: [ - _ActionIcon( - icon: icon, - accentColor: BrandAssets.accentLime, - primary: true), - const SizedBox(width: 14), - Expanded( - child: _ActionCopy( - title: title, subtitle: subtitle, primary: true)), - const Icon(Icons.arrow_forward_rounded, color: Colors.white), - ], - ) - : Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _ActionIcon(icon: icon, accentColor: accentColor), - const SizedBox(width: 12), - Expanded( - child: _ActionCopy( - title: title, subtitle: subtitle, primary: false), - ), - ], - ), - ), - ), - ); - } -} - -class _ActionIcon extends StatelessWidget { - const _ActionIcon({ - required this.icon, - required this.accentColor, - this.primary = false, - }); - - final IconData icon; - final Color accentColor; - final bool primary; - - @override - Widget build(BuildContext context) { - return Container( - width: primary ? 46 : 40, - height: primary ? 46 : 40, - decoration: BoxDecoration( - color: primary - ? Colors.white.withValues(alpha: 0.1) - : accentColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(BrandAssets.radius), - border: Border.all( - color: accentColor.withValues(alpha: primary ? 0.36 : 0.18)), - ), - child: Icon(icon, color: primary ? BrandAssets.accentLime : accentColor), - ); - } -} - -class _ActionCopy extends StatelessWidget { - const _ActionCopy({ - required this.title, - required this.subtitle, - this.primary = false, - }); - - final String title; - final String subtitle; - final bool primary; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: primary ? Colors.white : BrandAssets.ink, - ), - ), - const SizedBox(height: 3), - Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: primary - ? Colors.white.withValues(alpha: 0.72) - : BrandAssets.muted, - ), ), ], ); @@ -421,53 +517,86 @@ class _ReviewPreview extends StatelessWidget { @override Widget build(BuildContext context) { - return Semantics( - label: 'open-review-preview-${item.id}', - button: true, + final statusColor = switch (item.status) { + 'completed' => BrandAssets.success, + 'processing' => BrandAssets.accentCyan, + 'partial' => BrandAssets.warning, + 'pending' => BrandAssets.muted, + _ => BrandAssets.muted, + }; + + return Material( + color: Colors.transparent, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(BrandAssets.radius), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), child: Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(BrandAssets.spaceLg), decoration: BoxDecoration( color: BrandAssets.surfaceSoft, - borderRadius: BorderRadius.circular(BrandAssets.radius), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), border: Border.all(color: BrandAssets.line), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( - width: 40, - height: 40, + width: 44, + height: 44, decoration: BoxDecoration( - color: const Color(0xFFE0F7F4), - borderRadius: BorderRadius.circular(8), + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + border: Border.all( + color: statusColor.withValues(alpha: 0.2), + width: 1.5, + ), + ), + child: Icon( + Icons.article_outlined, + color: statusColor, + size: 22, ), - child: const Icon(Icons.article_outlined, - color: BrandAssets.brand), ), - const SizedBox(width: 12), + const SizedBox(width: BrandAssets.spaceLg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.title, - style: Theme.of(context).textTheme.titleSmall), + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), const SizedBox(height: 4), Text( '${ReviewLabels.contractType(item.contractType)} · ${ReviewLabels.status(item.status)}', style: Theme.of(context).textTheme.bodySmall, ), - if (item.riskLevel != null && item.riskLevel!.isNotEmpty) - Text( - ReviewLabels.riskLevel(item.riskLevel!), - style: Theme.of(context).textTheme.bodySmall, + if (item.riskLevel != null && item.riskLevel!.isNotEmpty) ...[ + const SizedBox(height: 6), + AppStatusBadge( + label: ReviewLabels.riskLevel(item.riskLevel!), + status: item.riskLevel == 'high' + ? BadgeStatus.error + : item.riskLevel == 'medium' + ? BadgeStatus.warning + : BadgeStatus.success, + size: BadgeSize.small, ), + ], ], ), ), + const SizedBox(width: BrandAssets.spaceMd), + Icon( + Icons.chevron_right_rounded, + color: BrandAssets.muted, + size: 20, + ), ], ), ), @@ -475,3 +604,61 @@ class _ReviewPreview extends StatelessWidget { ); } } + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.onStartReview}); + + final VoidCallback onStartReview; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(BrandAssets.spaceXXl), + decoration: BoxDecoration( + color: BrandAssets.surfaceSoft, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all(color: BrandAssets.line), + ), + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: BrandAssets.brand.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + ), + child: Icon( + Icons.history_outlined, + color: BrandAssets.brand, + size: 28, + ), + ), + const SizedBox(height: BrandAssets.spaceLg), + Text( + '暂无审查记录', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: BrandAssets.muted, + ), + ), + const SizedBox(height: BrandAssets.spaceSm), + Text( + '开始你的第一次合同审查吧', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: BrandAssets.spaceLg), + FilledButton.icon( + onPressed: onStartReview, + icon: const Icon(Icons.add, size: 18), + label: const Text('发起审查'), + style: FilledButton.styleFrom( + backgroundColor: BrandAssets.brand, + minimumSize: const Size(48, 44), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/mobile/lib/shared/widgets/app_scaffold_page.dart b/mobile/lib/shared/widgets/app_scaffold_page.dart index 7bd8dc7a..85e9c406 100644 --- a/mobile/lib/shared/widgets/app_scaffold_page.dart +++ b/mobile/lib/shared/widgets/app_scaffold_page.dart @@ -18,12 +18,31 @@ class AppScaffoldPage extends StatelessWidget { builder: (context, constraints) { return Stack( children: [ - const Positioned.fill( - child: CustomPaint(painter: _WorkbenchGridPainter()), + // Gradient background + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xF6FFFFFF), + BrandAssets.canvas, + Color(0xF0F4F7F6), + ], + stops: [0.0, 0.5, 1.0], + ), + ), + ), ), + // Dot pattern overlay + const Positioned.fill( + child: CustomPaint(painter: _DotPatternPainter()), + ), + // Main content SingleChildScrollView( physics: physics, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 28), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight - 44), @@ -37,36 +56,51 @@ class AppScaffoldPage extends StatelessWidget { } } -class _WorkbenchGridPainter extends CustomPainter { - const _WorkbenchGridPainter(); +class _DotPatternPainter extends CustomPainter { + const _DotPatternPainter(); @override void paint(Canvas canvas, Size size) { - final wash = Paint() - ..shader = const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xEFFFFFFF), - BrandAssets.canvas, - Color(0xFFEFF4F1), - ], - ).createShader(Offset.zero & size); - canvas.drawRect(Offset.zero & size, wash); - - final paint = Paint() - ..color = const Color(0x0710181F) + // Subtle grid lines + final gridPaint = Paint() + ..color = const Color(0x0510181F) ..strokeWidth = 1; - const step = 80.0; + const step = 60.0; for (double x = 0; x < size.width; x += step) { - canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); } for (double y = 0; y < size.height; y += step) { - canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); } + + // Dot pattern at intersections + final dotPaint = Paint() + ..color = const Color(0x0810181F); + + const dotRadius = 1.5; + for (double x = 0; x < size.width; x += step) { + for (double y = 0; y < size.height; y += step) { + canvas.drawCircle(Offset(x, y), dotRadius, dotPaint); + } + } + + // Subtle gradient fade at top + final gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0x10FFFFFF), + const Color(0x00FFFFFF), + ], + ); + + final rect = Rect.fromLTWH(0, 0, size.width, size.height * 0.15); + final gradientPaint = Paint() + ..shader = gradient.createShader(rect); + canvas.drawRect(rect, gradientPaint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} +} \ No newline at end of file diff --git a/mobile/lib/shared/widgets/app_section_card.dart b/mobile/lib/shared/widgets/app_section_card.dart index 55e64c60..cbc1362f 100644 --- a/mobile/lib/shared/widgets/app_section_card.dart +++ b/mobile/lib/shared/widgets/app_section_card.dart @@ -9,23 +9,49 @@ class AppSectionCard extends StatelessWidget { this.subtitle, required this.child, this.trailing, + this.variant = CardVariant.standard, }); final String title; final String? subtitle; final Widget child; final Widget? trailing; + final CardVariant variant; @override Widget build(BuildContext context) { + final decoration = switch (variant) { + CardVariant.standard => BoxDecoration( + color: BrandAssets.surface, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all(color: BrandAssets.cardBorder, width: 1), + boxShadow: const [BrandAssets.cardShadow], + ), + CardVariant.elevated => BoxDecoration( + color: BrandAssets.surface, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + boxShadow: const [BrandAssets.shadowMd], + ), + CardVariant.tinted => BoxDecoration( + color: BrandAssets.surfaceTinted, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all( + color: BrandAssets.brand.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: const [BrandAssets.shadowSm], + ), + CardVariant.glass => BoxDecoration( + gradient: BrandAssets.glassGradient, + borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + boxShadow: const [BrandAssets.shadowSm], + ), + }; + return Container( width: double.infinity, - decoration: BoxDecoration( - color: BrandAssets.surface, - borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), - border: Border.all(color: BrandAssets.cardBorder), - boxShadow: const [BrandAssets.cardShadow], - ), + decoration: decoration, child: ClipRRect( borderRadius: BorderRadius.circular(BrandAssets.radiusMedium), child: Padding( @@ -37,20 +63,31 @@ class AppSectionCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: BrandAssets.spaceXs), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BrandAssets.muted, + ), ), + ], + ], ), ), if (trailing != null) trailing!, ], ), - if (subtitle != null && subtitle!.isNotEmpty) ...[ - const SizedBox(height: BrandAssets.spaceXs), - Text(subtitle!, style: Theme.of(context).textTheme.bodySmall), - ], const SizedBox(height: BrandAssets.spaceLg), child, ], @@ -60,3 +97,238 @@ class AppSectionCard extends StatelessWidget { ); } } + +enum CardVariant { standard, elevated, tinted, glass } + +class AppActionTile extends StatelessWidget { + const AppActionTile({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + required this.accentColor, + this.primary = false, + this.showArrow = true, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + final Color accentColor; + final bool primary; + final bool showArrow; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + child: AnimatedContainer( + duration: BrandAssets.durationSm, + curve: BrandAssets.curveSmooth, + padding: const EdgeInsets.all(BrandAssets.spaceLg), + decoration: BoxDecoration( + color: primary ? BrandAssets.surfaceInk : BrandAssets.surface, + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + border: Border.all( + color: primary ? Colors.transparent : BrandAssets.line, + width: 1, + ), + boxShadow: primary + ? const [BrandAssets.panelShadow] + : const [BrandAssets.shadowXS], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: primary ? 48 : 44, + height: primary ? 48 : 44, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: primary ? 0.15 : 0.1), + borderRadius: BorderRadius.circular(BrandAssets.radiusSmall), + border: Border.all( + color: accentColor.withValues(alpha: primary ? 0.35 : 0.2), + width: 1.5, + ), + ), + child: Icon( + icon, + color: primary ? BrandAssets.accentLime : accentColor, + size: primary ? 24 : 20, + ), + ), + const SizedBox(width: BrandAssets.spaceLg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: primary ? Colors.white : BrandAssets.ink, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: primary + ? Colors.white.withValues(alpha: 0.7) + : BrandAssets.muted, + ), + ), + ], + ), + ), + if (showArrow) ...[ + const SizedBox(width: BrandAssets.spaceMd), + Icon( + Icons.arrow_forward_ios_rounded, + color: primary + ? Colors.white.withValues(alpha: 0.7) + : accentColor, + size: 16, + ), + ], + ], + ), + ), + ), + ); + } +} + +class AppStatusBadge extends StatelessWidget { + const AppStatusBadge({ + super.key, + required this.label, + required this.status, + this.size = BadgeSize.medium, + }); + + final String label; + final BadgeStatus status; + final BadgeSize size; + + @override + Widget build(BuildContext context) { + final (color, bgColor) = switch (status) { + BadgeStatus.success => (BrandAssets.success, BrandAssets.success.withValues(alpha: 0.12)), + BadgeStatus.warning => (BrandAssets.warning, BrandAssets.warning.withValues(alpha: 0.12)), + BadgeStatus.error => (BrandAssets.error, BrandAssets.error.withValues(alpha: 0.12)), + BadgeStatus.info => (BrandAssets.info, BrandAssets.info.withValues(alpha: 0.12)), + BadgeStatus.pending => (BrandAssets.muted, BrandAssets.canvasDark), + BadgeStatus.processing => (BrandAssets.accentCyan, BrandAssets.accentCyan.withValues(alpha: 0.12)), + }; + + final (fontSize, padding, iconSize) = switch (size) { + BadgeSize.small => (10.0, const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 12.0), + BadgeSize.medium => (11.5, const EdgeInsets.symmetric(horizontal: 10, vertical: 5), 14.0), + BadgeSize.large => (13.0, const EdgeInsets.symmetric(horizontal: 12, vertical: 7), 16.0), + }; + + return Container( + padding: padding, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(BrandAssets.radiusPill), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: iconSize * 0.6, + height: iconSize * 0.6, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 4, + offset: const Offset(0, 0), + ), + ], + ), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: color, + fontSize: fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} + +enum BadgeStatus { success, warning, error, info, pending, processing } +enum BadgeSize { small, medium, large } + +class AppSectionHeader extends StatelessWidget { + const AppSectionHeader({ + super.key, + required this.title, + this.subtitle, + this.trailing, + this.showDivider = false, + }); + + final String title; + final String? subtitle; + final Widget? trailing; + final bool showDivider; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + ), + if (trailing != null) trailing!, + ], + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: BrandAssets.spaceXs), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BrandAssets.muted, + ), + ), + ], + if (showDivider) ...[ + const SizedBox(height: BrandAssets.spaceMd), + const Divider(color: BrandAssets.divider, thickness: 1), + ], + ], + ); + } +} \ No newline at end of file