Splash screen loading

This commit is contained in:
LittleSheep 2025-03-22 16:36:10 +08:00
parent 7052b5b635
commit 71b41d470a
11 changed files with 305 additions and 51 deletions

View File

@ -870,5 +870,16 @@
"chatUnjoined": "Unjoined Channel", "chatUnjoined": "Unjoined Channel",
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.", "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.", "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"
} }

View File

@ -868,5 +868,16 @@
"chatUnjoined": "未加入频道", "chatUnjoined": "未加入频道",
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。", "chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。", "chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
"chatJoin": "加入频道" "chatJoin": "加入频道",
"appInitStarting": "启动中",
"appInitNetwork": "正在初始化网络",
"appInitUserdata": "正在初始化用户数据",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密钥对",
"appInitStickers": "正在初始化贴图包",
"appInitUserDirectory": "正在初始化用户目录",
"appInitRealm": "正在初始化领域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成"
} }

View File

@ -864,5 +864,20 @@
"other": "登入時最多要求 {} 步驗證" "other": "登入時最多要求 {} 步驗證"
}, },
"authAlwaysRisky": "總是風險", "authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。" "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啓動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用户數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用户目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成"
} }

View File

@ -864,5 +864,20 @@
"other": "登入時最多要求 {} 步驗證" "other": "登入時最多要求 {} 步驗證"
}, },
"authAlwaysRisky": "總是風險", "authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。" "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啟動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用戶數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用戶目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成"
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:math' hide log;
import 'dart:ui'; import 'dart:ui';
import 'package:bitsdojo_window/bitsdojo_window.dart'; 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.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:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart'; import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart'; import 'package:surface/logger.dart';
import 'package:surface/providers/channel.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:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart'; import 'package:surface/widgets/menu_bar.dart';
import 'package:surface/widgets/version_label.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
@ -228,6 +232,9 @@ class _AppSplashScreen extends StatefulWidget {
} }
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async { void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) { 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<void> _initialize() async { Future<void> _initialize() async {
try { try {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
@ -299,34 +311,45 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
// The Network initialization must be done after the HomeWidget initialization // 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 save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again // The Network initialization will also save initialize the Config, so it not need to be initialized again
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent(); await sn.initializeUserAgent();
await sn.setConfigWithNative(); await sn.setConfigWithNative();
if (!mounted) return; if (!mounted) return;
_setPhaseText('userdata');
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await ua.initialize(); await ua.initialize();
if (!mounted) return; if (!mounted) return;
_setPhaseText('websocket');
final ws = context.read<WebSocketProvider>(); final ws = context.read<WebSocketProvider>();
await ws.tryConnect(); await ws.tryConnect();
if (!mounted) return; if (!mounted) return;
_setPhaseText('notification');
final notify = context.read<NotificationProvider>(); final notify = context.read<NotificationProvider>();
notify.listen(); notify.listen();
await notify.registerPushNotifications(); await notify.registerPushNotifications();
if (!mounted) return; if (!mounted) return;
_setPhaseText('keyPair');
final kp = context.read<KeyPairProvider>(); final kp = context.read<KeyPairProvider>();
await kp.reloadActive(); await kp.reloadActive();
kp.listen(); kp.listen();
if (!mounted) return; if (!mounted) return;
_setPhaseText('stickers');
final sticker = context.read<SnStickerProvider>(); final sticker = context.read<SnStickerProvider>();
await sticker.listSticker(); await sticker.listSticker();
if (!mounted) return; if (!mounted) return;
_setPhaseText('userDirectory');
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final userCacheSize = await ud.loadAccountCache(); await ud.loadAccountCache();
if (!mounted) return; if (!mounted) return;
_setPhaseText('realm');
final rm = context.read<SnRealmProvider>(); final rm = context.read<SnRealmProvider>();
await rm.refreshAvailableRealms(); await rm.refreshAvailableRealms();
logging.info('[Users] Loaded local user cache, size: $userCacheSize'); if (!mounted) return;
logging.info('[Bootstrap] Everything initialized!'); _setPhaseText('chat');
final ct = context.read<ChatChannelProvider>();
await ct.refreshAvailableChannels();
_setPhaseText('done');
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
await context.showErrorDialog(err); await context.showErrorDialog(err);
@ -402,6 +425,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
void initState() { void initState() {
super.initState(); super.initState();
_isBusy = true;
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener( _appLifecycleListener = AppLifecycleListener(
onExitRequested: _onExitRequested, onExitRequested: _onExitRequested,
@ -415,6 +439,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
_postInitialization(); _postInitialization();
_tryRequestRating(); _tryRequestRating();
_checkForUpdate(); _checkForUpdate();
setState(() => _isBusy = false);
}); });
} }
@ -504,7 +529,44 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
} }
}); });
return SizeChangedLayoutNotifier( 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<Offset> 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;
}

View File

@ -28,6 +28,19 @@ class ChatChannelProvider extends ChangeNotifier {
_rels = context.read<SnRealmProvider>(); _rels = context.read<SnRealmProvider>();
} }
final List<SnChannel> _availableChannels = List.empty(growable: true);
List<SnChannel> get availableChannels => _availableChannels;
Future<void> refreshAvailableChannels() async {
final stream = fetchChannels();
stream.listen((ele) {
_availableChannels.clear();
_availableChannels.addAll(ele);
notifyListeners();
});
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
await Future.wait( await Future.wait(
channels.map( channels.map(

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/types/realm.dart';
class AppNavDestination { class AppNavDestination {
final String label; final String label;
@ -143,4 +144,11 @@ class NavigationProvider extends ChangeNotifier {
_currentIndex = idx; _currentIndex = idx;
notifyListeners(); notifyListeners();
} }
SnRealm? focusedRealm;
void setFocusedRealm(SnRealm? realm) {
focusedRealm = realm;
notifyListeners();
}
} }

View File

@ -88,6 +88,7 @@ Future<ThemeData> createAppTheme(
TargetPlatform.windows: ZoomPageTransitionsBuilder(), TargetPlatform.windows: ZoomPageTransitionsBuilder(),
}, },
), ),
progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
); );
} }

View File

@ -65,7 +65,7 @@ class ChatMessage extends StatelessWidget {
key: Key('chat-message-${data.id}'), key: Key('chat-message-${data.id}'),
iconOnLeftSwipe: Symbols.reply, iconOnLeftSwipe: Symbols.reply,
iconOnRightSwipe: Symbols.edit, iconOnRightSwipe: Symbols.edit,
swipeSensitivity: 20, swipeSensitivity: 10,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null, onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
child: ContextMenuArea( child: ContextMenuArea(

View File

@ -176,7 +176,7 @@ class MarkdownTextContent extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio( child: AspectRatio(
aspectRatio: attachment.metadata['ratio'] ?? aspectRatio: attachment.metadata['ratio']?.toDouble() ??
switch (attachment.mimetype switch (attachment.mimetype
.split('/') .split('/')
.firstOrNull) { .firstOrNull) {

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:animations/animations.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -11,9 +12,11 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/navigation.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/sn_realm.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:surface/widgets/version_label.dart'; import 'package:surface/widgets/version_label.dart';
class AppNavigationDrawer extends StatefulWidget { class AppNavigationDrawer extends StatefulWidget {
@ -39,6 +42,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final sn = context.read<SnNetworkProvider>();
final nav = context.watch<NavigationProvider>(); final nav = context.watch<NavigationProvider>();
final cfg = context.watch<ConfigProvider>(); final cfg = context.watch<ConfigProvider>();
final rel = context.read<SnRealmProvider>(); final rel = context.read<SnRealmProvider>();
@ -72,42 +76,111 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
child: WindowTitleBarBox(), child: WindowTitleBarBox(),
), ),
Gap(MediaQuery.of(context).padding.top), 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( Expanded(
child: ListView( child: PageTransitionSwitcher(
padding: EdgeInsets.zero, duration: const Duration(milliseconds: 300),
children: [ transitionBuilder: (Widget child,
...rel.availableRealms.map((ele) { Animation<double> primaryAnimation,
return ListTile( Animation<double> secondaryAnimation) {
minTileHeight: 48, return SharedAxisTransition(
contentPadding: EdgeInsets.symmetric(horizontal: 24), animation: primaryAnimation,
leading: AccountImage( secondaryAnimation: secondaryAnimation,
content: ele.avatar, fillColor: Colors.transparent,
radius: 16, 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( Row(
@ -115,13 +188,17 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
children: nav.destinations.where((ele) => ele.isPinned).map( children: nav.destinations.where((ele) => ele.isPinned).map(
(ele) { (ele) {
return Expanded( return Expanded(
child: IconButton.filledTonal( child: Tooltip(
icon: ele.icon, message: ele.label.tr(),
color: Theme.of(context).colorScheme.onPrimaryContainer, child: IconButton.filledTonal(
onPressed: () { icon: ele.icon,
GoRouter.of(context).goNamed(ele.screen); color:
Scaffold.of(context).closeDrawer(); Theme.of(context).colorScheme.onPrimaryContainer,
}, onPressed: () {
GoRouter.of(context).goNamed(ele.screen);
Scaffold.of(context).closeDrawer();
},
),
), ),
); );
}, },