diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 3d33963..6f50ca8 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -870,5 +870,16 @@ "chatUnjoined": "Unjoined Channel", "chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.", "chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.", - "chatJoin": "Join the Channel" + "chatJoin": "Join the Channel", + "appInitStarting": "Starting", + "appInitNetwork": "Initializing Network", + "appInitUserdata": "Initializing User Data", + "appInitWebsocket": "Establishing Solar Link", + "appInitNotification": "Initializing Push Notifications", + "appInitKeyPair": "Initializing Key Pairs", + "appInitStickers": "Initializing Stickers", + "appInitUserDirectory": "Initializing User Directory", + "appInitRealm": "Initializing Realms", + "appInitChat": "Initializing Chat", + "appInitDone": "Completed" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index e4d3fa8..e53ccee 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -868,5 +868,16 @@ "chatUnjoined": "未加入频道", "chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。", "chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。", - "chatJoin": "加入频道" + "chatJoin": "加入频道", + "appInitStarting": "启动中", + "appInitNetwork": "正在初始化网络", + "appInitUserdata": "正在初始化用户数据", + "appInitWebsocket": "正在建立 Solar Link", + "appInitNotification": "正在初始化推送通知", + "appInitKeyPair": "正在初始化密钥对", + "appInitStickers": "正在初始化贴图包", + "appInitUserDirectory": "正在初始化用户目录", + "appInitRealm": "正在初始化领域信息", + "appInitChat": "正在初始化聊天", + "appInitDone": "完成" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index c9eaa67..187984d 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -864,5 +864,20 @@ "other": "登入時最多要求 {} 步驗證" }, "authAlwaysRisky": "總是風險", - "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。" + "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。", + "chatUnjoined": "未加入頻道", + "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。", + "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。", + "chatJoin": "加入頻道", + "appInitStarting": "啓動中", + "appInitNetwork": "正在初始化網絡", + "appInitUserdata": "正在初始化用户數據", + "appInitWebsocket": "正在建立 Solar Link", + "appInitNotification": "正在初始化推送通知", + "appInitKeyPair": "正在初始化密鑰對", + "appInitStickers": "正在初始化貼圖包", + "appInitUserDirectory": "正在初始化用户目錄", + "appInitRealm": "正在初始化領域信息", + "appInitChat": "正在初始化聊天", + "appInitDone": "完成" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index c64ab23..71ae5c4 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -864,5 +864,20 @@ "other": "登入時最多要求 {} 步驗證" }, "authAlwaysRisky": "總是風險", - "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。" + "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。", + "chatUnjoined": "未加入頻道", + "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。", + "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。", + "chatJoin": "加入頻道", + "appInitStarting": "啟動中", + "appInitNetwork": "正在初始化網絡", + "appInitUserdata": "正在初始化用戶數據", + "appInitWebsocket": "正在建立 Solar Link", + "appInitNotification": "正在初始化推送通知", + "appInitKeyPair": "正在初始化密鑰對", + "appInitStickers": "正在初始化貼圖包", + "appInitUserDirectory": "正在初始化用戶目錄", + "appInitRealm": "正在初始化領域信息", + "appInitChat": "正在初始化聊天", + "appInitDone": "完成" } diff --git a/lib/main.dart b/lib/main.dart index 3e49343..c2be15e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'dart:math' hide log; import 'dart:ui'; import 'package:bitsdojo_window/bitsdojo_window.dart'; @@ -12,6 +13,7 @@ import 'package:firebase_core/firebase_core.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:hotkey_manager/hotkey_manager.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -19,6 +21,7 @@ import 'package:provider/provider.dart'; import 'package:relative_time/relative_time.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:styled_widget/styled_widget.dart'; import 'package:surface/firebase_options.dart'; import 'package:surface/logger.dart'; import 'package:surface/providers/channel.dart'; @@ -46,6 +49,7 @@ import 'package:surface/router.dart'; import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/menu_bar.dart'; +import 'package:surface/widgets/version_label.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:version/version.dart'; import 'package:workmanager/workmanager.dart'; @@ -228,6 +232,9 @@ class _AppSplashScreen extends StatefulWidget { } class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { + bool _isBusy = false; + String _phaseText = 'appInitStarting'; + void _tryRequestRating() async { final prefs = await SharedPreferences.getInstance(); if (prefs.containsKey('first_boot_time')) { @@ -287,6 +294,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { } } + void _setPhaseText(String text) { + _phaseText = 'appInit${text.capitalize()}'.tr(); + if (mounted) setState(() {}); + } + Future _initialize() async { try { final cfg = context.read(); @@ -299,34 +311,45 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { // The Network initialization must be done after the HomeWidget initialization // The Network initialization will save the server url to the HomeWidget // The Network initialization will also save initialize the Config, so it not need to be initialized again + _setPhaseText('network'); final sn = context.read(); await sn.initializeUserAgent(); await sn.setConfigWithNative(); if (!mounted) return; + _setPhaseText('userdata'); final ua = context.read(); await ua.initialize(); if (!mounted) return; + _setPhaseText('websocket'); final ws = context.read(); await ws.tryConnect(); if (!mounted) return; + _setPhaseText('notification'); final notify = context.read(); notify.listen(); await notify.registerPushNotifications(); if (!mounted) return; + _setPhaseText('keyPair'); final kp = context.read(); await kp.reloadActive(); kp.listen(); if (!mounted) return; + _setPhaseText('stickers'); final sticker = context.read(); await sticker.listSticker(); if (!mounted) return; + _setPhaseText('userDirectory'); final ud = context.read(); - final userCacheSize = await ud.loadAccountCache(); + await ud.loadAccountCache(); if (!mounted) return; + _setPhaseText('realm'); final rm = context.read(); await rm.refreshAvailableRealms(); - logging.info('[Users] Loaded local user cache, size: $userCacheSize'); - logging.info('[Bootstrap] Everything initialized!'); + if (!mounted) return; + _setPhaseText('chat'); + final ct = context.read(); + await ct.refreshAvailableChannels(); + _setPhaseText('done'); } catch (err) { if (!mounted) return; await context.showErrorDialog(err); @@ -402,6 +425,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { void initState() { super.initState(); + _isBusy = true; if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { _appLifecycleListener = AppLifecycleListener( onExitRequested: _onExitRequested, @@ -415,6 +439,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { _postInitialization(); _tryRequestRating(); _checkForUpdate(); + setState(() => _isBusy = false); }); } @@ -504,7 +529,44 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { } }); return SizeChangedLayoutNotifier( - child: widget.child, + child: _isBusy + ? Material( + key: Key('app-splash-screen-$_isBusy'), + child: Stack( + children: [ + CustomPaint(painter: GraphPainter()), + Center( + child: Container( + constraints: const BoxConstraints( + maxWidth: 240, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/icon/icon.png', + width: 64, + height: 64, + color: + Theme.of(context).colorScheme.onSurface, + ), + Text('Solar Network').bold(), + AppVersionLabel(), + Gap(8), + Text( + _phaseText, + textAlign: TextAlign.center, + ), + Gap(16), + const LinearProgressIndicator(), + ], + ), + ), + ), + ], + ), + ) + : widget.child, ); }, ), @@ -512,3 +574,44 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { ); } } + +class GraphPainter extends CustomPainter { + final Random random = Random(); + final int numNodes = 20; + final double maxDistance = 100; // Max distance to draw a line + + @override + void paint(Canvas canvas, Size size) { + final paintNode = Paint()..color = Colors.white; + final paintEdge = Paint() + ..color = Colors.white.withOpacity(0.3) + ..strokeWidth = 1; + + // Generate random points + List nodes = List.generate( + numNodes, + (_) => Offset( + random.nextDouble() * size.width, + random.nextDouble() * size.height, + ), + ); + + // Draw edges between close nodes + for (var i = 0; i < nodes.length; i++) { + for (var j = i + 1; j < nodes.length; j++) { + double distance = (nodes[i] - nodes[j]).distance; + if (distance < maxDistance) { + canvas.drawLine(nodes[i], nodes[j], paintEdge); + } + } + } + + // Draw nodes + for (var node in nodes) { + canvas.drawCircle(node, 4, paintNode); + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/providers/channel.dart b/lib/providers/channel.dart index 98cfcc3..3aae13e 100644 --- a/lib/providers/channel.dart +++ b/lib/providers/channel.dart @@ -28,6 +28,19 @@ class ChatChannelProvider extends ChangeNotifier { _rels = context.read(); } + final List _availableChannels = List.empty(growable: true); + + List get availableChannels => _availableChannels; + + Future refreshAvailableChannels() async { + final stream = fetchChannels(); + stream.listen((ele) { + _availableChannels.clear(); + _availableChannels.addAll(ele); + notifyListeners(); + }); + } + Future _saveChannelToLocal(Iterable channels) async { await Future.wait( channels.map( diff --git a/lib/providers/navigation.dart b/lib/providers/navigation.dart index 37dbde9..9871d6f 100644 --- a/lib/providers/navigation.dart +++ b/lib/providers/navigation.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:surface/types/realm.dart'; class AppNavDestination { final String label; @@ -143,4 +144,11 @@ class NavigationProvider extends ChangeNotifier { _currentIndex = idx; notifyListeners(); } + + SnRealm? focusedRealm; + + void setFocusedRealm(SnRealm? realm) { + focusedRealm = realm; + notifyListeners(); + } } diff --git a/lib/theme.dart b/lib/theme.dart index 709cc96..3ff3392 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -88,6 +88,7 @@ Future createAppTheme( TargetPlatform.windows: ZoomPageTransitionsBuilder(), }, ), + progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false), ); } diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index 5585fe9..053548d 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -65,7 +65,7 @@ class ChatMessage extends StatelessWidget { key: Key('chat-message-${data.id}'), iconOnLeftSwipe: Symbols.reply, iconOnRightSwipe: Symbols.edit, - swipeSensitivity: 20, + swipeSensitivity: 10, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null, child: ContextMenuArea( diff --git a/lib/widgets/markdown_content.dart b/lib/widgets/markdown_content.dart index f5317cf..1991be9 100644 --- a/lib/widgets/markdown_content.dart +++ b/lib/widgets/markdown_content.dart @@ -176,7 +176,7 @@ class MarkdownTextContent extends StatelessWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( - aspectRatio: attachment.metadata['ratio'] ?? + aspectRatio: attachment.metadata['ratio']?.toDouble() ?? switch (attachment.mimetype .split('/') .firstOrNull) { diff --git a/lib/widgets/navigation/app_drawer_navigation.dart b/lib/widgets/navigation/app_drawer_navigation.dart index 30327c9..8016487 100644 --- a/lib/widgets/navigation/app_drawer_navigation.dart +++ b/lib/widgets/navigation/app_drawer_navigation.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:animations/animations.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -11,9 +12,11 @@ import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/config.dart'; import 'package:surface/providers/navigation.dart'; +import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_realm.dart'; import 'package:surface/providers/userinfo.dart'; import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/version_label.dart'; class AppNavigationDrawer extends StatefulWidget { @@ -39,6 +42,7 @@ class _AppNavigationDrawerState extends State { @override Widget build(BuildContext context) { final ua = context.read(); + final sn = context.read(); final nav = context.watch(); final cfg = context.watch(); final rel = context.read(); @@ -72,42 +76,111 @@ class _AppNavigationDrawerState extends State { child: WindowTitleBarBox(), ), Gap(MediaQuery.of(context).padding.top), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Solar Network').bold(), - AppVersionLabel(), - ], - ).padding( - horizontal: 32, - vertical: 12, - ), Expanded( - child: ListView( - padding: EdgeInsets.zero, - children: [ - ...rel.availableRealms.map((ele) { - return ListTile( - minTileHeight: 48, - contentPadding: EdgeInsets.symmetric(horizontal: 24), - leading: AccountImage( - content: ele.avatar, - radius: 16, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, + Animation primaryAnimation, + Animation secondaryAnimation) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + fillColor: Colors.transparent, + transitionType: SharedAxisTransitionType.horizontal, + child: child, + ); + }, + child: nav.focusedRealm == null + ? ListView( + key: const Key('realm-list-view'), + padding: EdgeInsets.zero, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Solar Network').bold(), + AppVersionLabel(), + ], + ).padding( + horizontal: 32, + vertical: 12, + ), + ...rel.availableRealms.map((ele) { + return ListTile( + minTileHeight: 48, + contentPadding: + EdgeInsets.symmetric(horizontal: 24), + leading: AccountImage( + content: ele.avatar, + radius: 16, + ), + title: Text(ele.name), + onTap: () { + if (Scaffold.of(context).isDrawerOpen) { + GoRouter.of(context).goNamed( + 'realmDetail', + pathParameters: { + 'alias': ele.alias, + }, + ); + Scaffold.of(context).closeDrawer(); + } + nav.setFocusedRealm(ele); + }, + ); + }), + ], + ) + : ListView( + key: ValueKey(nav.focusedRealm), + padding: EdgeInsets.zero, + children: [ + if (nav.focusedRealm!.banner != null) + AspectRatio( + aspectRatio: 16 / 9, + child: AutoResizeUniversalImage( + sn.getAttachmentUrl( + nav.focusedRealm!.banner!, + ), + fit: BoxFit.cover, + ), + ), + ListTile( + minTileHeight: 48, + tileColor: Theme.of(context) + .colorScheme + .surfaceContainer, + contentPadding: EdgeInsets.only( + left: 24, + right: 16, + ), + leading: AccountImage( + content: nav.focusedRealm!.avatar, + radius: 16, + ), + trailing: IconButton( + icon: const Icon(Symbols.close), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + onPressed: () { + nav.setFocusedRealm(null); + }, + ), + title: Text(nav.focusedRealm!.name), + onTap: () { + GoRouter.of(context).pushNamed( + 'realmDetail', + pathParameters: { + 'alias': nav.focusedRealm!.alias, + }, + ); + Scaffold.of(context).closeDrawer(); + }, + ), + ], ), - title: Text(ele.name), - onTap: () { - GoRouter.of(context).goNamed( - 'realmDetail', - pathParameters: { - 'alias': ele.alias, - }, - ); - Scaffold.of(context).closeDrawer(); - }, - ); - }), - ], ), ), Row( @@ -115,13 +188,17 @@ class _AppNavigationDrawerState extends State { children: nav.destinations.where((ele) => ele.isPinned).map( (ele) { return Expanded( - child: IconButton.filledTonal( - icon: ele.icon, - color: Theme.of(context).colorScheme.onPrimaryContainer, - onPressed: () { - GoRouter.of(context).goNamed(ele.screen); - Scaffold.of(context).closeDrawer(); - }, + child: Tooltip( + message: ele.label.tr(), + child: IconButton.filledTonal( + icon: ele.icon, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + onPressed: () { + GoRouter.of(context).goNamed(ele.screen); + Scaffold.of(context).closeDrawer(); + }, + ), ), ); },