afbcc71144
- 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
478 lines
15 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|