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);
|
||||
}
|
||||
|
||||
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<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
|
||||
/// don't import protobuf packages.
|
||||
class ModuleSummary {
|
||||
|
|
|
|||
|
|
@ -18,15 +18,53 @@ import 'widgets/widgets.dart';
|
|||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Restore the persisted endpoint before the first frame so the
|
||||
// sidebar pill doesn't briefly say "127.0.0.1:50051" before
|
||||
// switching to the operator's actual hub.
|
||||
// Restore the persisted endpoint and theme mode before the
|
||||
// first frame so neither flicker on startup.
|
||||
await HubService.instance.loadPersistedEndpoint();
|
||||
runApp(const StudioApp());
|
||||
final themeMode = await HubService.instance.loadThemeMode();
|
||||
runApp(StudioApp(initialThemeMode: themeMode));
|
||||
}
|
||||
|
||||
class StudioApp extends StatelessWidget {
|
||||
const StudioApp({super.key});
|
||||
class StudioApp extends StatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -35,7 +73,7 @@ class StudioApp extends StatelessWidget {
|
|||
debugShowCheckedModeBanner: false,
|
||||
theme: FaiTheme.light(),
|
||||
darkTheme: FaiTheme.dark(),
|
||||
themeMode: ThemeMode.system,
|
||||
themeMode: _flutterMode(),
|
||||
home: const StudioShell(),
|
||||
);
|
||||
}
|
||||
|
|
@ -204,20 +242,13 @@ class _Sidebar extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// Footer: settings + version.
|
||||
// Footer: theme toggle + settings.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'platform target',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ThemeToggle(),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
tooltip: 'Hub endpoint…',
|
||||
|
|
@ -387,3 +418,34 @@ class _NavPage {
|
|||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue