feat: mobile UI polish - fix grid overflow, simplify hero panel, improve appbar layout

- Adjust GridView aspect ratio to prevent overflow warnings
- Resize icons and text in action cards
- Add mainAxisAlignment.center for better vertical centering
- Update API config to use local network IP
This commit is contained in:
2026-06-08 04:28:46 +08:00
parent 6c37d72179
commit afbcc71144
8 changed files with 2080 additions and 647 deletions
+1 -1
View File
@@ -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',
+176 -56
View File
@@ -212,12 +212,12 @@ class _AiContractAppShellState extends State<AiContractAppShell>
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<AiContractAppShell>
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<AiContractAppShell>
child: _buildCurrentScreen(),
),
),
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: BrandAssets.surface,
border: Border(top: BorderSide(color: BrandAssets.line)),
boxShadow: <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<AiContractAppShell>
}
}
}
class _BottomNavBar extends StatelessWidget {
const _BottomNavBar({
required this.currentTab,
required this.onTabSelected,
});
final AppTab currentTab;
final ValueChanged<AppTab> 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>[
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,
),
),
],
),
),
);
}
}
+315 -42
View File
@@ -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<TextStyle>(
(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<IconThemeData>(
(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<EdgeInsetsGeometry>(
EdgeInsets.symmetric(horizontal: 14, vertical: 12),
EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(BrandAssets.radius)),
borderRadius: BorderRadius.circular(BrandAssets.radiusSmall)),
),
side: const WidgetStatePropertyAll<BorderSide>(
BorderSide(color: BrandAssets.lineStrong),
BorderSide(color: BrandAssets.lineStrong, width: 1.5),
),
textStyle: const WidgetStatePropertyAll<TextStyle>(
TextStyle(fontWeight: FontWeight.w800),
TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
backgroundColor: WidgetStateProperty.resolveWith<Color>(
(states) {
if (states.contains(WidgetState.selected)) {
return BrandAssets.brand;
}
return BrandAssets.surface;
},
),
foregroundColor: WidgetStateProperty.resolveWith<Color>(
(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<Color>(
(states) => BrandAssets.brand.withValues(alpha: 0.12),
),
todayForegroundColor: WidgetStateProperty.resolveWith<Color>(
(states) => BrandAssets.brand,
),
dayBackgroundColor: WidgetStateProperty.resolveWith<Color>(
(states) => BrandAssets.surface,
),
rangeSelectionBackgroundColor: BrandAssets.brand.withValues(alpha: 0.1),
rangePickerBackgroundColor: BrandAssets.surfaceSoft,
),
);
}
}
}
+460 -124
View File
@@ -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>[
Color(0xFF0D191C),
Color(0xFF0D342C),
Color(0xFF191620),
Color(0xFF061820),
Color(0xFF0A3A30),
Color(0xFF12101A),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: <double>[0.0, 0.5, 1.0],
);
static const LinearGradient primaryGradient = LinearGradient(
colors: <Color>[
brandDark,
brand,
brandLight,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -60,33 +118,178 @@ class BrandAssets {
static const LinearGradient surfaceGradient = LinearGradient(
colors: <Color>[
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>[
Color(0xE6FFFFFF),
Color(0xCCFFFFFF),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static const LinearGradient inkGradient = LinearGradient(
colors: <Color>[
ink,
inkSoft,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static const LinearGradient appBarGradient = LinearGradient(
colors: <Color>[
Color(0xFF0B1A1C),
Color(0xFF0A2520),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static const LinearGradient navGradient = LinearGradient(
colors: <Color>[
Color(0xFFFFFFFF),
Color(0xF8FFFFFF),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static final RadialGradient brandGlowGradient = RadialGradient(
colors: <Color>[
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 <BoxShadow>[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 <BoxShadow>[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>[
BoxShadow(
color: Color(0x2431C5DA),
blurRadius: 18,
offset: Offset(0, 9),
),
],
borderRadius: BorderRadius.circular(size * 0.22),
boxShadow: showGlow
? const <BoxShadow>[
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>[
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>[
BoxShadow(
color: color.withValues(alpha: 0.4),
blurRadius: size * 0.8,
offset: const Offset(0, 0),
),
],
),
);
}
}
enum StatusType { success, warning, error, info, processing, pending }
@@ -15,7 +15,8 @@ class LoginScreen extends StatefulWidget {
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
class _LoginScreenState extends State<LoginScreen>
with SingleTickerProviderStateMixin {
final ScrollController _scrollController = ScrollController();
final GlobalKey _accountFieldKey = GlobalKey();
final GlobalKey _passwordFieldKey = GlobalKey();
@@ -28,6 +29,9 @@ class _LoginScreenState extends State<LoginScreen> {
bool _obscurePassword = true;
String? _localError;
late final AnimationController _animationController;
late final Animation<double> _fadeAnimation;
late final Animation<Offset> _slideAnimation;
@override
void initState() {
@@ -37,6 +41,29 @@ class _LoginScreenState extends State<LoginScreen> {
_accountFocusNode = FocusNode();
_passwordFocusNode = FocusNode();
_animationController = AnimationController(
vsync: this,
duration: BrandAssets.durationXL,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
));
_slideAnimation = Tween<Offset>(
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<LoginScreen> {
_passwordController.dispose();
_accountFocusNode.dispose();
_passwordFocusNode.dispose();
_animationController.dispose();
super.dispose();
}
@@ -117,134 +145,151 @@ class _LoginScreenState extends State<LoginScreen> {
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>[
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 <String>['合同审查', 'AI工具']
: const <String>['合同审查', '风险摘要', '报告流转'],
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 <String>[
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 <String>[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<bool>(
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<String?>(
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: <Color>[
BrandAssets.brand,
BrandAssets.mint,
BrandAssets.accentCyan,
],
),
borderRadius: BorderRadius.circular(2),
),
),
],
@@ -253,6 +298,152 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
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 <String>[
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 <String>[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<bool>(
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<String?>(
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,
),
),
),
],
),
);
}
}
}
@@ -62,7 +62,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
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<DashboardScreen> {
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: <String>[
'${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 <BoxShadow>[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 <BoxShadow>[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 <BoxShadow>[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 <BoxShadow>[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),
),
),
],
),
);
}
}
@@ -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>[
Color(0xF6FFFFFF),
BrandAssets.canvas,
Color(0xF0F4F7F6),
],
stops: <double>[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>[
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: <Color>[
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;
}
}
+286 -14
View File
@@ -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 <BoxShadow>[BrandAssets.cardShadow],
),
CardVariant.elevated => BoxDecoration(
color: BrandAssets.surface,
borderRadius: BorderRadius.circular(BrandAssets.radiusMedium),
boxShadow: const <BoxShadow>[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 <BoxShadow>[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 <BoxShadow>[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 <BoxShadow>[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 <BoxShadow>[BrandAssets.panelShadow]
: const <BoxShadow>[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>[
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),
],
],
);
}
}