354 lines
11 KiB
Dart
354 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../services/notification_service.dart';
|
|
import '../../services/secure_chat_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../notifications/notifications_screen.dart';
|
|
import '../compose/compose_screen.dart';
|
|
import '../search/search_screen.dart';
|
|
import '../discover/discover_screen.dart';
|
|
import '../beacon/beacon_screen.dart';
|
|
import '../quips/create/quip_creation_flow.dart';
|
|
import '../secure_chat/secure_chat_full_screen.dart';
|
|
import '../../widgets/radial_menu_overlay.dart';
|
|
import '../../providers/quip_upload_provider.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
/// Root shell for the main tabs. The active tab is controlled by GoRouter's
|
|
/// [StatefulNavigationShell] so navigation state and tab selection stay in sync.
|
|
class HomeShell extends StatefulWidget {
|
|
final StatefulNavigationShell navigationShell;
|
|
|
|
const HomeShell({super.key, required this.navigationShell});
|
|
|
|
@override
|
|
State<HomeShell> createState() => _HomeShellState();
|
|
}
|
|
|
|
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|
bool _isRadialMenuVisible = false;
|
|
final SecureChatService _chatService = SecureChatService();
|
|
StreamSubscription<RemoteMessage>? _notifSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_chatService.startBackgroundSync();
|
|
_initNotificationListener();
|
|
}
|
|
|
|
void _initNotificationListener() {
|
|
_notifSub = NotificationService.instance.foregroundMessages.listen((message) {
|
|
if (mounted) {
|
|
NotificationService.instance.showNotificationBanner(context, message);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_chatService.stopBackgroundSync();
|
|
_notifSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
_chatService.startBackgroundSync();
|
|
} else if (state == AppLifecycleState.paused) {
|
|
_chatService.stopBackgroundSync();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentIndex = widget.navigationShell.currentIndex;
|
|
|
|
return Scaffold(
|
|
appBar: _buildAppBar(),
|
|
body: Stack(
|
|
children: [
|
|
NavigationShellScope(
|
|
currentIndex: currentIndex,
|
|
child: widget.navigationShell,
|
|
),
|
|
RadialMenuOverlay(
|
|
isVisible: _isRadialMenuVisible,
|
|
onDismiss: () => setState(() => _isRadialMenuVisible = false),
|
|
onPostTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const ComposeScreen(),
|
|
),
|
|
);
|
|
},
|
|
onQuipTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const QuipCreationFlow(),
|
|
),
|
|
);
|
|
},
|
|
onBeaconTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const BeaconScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: Transform.translate(
|
|
offset: const Offset(0, 12),
|
|
child: GestureDetector(
|
|
onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Outer Ring for Upload Progress
|
|
Consumer(
|
|
builder: (context, ref, child) {
|
|
final upload = ref.watch(quipUploadProvider);
|
|
|
|
if (!upload.isUploading && upload.progress == 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return SizedBox(
|
|
width: 64,
|
|
height: 64,
|
|
child: CircularProgressIndicator(
|
|
value: upload.progress,
|
|
strokeWidth: 4,
|
|
backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1),
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.navyBlue,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppTheme.navyBlue.withOpacity(0.4),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: const Icon(
|
|
Icons.add,
|
|
color: Colors.white,
|
|
size: 32,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
|
bottomNavigationBar: Padding(
|
|
padding: const EdgeInsets.only(bottom: 2),
|
|
child: BottomAppBar(
|
|
notchMargin: 8.0,
|
|
padding: EdgeInsets.zero,
|
|
height: 58,
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: const CircularNotchedRectangle(),
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(16),
|
|
topRight: Radius.circular(16),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_buildNavBarItem(
|
|
icon: Icons.home_outlined,
|
|
activeIcon: Icons.home,
|
|
index: 0,
|
|
label: 'Home',
|
|
),
|
|
_buildNavBarItem(
|
|
icon: Icons.play_circle_outline,
|
|
activeIcon: Icons.play_circle,
|
|
index: 1,
|
|
label: 'Quips',
|
|
),
|
|
const SizedBox(width: 48),
|
|
_buildNavBarItem(
|
|
icon: Icons.sensors_outlined,
|
|
activeIcon: Icons.sensors,
|
|
index: 2,
|
|
label: 'Beacon',
|
|
),
|
|
_buildNavBarItem(
|
|
icon: Icons.person_outline,
|
|
activeIcon: Icons.person,
|
|
index: 3,
|
|
label: 'Profile',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar() {
|
|
return AppBar(
|
|
title: InkWell(
|
|
onTap: () => widget.navigationShell.goBranch(0),
|
|
child: Image.asset(
|
|
'assets/images/toplogo.png',
|
|
height: 38,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
centerTitle: false,
|
|
elevation: 0,
|
|
backgroundColor: AppTheme.scaffoldBg,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
bottomLeft: Radius.circular(16),
|
|
bottomRight: Radius.circular(16),
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
|
tooltip: 'Discover',
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const DiscoverScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
|
tooltip: 'Messages',
|
|
onPressed: () {
|
|
Navigator.of(context, rootNavigator: true).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const SecureChatFullScreen(),
|
|
fullscreenDialog: true,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.notifications_none, color: AppTheme.navyBlue),
|
|
tooltip: 'Notifications',
|
|
onPressed: () {
|
|
showGeneralDialog(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
barrierLabel: 'Notifications',
|
|
barrierColor: Colors.black54,
|
|
transitionDuration: const Duration(milliseconds: 250),
|
|
pageBuilder: (context, _, __) {
|
|
final height = MediaQuery.of(context).size.height;
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(
|
|
bottom: Radius.circular(20),
|
|
),
|
|
child: SizedBox(
|
|
height: height * 0.9,
|
|
child: const NotificationsScreen(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
transitionBuilder: (context, animation, _, child) {
|
|
final curve = CurvedAnimation(
|
|
parent: animation,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
return SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0, -1),
|
|
end: Offset.zero,
|
|
).animate(curve),
|
|
child: child,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildNavBarItem({
|
|
required IconData icon,
|
|
required IconData activeIcon,
|
|
required int index,
|
|
required String label,
|
|
}) {
|
|
final isActive = widget.navigationShell.currentIndex == index;
|
|
return Expanded(
|
|
child: InkWell(
|
|
onTap: () => widget.navigationShell.goBranch(
|
|
index,
|
|
initialLocation: index == widget.navigationShell.currentIndex,
|
|
),
|
|
child: Container(
|
|
height: double.infinity,
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
isActive ? activeIcon : icon,
|
|
color: isActive ? AppTheme.navyBlue : Colors.grey,
|
|
size: 26,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Provides the current navigation shell index to descendants that need to
|
|
/// react (e.g. pausing quip playback when the tab is not active).
|
|
class NavigationShellScope extends InheritedWidget {
|
|
final int currentIndex;
|
|
|
|
const NavigationShellScope({
|
|
super.key,
|
|
required this.currentIndex,
|
|
required super.child,
|
|
});
|
|
|
|
static NavigationShellScope? of(BuildContext context) {
|
|
return context.dependOnInheritedWidgetOfExactType<NavigationShellScope>();
|
|
}
|
|
|
|
@override
|
|
bool updateShouldNotify(covariant NavigationShellScope oldWidget) {
|
|
return currentIndex != oldWidget.currentIndex;
|
|
}
|
|
}
|