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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user