Files
ai-contract-platform/mobile/lib/app/shell/app_shell.dart
T
yuqei afbcc71144 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
2026-06-08 04:28:46 +08:00

478 lines
15 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../features/auth/application/auth_controller.dart';
import '../../features/auth/domain/auth_session.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/contracts/presentation/generated_contract_result_screen.dart';
import '../../features/dashboard/presentation/dashboard_screen.dart';
import '../../features/documents/presentation/legal_document_type_list_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/review/presentation/review_entry_screen.dart';
import '../../features/review_history/presentation/review_detail_screen.dart';
import '../../features/review_history/presentation/review_history_screen.dart';
import '../../features/contracts/presentation/contract_template_list_screen.dart';
import '../../features/tools/presentation/tools_hub_screen.dart';
import '../../shared/network/api_client.dart';
import '../../shared/notifications/app_notification_action.dart';
import '../../shared/notifications/notification_service.dart';
import '../../shared/storage/session_store.dart';
import '../navigation/app_router.dart';
import '../theme/brand_assets.dart';
class AiContractAppShell extends StatefulWidget {
const AiContractAppShell({super.key});
@override
State<AiContractAppShell> createState() => _AiContractAppShellState();
}
class _AiContractAppShellState extends State<AiContractAppShell>
with WidgetsBindingObserver {
AppTab _currentTab = AppTab.dashboard;
late final AuthController _authController;
final ApiClient _apiClient = const ApiClient();
String? _activeSessionUserId;
bool _isHandlingNotificationAction = false;
@override
void initState() {
super.initState();
_authController = AuthController();
WidgetsBinding.instance.addObserver(this);
SessionStore.listenable.addListener(_handleSessionChanged);
NotificationService.instance.pendingAction
.addListener(_handleNotificationSignal);
unawaited(_syncNotificationLifecycle());
}
@override
void dispose() {
NotificationService.instance.pendingAction
.removeListener(_handleNotificationSignal);
SessionStore.listenable.removeListener(_handleSessionChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_syncNotificationLifecycle(runSyncNow: true));
}
}
void _selectTab(AppTab tab) {
setState(() {
_currentTab = tab;
});
}
void _openTools() {
setState(() {
_currentTab = AppTab.tools;
});
}
Future<void> _openContractWorkspace() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ContractTemplateListScreen(),
),
);
}
Future<void> _openDocumentWorkspace() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const LegalDocumentTypeListScreen(),
),
);
}
void _handleSessionChanged() {
unawaited(_syncNotificationLifecycle());
}
void _handleNotificationSignal() {
unawaited(_tryHandlePendingNotificationAction());
}
Future<void> _syncNotificationLifecycle({bool runSyncNow = false}) async {
final session = SessionStore.current;
if (session == null || !session.isAuthenticated) {
if (_activeSessionUserId != null) {
_activeSessionUserId = null;
await _safeNotificationCall(NotificationService.instance.onSignedOut);
}
return;
}
if (_activeSessionUserId != session.userId) {
_activeSessionUserId = session.userId;
await _safeNotificationCall(NotificationService.instance.onSignedIn);
runSyncNow = true;
}
if (runSyncNow) {
await _safeNotificationCall(
NotificationService.instance.syncReviewNotifications);
}
await _tryHandlePendingNotificationAction();
}
Future<void> _tryHandlePendingNotificationAction() async {
if (!mounted || _isHandlingNotificationAction) {
return;
}
final session = SessionStore.current;
final action = NotificationService.instance.pendingAction.value;
if (session == null || !session.isAuthenticated || action == null) {
return;
}
_isHandlingNotificationAction = true;
try {
switch (action.type) {
case AppNotificationActionType.reviewStatus:
final reviewId = action.reviewId;
if (reviewId == null || reviewId.isEmpty) {
break;
}
_selectTab(AppTab.history);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ReviewDetailScreen(
reviewId: reviewId,
initialTitle: action.reviewTitle?.isNotEmpty == true
? action.reviewTitle!
: '审查详情',
),
),
);
break;
case AppNotificationActionType.generatedContract:
final contractId = action.contractId;
if (contractId == null || contractId.isEmpty) {
break;
}
final contract = await _apiClient.getGeneratedContract(
session.accessToken,
contractId,
);
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => GeneratedContractResultScreen(contract: contract),
),
);
break;
}
NotificationService.instance.clearPendingAction();
} catch (_) {
// Keep the action and allow a later retry.
} finally {
_isHandlingNotificationAction = false;
}
}
Future<T?> _safeNotificationCall<T>(Future<T> Function() action) async {
try {
return await action();
} on MissingPluginException {
return null;
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AuthSession?>(
valueListenable: SessionStore.listenable,
builder: (context, session, _) {
final isAuthenticated = session?.isAuthenticated ?? false;
if (!isAuthenticated) {
return Scaffold(
resizeToAvoidBottomInset: true,
body: SafeArea(
child: LoginScreen(controller: _authController),
),
);
}
return Scaffold(
appBar: AppBar(
toolbarHeight: 64,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleSpacing: 16,
title: Row(
children: [
const BrandMark(size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'移动工作台',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
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: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
border:
Border.all(color: Colors.white.withValues(alpha: 0.12)),
),
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,
),
),
],
),
),
],
),
),
body: SafeArea(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
child: _buildCurrentScreen(),
),
),
bottomNavigationBar: _BottomNavBar(
currentTab: _currentTab,
onTabSelected: _selectTab,
),
);
},
);
}
Widget _buildCurrentScreen() {
switch (_currentTab) {
case AppTab.dashboard:
return DashboardScreen(
key: const ValueKey(AppTab.dashboard),
onStartReview: () => _selectTab(AppTab.review),
onOpenHistory: () => _selectTab(AppTab.history),
onOpenProfile: () => _selectTab(AppTab.profile),
onOpenLegalChat: _openTools,
onOpenContracts: _openContractWorkspace,
onOpenDocuments: _openDocumentWorkspace,
onOpenReviewDetail: (item) {
_selectTab(AppTab.history);
unawaited(
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ReviewDetailScreen(
reviewId: item.id,
initialTitle: item.title,
),
),
),
);
},
);
case AppTab.review:
return const ReviewEntryScreen(key: ValueKey(AppTab.review));
case AppTab.tools:
return const ToolsHubScreen(key: ValueKey(AppTab.tools));
case AppTab.history:
return ReviewHistoryScreen(
key: const ValueKey(AppTab.history),
onStartReview: () => _selectTab(AppTab.review),
);
case AppTab.profile:
return ProfileScreen(
key: const ValueKey(AppTab.profile),
onOpenHistory: () => _selectTab(AppTab.history),
);
}
}
}
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,
),
),
],
),
),
);
}
}