Skip to content

Commit d773d2a

Browse files
committed
feat(home): add StartupScreen for initial app loading overlay
1 parent 9d864fd commit d773d2a

File tree

2 files changed

+303
-6
lines changed

2 files changed

+303
-6
lines changed

lib/home/startup_screen.dart

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
/*
2+
* Orion - Startup Screen
3+
* Copyright (C) 2025 Open Resin Alliance
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import 'dart:async';
19+
import 'dart:ui' as ui;
20+
21+
import 'package:flutter/material.dart';
22+
import 'package:orion/backend_service/providers/status_provider.dart';
23+
import 'package:orion/glasser/src/gradient_utils.dart';
24+
import 'package:orion/util/providers/theme_provider.dart';
25+
import 'package:orion/util/orion_config.dart';
26+
import 'package:provider/provider.dart';
27+
28+
/// Blocking startup overlay shown while the app awaits the initial backend
29+
/// connection. Displayed by [StartupGate] until
30+
/// [StatusProvider.hasEverConnected] becomes true.
31+
class StartupScreen extends StatefulWidget {
32+
const StartupScreen({super.key});
33+
34+
@override
35+
State<StartupScreen> createState() => _StartupScreenState();
36+
}
37+
38+
class _StartupScreenState extends State<StartupScreen>
39+
with TickerProviderStateMixin {
40+
late final AnimationController _logoController;
41+
late final AnimationController _loaderController;
42+
late final AnimationController _logoMoveController;
43+
late final AnimationController _backgroundController;
44+
late final Animation<double> _logoOpacity;
45+
late final Animation<double> _logoMove;
46+
late final Animation<double> _loaderOpacity;
47+
late final Animation<double> _backgroundOpacity;
48+
late final String _printerName;
49+
List<Color> _gradientColors = const [];
50+
Color _backgroundColor = Colors.black;
51+
52+
@override
53+
void initState() {
54+
super.initState();
55+
56+
_logoController = AnimationController(
57+
vsync: this,
58+
duration: const Duration(milliseconds: 600),
59+
);
60+
_logoMoveController = AnimationController(
61+
vsync: this,
62+
duration: const Duration(milliseconds: 800),
63+
);
64+
final config = OrionConfig();
65+
final rawPrinterName =
66+
config.getString('machineName', category: 'machine').trim();
67+
_printerName = rawPrinterName.isEmpty ? '3D Printer' : rawPrinterName;
68+
_loaderController = AnimationController(
69+
vsync: this,
70+
duration: const Duration(milliseconds: 1200),
71+
);
72+
_backgroundController = AnimationController(
73+
vsync: this,
74+
duration: const Duration(milliseconds: 1800),
75+
);
76+
_logoOpacity =
77+
CurvedAnimation(parent: _logoController, curve: Curves.easeInOut);
78+
_logoMove = CurvedAnimation(
79+
parent: _logoMoveController, curve: Curves.easeInOutSine);
80+
_loaderOpacity =
81+
CurvedAnimation(parent: _loaderController, curve: Curves.easeInOut);
82+
_backgroundOpacity =
83+
CurvedAnimation(parent: _backgroundController, curve: Curves.easeInOut);
84+
85+
// Stage animations: logo after 1s, background after 4s with a slower fade.
86+
Future.delayed(const Duration(seconds: 1), () {
87+
if (mounted) _logoController.forward();
88+
});
89+
Future.delayed(const Duration(seconds: 2), () {
90+
if (mounted) _logoMoveController.forward();
91+
});
92+
Future.delayed(const Duration(seconds: 3), () {
93+
if (mounted) _loaderController.forward();
94+
});
95+
Future.delayed(const Duration(seconds: 4), () {
96+
if (mounted) _backgroundController.forward();
97+
});
98+
}
99+
100+
@override
101+
void didChangeDependencies() {
102+
super.didChangeDependencies();
103+
final baseBackground = Theme.of(context).scaffoldBackgroundColor;
104+
_backgroundColor =
105+
Color.lerp(baseBackground, Colors.black, 0.6) ?? Colors.black;
106+
try {
107+
final themeProvider = Provider.of<ThemeProvider>(context, listen: false);
108+
if (themeProvider.isGlassTheme) {
109+
// Use the same gradient resolution as the rest of the app so
110+
// brightness and color stops match GlassApp.
111+
final gradient = GlassGradientUtils.resolveGradient(
112+
themeProvider: themeProvider,
113+
);
114+
_gradientColors = gradient.isNotEmpty ? gradient : const [];
115+
} else {
116+
// For non-glass themes, prefer the theme's scaffold background color
117+
// as a solid background. We already computed a blended _backgroundColor
118+
// above; slightly darken it so the startup overlay reads well over
119+
// light/dark backgrounds.
120+
_gradientColors = const [];
121+
_backgroundColor = Color.lerp(Theme.of(context).scaffoldBackgroundColor,
122+
Colors.black, 0.35) ??
123+
_backgroundColor;
124+
}
125+
} catch (_) {
126+
_gradientColors = const [];
127+
}
128+
}
129+
130+
@override
131+
void dispose() {
132+
_logoController.dispose();
133+
_loaderController.dispose();
134+
_logoMoveController.dispose();
135+
_backgroundController.dispose();
136+
super.dispose();
137+
}
138+
139+
// Interpolates between a greyscale color matrix (t=0) and the identity
140+
// color matrix (t=1). We drive this with the loader animation so the
141+
// logo is greyscale initially and transitions back to color as the
142+
// loader/text are revealed.
143+
List<double> _colorMatrixFor(double t) {
144+
const List<double> grey = [
145+
0.2126, 0.7152, 0.0722, 0, 0, // R
146+
0.2126, 0.7152, 0.0722, 0, 0, // G
147+
0.2126, 0.7152, 0.0722, 0, 0, // B
148+
0, 0, 0, 1, 0, // A
149+
];
150+
const List<double> identity = [
151+
1, 0, 0, 0, 0, // R
152+
0, 1, 0, 0, 0, // G
153+
0, 0, 1, 0, 0, // B
154+
0, 0, 0, 1, 0, // A
155+
];
156+
// t==0 => grey, t==1 => identity
157+
return List<double>.generate(
158+
20,
159+
(i) => grey[i] + (identity[i] - grey[i]) * t,
160+
growable: false,
161+
);
162+
}
163+
164+
@override
165+
Widget build(BuildContext context) {
166+
return Scaffold(
167+
backgroundColor: Colors.black,
168+
body: Stack(
169+
fit: StackFit.expand,
170+
children: [
171+
Container(color: Colors.black),
172+
FadeTransition(
173+
opacity: _backgroundOpacity,
174+
child: _gradientColors.length >= 2
175+
? Container(
176+
decoration: BoxDecoration(
177+
gradient: LinearGradient(
178+
begin: Alignment.topLeft,
179+
end: Alignment.bottomRight,
180+
colors: _gradientColors,
181+
),
182+
),
183+
// Match GlassApp: overlay a semi-transparent black layer on
184+
// top of the gradient to achieve the same perceived
185+
// brightness.
186+
child: Container(
187+
decoration: BoxDecoration(
188+
color: Colors.black.withValues(alpha: 0.5),
189+
),
190+
),
191+
)
192+
: DecoratedBox(
193+
decoration: BoxDecoration(
194+
color: _backgroundColor.withValues(alpha: 1.0),
195+
),
196+
),
197+
),
198+
Center(
199+
child: FadeTransition(
200+
opacity: _logoOpacity,
201+
child: AnimatedBuilder(
202+
animation: _logoMove,
203+
builder: (context, _) {
204+
final dy = -30.0 * _logoMove.value;
205+
final matrix = _colorMatrixFor(_logoMove.value);
206+
return Transform.translate(
207+
offset: Offset(0, dy - 10),
208+
child: Stack(
209+
alignment: Alignment.center,
210+
children: [
211+
// Slightly upscaled blurred black copy (halo)
212+
Transform.scale(
213+
scale: 1.07,
214+
child: ImageFiltered(
215+
imageFilter:
216+
ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
217+
child: ColorFiltered(
218+
colorFilter: ColorFilter.mode(
219+
Colors.black.withValues(alpha: 0.5),
220+
BlendMode.srcIn),
221+
child: Padding(
222+
padding: const EdgeInsets.all(8),
223+
child: Image.asset(
224+
'assets/images/open_resin_alliance_logo_darkmode.png',
225+
width: 220,
226+
height: 220,
227+
),
228+
),
229+
),
230+
),
231+
),
232+
// Foreground logo which receives the greyscale->color
233+
// matrix animation.
234+
Padding(
235+
padding: const EdgeInsets.all(8),
236+
child: ColorFiltered(
237+
colorFilter: ColorFilter.matrix(matrix),
238+
child: Image.asset(
239+
'assets/images/open_resin_alliance_logo_darkmode.png',
240+
width: 220,
241+
height: 220,
242+
),
243+
),
244+
),
245+
],
246+
),
247+
);
248+
},
249+
),
250+
),
251+
),
252+
// Full-width indeterminate progress bar at the bottom edge. Kept
253+
// separate from the centered content so it doesn't affect layout.
254+
Positioned(
255+
left: 20,
256+
right: 20,
257+
bottom: 30,
258+
child: FadeTransition(
259+
opacity: _loaderOpacity,
260+
child: Text(
261+
'Starting up $_printerName',
262+
textAlign: TextAlign.center,
263+
style: const TextStyle(
264+
fontFamily: 'AtkinsonHyperlegible',
265+
fontSize: 24,
266+
color: Colors.white70,
267+
),
268+
),
269+
),
270+
),
271+
Positioned(
272+
left: 40,
273+
right: 40,
274+
bottom: 90,
275+
child: FadeTransition(
276+
opacity: _loaderOpacity,
277+
child: SizedBox(
278+
height: 14,
279+
child: ClipRRect(
280+
borderRadius: BorderRadius.circular(7),
281+
child: LinearProgressIndicator(
282+
// Use theme primary color for the indicator to match app theming.
283+
valueColor: AlwaysStoppedAnimation<Color>(
284+
Theme.of(context).colorScheme.primary),
285+
backgroundColor: Colors.black.withValues(alpha: 0.3),
286+
),
287+
),
288+
),
289+
),
290+
),
291+
],
292+
),
293+
);
294+
}
295+
}

lib/main.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import 'package:orion/files/files_screen.dart';
3333
import 'package:orion/files/grid_files_screen.dart';
3434
import 'package:orion/glasser/glasser.dart';
3535
import 'package:orion/home/home_screen.dart';
36-
import 'package:orion/home/onboarding_screen.dart';
36+
import 'package:orion/home/startup_gate.dart';
3737
import 'package:orion/l10n/generated/app_localizations.dart';
3838
import 'package:orion/settings/about_screen.dart';
3939
import 'package:orion/settings/settings_screen.dart';
@@ -201,9 +201,10 @@ class OrionMainAppState extends State<OrionMainApp> {
201201
GoRoute(
202202
path: '/',
203203
builder: (BuildContext context, GoRouterState state) {
204-
return initialSetupTrigger()
205-
? const OnboardingScreen()
206-
: const HomeScreen();
204+
// Let the StartupGate decide whether to show the startup overlay
205+
// while the initial backend connection attempt completes. It will
206+
// render the onboarding screen or the HomeScreen once connected.
207+
return const StartupGate();
207208
},
208209
routes: <RouteBase>[
209210
GoRoute(
@@ -285,6 +286,7 @@ class OrionMainAppState extends State<OrionMainApp> {
285286
WidgetsBinding.instance.addPostFrameCallback((_) {
286287
try {
287288
final navCtx = _navKey.currentContext;
289+
// Startup gating is handled by the root route's StartupGate.
288290
if (_connWatcher == null && navCtx != null) {
289291
_connWatcher = ConnectionErrorWatcher.install(navCtx);
290292
}
@@ -300,8 +302,8 @@ class OrionMainAppState extends State<OrionMainApp> {
300302
final active =
301303
(s?.isPrinting == true) || (s?.isPaused == true);
302304
if (active && !_wasPrinting) {
303-
// Only navigate if not already on /status
304-
// Navigate to status on transition to active print.
305+
// Only navigate if not already on /status. Navigate
306+
// to status on transition to active print.
305307
try {
306308
final navState = _navKey.currentState;
307309
final sModel = statusProv.status;

0 commit comments

Comments
 (0)