542 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			542 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:io';
 | |
| import 'dart:ui';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:go_router/go_router.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/pods/config.dart';
 | |
| import 'package:island/route.dart';
 | |
| import 'package:island/pods/userinfo.dart';
 | |
| import 'package:island/pods/websocket.dart';
 | |
| import 'package:island/services/responsive.dart';
 | |
| import 'package:material_symbols_icons/material_symbols_icons.dart';
 | |
| import 'package:path_provider/path_provider.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| import 'package:window_manager/window_manager.dart';
 | |
| 
 | |
| class AppScrollBehavior extends MaterialScrollBehavior {
 | |
|   @override
 | |
|   Set<PointerDeviceKind> get dragDevices => {
 | |
|     PointerDeviceKind.touch, // default
 | |
|     PointerDeviceKind.trackpad, // default
 | |
|     PointerDeviceKind.mouse, // add mouse dragging
 | |
|   };
 | |
| }
 | |
| 
 | |
| class WindowScaffold extends HookConsumerWidget {
 | |
|   final Widget child;
 | |
|   const WindowScaffold({super.key, required this.child});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final isMaximized = useState(false);
 | |
| 
 | |
|     // Add window resize listener for desktop platforms
 | |
|     useEffect(() {
 | |
|       if (!kIsWeb &&
 | |
|           (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | |
|         void saveWindowSize() {
 | |
|           windowManager.getBounds().then((bounds) {
 | |
|             final settingsNotifier = ref.read(
 | |
|               appSettingsNotifierProvider.notifier,
 | |
|             );
 | |
|             settingsNotifier.setWindowSize(bounds.size);
 | |
|           });
 | |
|         }
 | |
| 
 | |
|         // Save window size when app is about to close
 | |
|         WidgetsBinding.instance.addObserver(
 | |
|           _WindowSizeObserver(saveWindowSize),
 | |
|         );
 | |
| 
 | |
|         final maximizeListener = _WindowMaximizeListener(isMaximized);
 | |
|         windowManager.addListener(maximizeListener);
 | |
|         windowManager.isMaximized().then((max) => isMaximized.value = max);
 | |
| 
 | |
|         return () {
 | |
|           // Cleanup observer when widget is disposed
 | |
|           WidgetsBinding.instance.removeObserver(
 | |
|             _WindowSizeObserver(saveWindowSize),
 | |
|           );
 | |
|           windowManager.removeListener(maximizeListener);
 | |
|         };
 | |
|       }
 | |
|       return null;
 | |
|     }, []);
 | |
| 
 | |
|     final router = ref.watch(routerProvider);
 | |
| 
 | |
|     final pageActionsButton = [
 | |
|       if (router.canPop())
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.close),
 | |
|           onPressed: router.canPop() ? () => router.pop() : null,
 | |
|           iconSize: 16,
 | |
|           padding: EdgeInsets.all(8),
 | |
|           constraints: BoxConstraints(),
 | |
|           color: Theme.of(context).iconTheme.color,
 | |
|         )
 | |
|       else
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.home),
 | |
|           onPressed: () => router.go('/'),
 | |
|           iconSize: 16,
 | |
|           padding: EdgeInsets.all(8),
 | |
|           constraints: BoxConstraints(),
 | |
|           color: Theme.of(context).iconTheme.color,
 | |
|         ),
 | |
|       const Gap(8),
 | |
|     ];
 | |
| 
 | |
|     if (!kIsWeb &&
 | |
|         (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | |
|       return Shortcuts(
 | |
|         shortcuts: <LogicalKeySet, Intent>{
 | |
|           LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
 | |
|         },
 | |
|         child: Actions(
 | |
|           actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
 | |
|           child: Material(
 | |
|             color: Theme.of(context).colorScheme.surfaceContainer,
 | |
|             child: Stack(
 | |
|               fit: StackFit.expand,
 | |
|               children: [
 | |
|                 Column(
 | |
|                   children: [
 | |
|                     DragToMoveArea(
 | |
|                       child:
 | |
|                           Platform.isMacOS
 | |
|                               ? Stack(
 | |
|                                 alignment: Alignment.center,
 | |
|                                 children: [
 | |
|                                   if (isWideScreen(context))
 | |
|                                     Row(
 | |
|                                       children: [
 | |
|                                         const Spacer(),
 | |
|                                         ...pageActionsButton,
 | |
|                                       ],
 | |
|                                     )
 | |
|                                   else
 | |
|                                     SizedBox(height: 32),
 | |
|                                   Text(
 | |
|                                     'Solar Network',
 | |
|                                     textAlign: TextAlign.center,
 | |
|                                     style: TextStyle(
 | |
|                                       color:
 | |
|                                           Theme.of(
 | |
|                                             context,
 | |
|                                           ).colorScheme.onSurface,
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                 ],
 | |
|                               )
 | |
|                               : Row(
 | |
|                                 crossAxisAlignment: CrossAxisAlignment.center,
 | |
|                                 mainAxisAlignment: MainAxisAlignment.start,
 | |
|                                 children: [
 | |
|                                   Expanded(
 | |
|                                     child: Row(
 | |
|                                       children: [
 | |
|                                         Image.asset(
 | |
|                                           Theme.of(context).brightness ==
 | |
|                                                   Brightness.dark
 | |
|                                               ? 'assets/icons/icon-dark.png'
 | |
|                                               : 'assets/icons/icon.png',
 | |
|                                           width: 20,
 | |
|                                           height: 20,
 | |
|                                         ),
 | |
|                                         const SizedBox(width: 8),
 | |
|                                         Text(
 | |
|                                           'Solar Network',
 | |
|                                           textAlign: TextAlign.start,
 | |
|                                         ),
 | |
|                                       ],
 | |
|                                     ).padding(horizontal: 12, vertical: 5),
 | |
|                                   ),
 | |
|                                   IconButton(
 | |
|                                     icon: Icon(Symbols.minimize),
 | |
|                                     onPressed: () => windowManager.minimize(),
 | |
|                                     iconSize: 16,
 | |
|                                     padding: EdgeInsets.all(8),
 | |
|                                     constraints: BoxConstraints(),
 | |
|                                     color: Theme.of(context).iconTheme.color,
 | |
|                                   ),
 | |
|                                   IconButton(
 | |
|                                     icon: Icon(
 | |
|                                       isMaximized.value
 | |
|                                           ? Symbols.fullscreen_exit
 | |
|                                           : Symbols.fullscreen,
 | |
|                                     ),
 | |
|                                     onPressed: () async {
 | |
|                                       if (await windowManager.isMaximized()) {
 | |
|                                         windowManager.restore();
 | |
|                                       } else {
 | |
|                                         windowManager.maximize();
 | |
|                                       }
 | |
|                                     },
 | |
|                                     iconSize: 16,
 | |
|                                     padding: EdgeInsets.all(8),
 | |
|                                     constraints: BoxConstraints(),
 | |
|                                     color: Theme.of(context).iconTheme.color,
 | |
|                                   ),
 | |
|                                   IconButton(
 | |
|                                     icon: Icon(Symbols.close),
 | |
|                                     onPressed: () => windowManager.hide(),
 | |
|                                     iconSize: 16,
 | |
|                                     padding: EdgeInsets.all(8),
 | |
|                                     constraints: BoxConstraints(),
 | |
|                                     color: Theme.of(context).iconTheme.color,
 | |
|                                   ),
 | |
|                                 ],
 | |
|                               ),
 | |
|                     ),
 | |
|                     Expanded(child: child),
 | |
|                   ],
 | |
|                 ),
 | |
|                 _WebSocketIndicator(),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Shortcuts(
 | |
|       shortcuts: <LogicalKeySet, Intent>{
 | |
|         LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
 | |
|       },
 | |
|       child: Actions(
 | |
|         actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
 | |
|         child: Stack(
 | |
|           fit: StackFit.expand,
 | |
|           children: [Positioned.fill(child: child), _WebSocketIndicator()],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _WindowSizeObserver extends WidgetsBindingObserver {
 | |
|   final VoidCallback onSaveWindowSize;
 | |
| 
 | |
|   _WindowSizeObserver(this.onSaveWindowSize);
 | |
| 
 | |
|   @override
 | |
|   void didChangeAppLifecycleState(AppLifecycleState state) {
 | |
|     super.didChangeAppLifecycleState(state);
 | |
| 
 | |
|     // Save window size when app is paused, detached, or hidden
 | |
|     if (state == AppLifecycleState.paused ||
 | |
|         state == AppLifecycleState.detached ||
 | |
|         state == AppLifecycleState.hidden) {
 | |
|       if (!kIsWeb &&
 | |
|           (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | |
|         onSaveWindowSize();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   bool operator ==(Object other) {
 | |
|     return other is _WindowSizeObserver &&
 | |
|         other.onSaveWindowSize == onSaveWindowSize;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   int get hashCode => onSaveWindowSize.hashCode;
 | |
| }
 | |
| 
 | |
| class _WindowMaximizeListener with WindowListener {
 | |
|   final ValueNotifier<bool> isMaximized;
 | |
|   _WindowMaximizeListener(this.isMaximized);
 | |
| 
 | |
|   @override
 | |
|   void onWindowMaximize() {
 | |
|     isMaximized.value = true;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void onWindowUnmaximize() {
 | |
|     isMaximized.value = false;
 | |
|   }
 | |
| }
 | |
| 
 | |
| final rootScaffoldKey = GlobalKey<ScaffoldState>();
 | |
| 
 | |
| class AppScaffold extends HookConsumerWidget {
 | |
|   final Widget? body;
 | |
|   final PreferredSizeWidget? bottomNavigationBar;
 | |
|   final PreferredSizeWidget? bottomSheet;
 | |
|   final Drawer? drawer;
 | |
|   final Widget? endDrawer;
 | |
|   final FloatingActionButtonAnimator? floatingActionButtonAnimator;
 | |
|   final FloatingActionButtonLocation? floatingActionButtonLocation;
 | |
|   final Widget? floatingActionButton;
 | |
|   final PreferredSizeWidget? appBar;
 | |
|   final DrawerCallback? onDrawerChanged;
 | |
|   final DrawerCallback? onEndDrawerChanged;
 | |
|   final bool? isNoBackground;
 | |
|   final bool? extendBody;
 | |
| 
 | |
|   const AppScaffold({
 | |
|     super.key,
 | |
|     this.appBar,
 | |
|     this.body,
 | |
|     this.floatingActionButton,
 | |
|     this.floatingActionButtonLocation,
 | |
|     this.floatingActionButtonAnimator,
 | |
|     this.bottomNavigationBar,
 | |
|     this.bottomSheet,
 | |
|     this.drawer,
 | |
|     this.endDrawer,
 | |
|     this.onDrawerChanged,
 | |
|     this.onEndDrawerChanged,
 | |
|     this.isNoBackground,
 | |
|     this.extendBody,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final focusNode = useFocusNode();
 | |
| 
 | |
|     useEffect(() {
 | |
|       focusNode.requestFocus();
 | |
|       return null;
 | |
|     }, []);
 | |
| 
 | |
|     final appBarHeight = appBar?.preferredSize.height ?? 0;
 | |
|     final safeTop = MediaQuery.of(context).padding.top;
 | |
| 
 | |
|     final noBackground = isNoBackground ?? isWideScreen(context);
 | |
| 
 | |
|     final builtWidget = Focus(
 | |
|       focusNode: focusNode,
 | |
|       child: Scaffold(
 | |
|         extendBody: extendBody ?? true,
 | |
|         extendBodyBehindAppBar: true,
 | |
|         backgroundColor: Colors.transparent,
 | |
|         body: Column(
 | |
|           children: [
 | |
|             IgnorePointer(
 | |
|               child: SizedBox(
 | |
|                 height: appBar != null ? appBarHeight + safeTop : 0,
 | |
|               ),
 | |
|             ),
 | |
|             if (body != null) Expanded(child: body!),
 | |
|           ],
 | |
|         ),
 | |
|         appBar: appBar,
 | |
|         bottomNavigationBar: bottomNavigationBar,
 | |
|         bottomSheet: bottomSheet,
 | |
|         drawer: drawer,
 | |
|         endDrawer: endDrawer,
 | |
|         floatingActionButton: floatingActionButton,
 | |
|         floatingActionButtonAnimator: floatingActionButtonAnimator,
 | |
|         onDrawerChanged: onDrawerChanged,
 | |
|         onEndDrawerChanged: onEndDrawerChanged,
 | |
|       ),
 | |
|     );
 | |
| 
 | |
|     return noBackground
 | |
|         ? builtWidget
 | |
|         : AppBackground(isRoot: true, child: builtWidget);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class PopIntent extends Intent {
 | |
|   const PopIntent();
 | |
| }
 | |
| 
 | |
| class PopAction extends Action<PopIntent> {
 | |
|   final WidgetRef ref;
 | |
| 
 | |
|   PopAction(this.ref);
 | |
| 
 | |
|   @override
 | |
|   void invoke(PopIntent intent) {
 | |
|     if (ref.watch(routerProvider).canPop()) {
 | |
|       ref.read(routerProvider).pop();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| class PageBackButton extends StatelessWidget {
 | |
|   final Color? color;
 | |
|   final List<Shadow>? shadows;
 | |
|   final VoidCallback? onWillPop;
 | |
|   final String? backTo;
 | |
|   const PageBackButton({
 | |
|     super.key,
 | |
|     this.shadows,
 | |
|     this.onWillPop,
 | |
|     this.color,
 | |
|     this.backTo,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final hasPageAction = !kIsWeb && Platform.isMacOS;
 | |
| 
 | |
|     if (hasPageAction && isWideScreen(context)) return const SizedBox.shrink();
 | |
| 
 | |
|     return IconButton(
 | |
|       onPressed: () {
 | |
|         onWillPop?.call();
 | |
|         if (context.canPop()) {
 | |
|           context.pop();
 | |
|         } else {
 | |
|           context.go(backTo ?? '/');
 | |
|         }
 | |
|       },
 | |
|       icon: Icon(
 | |
|         color: color,
 | |
|         context.canPop()
 | |
|             ? (!kIsWeb && (Platform.isMacOS || Platform.isIOS))
 | |
|                 ? Symbols.arrow_back_ios_new
 | |
|                 : Symbols.arrow_back
 | |
|             : Symbols.home,
 | |
|         shadows: shadows,
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| const kAppBackgroundImagePath = 'island_app_background';
 | |
| 
 | |
| final backgroundImageFileProvider = FutureProvider<File?>((ref) async {
 | |
|   if (kIsWeb) return null;
 | |
|   final dir = await getApplicationSupportDirectory();
 | |
|   final path = '${dir.path}/$kAppBackgroundImagePath';
 | |
|   final file = File(path);
 | |
|   return file.existsSync() ? file : null;
 | |
| });
 | |
| 
 | |
| class AppBackground extends ConsumerWidget {
 | |
|   final Widget child;
 | |
|   final bool isRoot;
 | |
| 
 | |
|   const AppBackground({super.key, required this.child, this.isRoot = false});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final imageFileAsync = ref.watch(backgroundImageFileProvider);
 | |
|     final settings = ref.watch(appSettingsNotifierProvider);
 | |
| 
 | |
|     if (isRoot || !isWideScreen(context)) {
 | |
|       return imageFileAsync.when(
 | |
|         data: (file) {
 | |
|           if (file != null && settings.showBackgroundImage) {
 | |
|             return Container(
 | |
|               color: Theme.of(context).colorScheme.surface,
 | |
|               child: Container(
 | |
|                 decoration: BoxDecoration(
 | |
|                   backgroundBlendMode: BlendMode.darken,
 | |
|                   color: Theme.of(context).colorScheme.surface,
 | |
|                   image: DecorationImage(
 | |
|                     opacity: 0.2,
 | |
|                     image: FileImage(file),
 | |
|                     fit: BoxFit.cover,
 | |
|                   ),
 | |
|                 ),
 | |
|                 child: child,
 | |
|               ),
 | |
|             );
 | |
|           }
 | |
|           return Material(
 | |
|             color: Theme.of(context).colorScheme.surface,
 | |
|             child: child,
 | |
|           );
 | |
|         },
 | |
|         loading: () => const SizedBox(),
 | |
|         error:
 | |
|             (_, _) => Material(
 | |
|               color: Theme.of(context).colorScheme.surface,
 | |
|               child: child,
 | |
|             ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Material(color: Colors.transparent, child: child);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class EmptyPageHolder extends HookConsumerWidget {
 | |
|   const EmptyPageHolder({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final hasBackground =
 | |
|         ref.watch(backgroundImageFileProvider).valueOrNull != null;
 | |
|     if (hasBackground) {
 | |
|       return const SizedBox.shrink();
 | |
|     }
 | |
|     return Container(color: Theme.of(context).scaffoldBackgroundColor);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _WebSocketIndicator extends HookConsumerWidget {
 | |
|   const _WebSocketIndicator();
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final isDesktop =
 | |
|         !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
 | |
| 
 | |
|     final user = ref.watch(userInfoProvider);
 | |
|     final websocketState = ref.watch(websocketStateProvider);
 | |
|     final indicatorHeight =
 | |
|         MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 25);
 | |
| 
 | |
|     Color indicatorColor;
 | |
|     String indicatorText;
 | |
| 
 | |
|     if (websocketState == WebSocketState.connected()) {
 | |
|       indicatorColor = Colors.green;
 | |
|       indicatorText = 'connectionConnected';
 | |
|     } else if (websocketState == WebSocketState.connecting()) {
 | |
|       indicatorColor = Colors.teal;
 | |
|       indicatorText = 'connectionReconnecting';
 | |
|     } else {
 | |
|       indicatorColor = Colors.red;
 | |
|       indicatorText = 'connectionDisconnected';
 | |
|     }
 | |
| 
 | |
|     return AnimatedPositioned(
 | |
|       duration: Duration(milliseconds: 1850),
 | |
|       top:
 | |
|           user.value == null ||
 | |
|                   user.value == null ||
 | |
|                   websocketState == WebSocketState.connected()
 | |
|               ? -indicatorHeight
 | |
|               : 0,
 | |
|       curve: Curves.fastLinearToSlowEaseIn,
 | |
|       left: 0,
 | |
|       right: 0,
 | |
|       height: indicatorHeight,
 | |
|       child: IgnorePointer(
 | |
|         child: Material(
 | |
|           elevation:
 | |
|               user.value == null || websocketState == WebSocketState.connected()
 | |
|                   ? 0
 | |
|                   : 4,
 | |
|           child: AnimatedContainer(
 | |
|             duration: Duration(milliseconds: 300),
 | |
|             color: indicatorColor,
 | |
|             child: Center(
 | |
|               child:
 | |
|                   Text(
 | |
|                     indicatorText,
 | |
|                     style: TextStyle(color: Colors.white, fontSize: 16),
 | |
|                   ).tr(),
 | |
|             ).padding(top: MediaQuery.of(context).padding.top),
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |