feat(ui): theme-mode toggle (system / light / dark) with persistence
Three-way switch in the sidebar footer next to the settings gear. Cycles system → light → dark → system. Choice persists via shared_preferences across app launches; restored before the first frame so there's no theme flicker on startup. The icon changes per state (auto / sun / moon) and the tooltip spells it out so the cycling behaviour is discoverable. - StudioApp is now stateful; exposes StudioApp.of(context) to let descendants flip the theme without prop-drilling. - ThemeModeValue enum lives in data/hub.dart so the persistence layer stays free of flutter/material imports. Bumps fai_studio 0.6.0 -> 0.7.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
b3fdd69139
commit
671194c105
3 changed files with 119 additions and 29 deletions
|
|
@ -52,6 +52,22 @@ class HubService {
|
||||||
await prefs.setBool(_kSecureKey, endpoint.secure);
|
await prefs.setBool(_kSecureKey, endpoint.secure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _kThemeKey = 'theme.mode';
|
||||||
|
|
||||||
|
/// Read the persisted theme mode (system / light / dark) for
|
||||||
|
/// initial app startup. Defaults to system.
|
||||||
|
Future<ThemeModeValue> loadThemeMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_kThemeKey);
|
||||||
|
return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the theme mode the user chose.
|
||||||
|
Future<void> saveThemeMode(ThemeModeValue mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_kThemeKey, mode.wire);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> healthy() => _client.healthy();
|
Future<bool> healthy() => _client.healthy();
|
||||||
|
|
||||||
Future<List<ModuleSummary>> listModules() async {
|
Future<List<ModuleSummary>> listModules() async {
|
||||||
|
|
@ -216,6 +232,24 @@ class ServiceEntry {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three-way switch persisted across launches.
|
||||||
|
enum ThemeModeValue {
|
||||||
|
system('system'),
|
||||||
|
light('light'),
|
||||||
|
dark('dark');
|
||||||
|
|
||||||
|
final String wire;
|
||||||
|
const ThemeModeValue(this.wire);
|
||||||
|
|
||||||
|
static ThemeModeValue? fromWire(String? s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
for (final v in values) {
|
||||||
|
if (v.wire == s) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// UI-side type, decoupled from the proto wire type so pages
|
/// UI-side type, decoupled from the proto wire type so pages
|
||||||
/// don't import protobuf packages.
|
/// don't import protobuf packages.
|
||||||
class ModuleSummary {
|
class ModuleSummary {
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,53 @@ import 'widgets/widgets.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Restore the persisted endpoint before the first frame so the
|
// Restore the persisted endpoint and theme mode before the
|
||||||
// sidebar pill doesn't briefly say "127.0.0.1:50051" before
|
// first frame so neither flicker on startup.
|
||||||
// switching to the operator's actual hub.
|
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
runApp(const StudioApp());
|
final themeMode = await HubService.instance.loadThemeMode();
|
||||||
|
runApp(StudioApp(initialThemeMode: themeMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
class StudioApp extends StatelessWidget {
|
class StudioApp extends StatefulWidget {
|
||||||
const StudioApp({super.key});
|
final ThemeModeValue initialThemeMode;
|
||||||
|
|
||||||
|
const StudioApp({super.key, required this.initialThemeMode});
|
||||||
|
|
||||||
|
/// Lookup helper so descendants can flip the theme without
|
||||||
|
/// passing callbacks down through every level.
|
||||||
|
static StudioAppState? of(BuildContext context) =>
|
||||||
|
context.findAncestorStateOfType<StudioAppState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StudioApp> createState() => StudioAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class StudioAppState extends State<StudioApp> {
|
||||||
|
late ThemeModeValue _mode;
|
||||||
|
|
||||||
|
ThemeModeValue get mode => _mode;
|
||||||
|
|
||||||
|
Future<void> setMode(ThemeModeValue m) async {
|
||||||
|
setState(() => _mode = m);
|
||||||
|
await HubService.instance.saveThemeMode(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mode = widget.initialThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeMode _flutterMode() {
|
||||||
|
switch (_mode) {
|
||||||
|
case ThemeModeValue.system:
|
||||||
|
return ThemeMode.system;
|
||||||
|
case ThemeModeValue.light:
|
||||||
|
return ThemeMode.light;
|
||||||
|
case ThemeModeValue.dark:
|
||||||
|
return ThemeMode.dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -35,7 +73,7 @@ class StudioApp extends StatelessWidget {
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: FaiTheme.light(),
|
theme: FaiTheme.light(),
|
||||||
darkTheme: FaiTheme.dark(),
|
darkTheme: FaiTheme.dark(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: _flutterMode(),
|
||||||
home: const StudioShell(),
|
home: const StudioShell(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -204,20 +242,13 @@ class _Sidebar extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Footer: settings + version.
|
// Footer: theme toggle + settings.
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
_ThemeToggle(),
|
||||||
child: Text(
|
const Spacer(),
|
||||||
'platform target',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||||
tooltip: 'Hub endpoint…',
|
tooltip: 'Hub endpoint…',
|
||||||
|
|
@ -387,3 +418,34 @@ class _NavPage {
|
||||||
required this.page,
|
required this.page,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ThemeToggle extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = StudioApp.of(context);
|
||||||
|
final mode = app?.mode ?? ThemeModeValue.system;
|
||||||
|
final icon = switch (mode) {
|
||||||
|
ThemeModeValue.system => Icons.brightness_auto_outlined,
|
||||||
|
ThemeModeValue.light => Icons.light_mode_outlined,
|
||||||
|
ThemeModeValue.dark => Icons.dark_mode_outlined,
|
||||||
|
};
|
||||||
|
final tooltip = switch (mode) {
|
||||||
|
ThemeModeValue.system => 'theme: follow system',
|
||||||
|
ThemeModeValue.light => 'theme: light',
|
||||||
|
ThemeModeValue.dark => 'theme: dark',
|
||||||
|
};
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(icon, size: 16),
|
||||||
|
tooltip: tooltip,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
final next = switch (mode) {
|
||||||
|
ThemeModeValue.system => ThemeModeValue.light,
|
||||||
|
ThemeModeValue.light => ThemeModeValue.dark,
|
||||||
|
ThemeModeValue.dark => ThemeModeValue.system,
|
||||||
|
};
|
||||||
|
app?.setMode(next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
// Smoke test: app boots without crashing and shows the three
|
// Smoke test: app boots without crashing and shows the four
|
||||||
// MVP destinations in the navigation rail.
|
// MVP destinations in the sidebar.
|
||||||
//
|
|
||||||
// Pages themselves now make live gRPC calls. We pump only one
|
|
||||||
// frame so they enter the loading state without actually
|
|
||||||
// awaiting the connection (which would fail without a running
|
|
||||||
// hub in CI).
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fai_studio/data/hub.dart';
|
||||||
import 'package:fai_studio/main.dart';
|
import 'package:fai_studio/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Studio shell shows the four destinations',
|
testWidgets('Studio shell shows the four destinations',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(const StudioApp());
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(initialThemeMode: ThemeModeValue.system),
|
||||||
|
);
|
||||||
|
|
||||||
expect(find.text('F∆I Studio'), findsOneWidget);
|
expect(find.text('F∆I Studio'), findsOneWidget);
|
||||||
// "Doctor" appears twice (sidebar label + app bar title of the
|
|
||||||
// first selected page) — same for "Modules" / "Audit" /
|
|
||||||
// "Approvals" once their pages are first visited. We just check
|
|
||||||
// the labels are findable at all.
|
|
||||||
expect(find.text('Doctor'), findsWidgets);
|
expect(find.text('Doctor'), findsWidgets);
|
||||||
expect(find.text('Modules'), findsWidgets);
|
expect(find.text('Modules'), findsWidgets);
|
||||||
expect(find.text('Audit'), findsWidgets);
|
expect(find.text('Audit'), findsWidgets);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue