731 lines
24 KiB
Dart
731 lines
24 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../models/scooter_state.dart';
|
|
import '../scooters/apollo_protocol.dart';
|
|
import '../scooters/apollo_scooter.dart';
|
|
import '../scooters/scooter.dart';
|
|
import '../services/pin_store.dart';
|
|
import '../settings.dart';
|
|
import '../theme.dart';
|
|
import 'clusters.dart';
|
|
import 'log_screen.dart';
|
|
|
|
class ScooterScreen extends StatefulWidget {
|
|
const ScooterScreen({super.key, required this.scooter});
|
|
|
|
final ApolloScooter scooter;
|
|
|
|
@override
|
|
State<ScooterScreen> createState() => _ScooterScreenState();
|
|
}
|
|
|
|
class _ScooterScreenState extends State<ScooterScreen> {
|
|
final _pin = TextEditingController();
|
|
final _pinStore = PinStore();
|
|
String? _pinError;
|
|
bool _busy = false;
|
|
bool _hasSavedPin = false;
|
|
bool _autoAuthTried = false;
|
|
|
|
ApolloScooter get scooter => widget.scooter;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
scooter.addListener(_onScooterChanged);
|
|
_loadSavedPin();
|
|
scooter.connect().catchError((_) {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
scooter.removeListener(_onScooterChanged);
|
|
_pin.dispose();
|
|
scooter.disposeScooter();
|
|
super.dispose();
|
|
}
|
|
|
|
// ---- PIN -----------------------------------------------------------------
|
|
|
|
Future<void> _loadSavedPin() async {
|
|
final saved = await _pinStore.read(scooter.device.id);
|
|
if (!mounted || saved == null) return;
|
|
setState(() {
|
|
_pin.text = saved;
|
|
_hasSavedPin = true;
|
|
});
|
|
_maybeAutoAuthenticate();
|
|
}
|
|
|
|
void _onScooterChanged() => _maybeAutoAuthenticate();
|
|
|
|
void _maybeAutoAuthenticate() {
|
|
if (_autoAuthTried || !_hasSavedPin || _busy) return;
|
|
if (scooter.state.connectionStatus != ScooterConnectionStatus.connected) return;
|
|
_autoAuthTried = true;
|
|
_authenticate();
|
|
}
|
|
|
|
Future<void> _forgetPin() async {
|
|
await _pinStore.forget(scooter.device.id);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_hasSavedPin = false;
|
|
_pin.clear();
|
|
});
|
|
}
|
|
|
|
Future<void> _authenticate() async {
|
|
final pin = _pin.text.trim();
|
|
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
|
|
setState(() => _pinError = 'Enter the six-digit scooter PIN.');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_pinError = null;
|
|
_busy = true;
|
|
});
|
|
try {
|
|
final result = await scooter.authenticate(pin);
|
|
if (result == AuthenticationResult.invalidCredential) {
|
|
setState(() => _pinError = 'Incorrect scooter PIN.');
|
|
if (_hasSavedPin) await _forgetPin();
|
|
} else {
|
|
await _pinStore.save(scooter.device.id, pin);
|
|
if (mounted) setState(() => _hasSavedPin = true);
|
|
}
|
|
} on TimeoutException {
|
|
setState(() => _pinError = 'The scooter did not respond.');
|
|
} on ScooterConnectionLostException {
|
|
// Shown by the status overlay.
|
|
} catch (e) {
|
|
setState(() => _pinError = 'Authentication failed: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _reconnect() async {
|
|
_autoAuthTried = false;
|
|
setState(() => _busy = true);
|
|
try {
|
|
await scooter.connect();
|
|
} catch (_) {
|
|
// Reflected in scooter.state and shown by the overlay.
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
// ---- controls ------------------------------------------------------------
|
|
|
|
Future<void> _run(Future<void> Function() action) async {
|
|
if (!scooter.controlWritesEnabled) {
|
|
_snack('Control writes are currently disabled in this build.');
|
|
return;
|
|
}
|
|
if (!scooter.canWrite) {
|
|
_snack('Scooter connection is still initializing... Try this action again shortly.');
|
|
return;
|
|
}
|
|
setState(() => _busy = true);
|
|
try {
|
|
await action();
|
|
} on TimeoutException catch (e) {
|
|
_snack(e.message ?? 'The scooter did not confirm the change.');
|
|
} catch (e) {
|
|
_snack('$e');
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
void _snack(String text) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(content: Text(text)));
|
|
}
|
|
|
|
bool _isImperial(ScooterState s) => switch (AppSettings.instance.units) {
|
|
UnitPreference.auto => s.imperial ?? false,
|
|
UnitPreference.metric => false,
|
|
UnitPreference.imperial => true,
|
|
};
|
|
|
|
// ---- build ---------------------------------------------------------------
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: ListenableBuilder(
|
|
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
|
builder: (context, _) {
|
|
final s = scooter.state;
|
|
final actions = ClusterActions(
|
|
busy: _busy,
|
|
toggleHeadlight: () => _run(() => scooter.setHeadlight(!(s.headlight ?? false))),
|
|
toggleLock: () => _run((s.locked ?? false) ? scooter.unlock : scooter.lock),
|
|
readOnlyTap: (name) => _snack('$name is read-only for now.'),
|
|
);
|
|
return Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
_topBar(s),
|
|
Expanded(
|
|
child: buildCluster(
|
|
AppSettings.instance.layout,
|
|
ClusterData(state: s, imperial: _isImperial(s)),
|
|
actions,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
?_overlayFor(s),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _topBar(ScooterState s) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
scooter.device.name.isEmpty ? 'Scooter' : scooter.device.name,
|
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
|
),
|
|
_ConnectionPill(status: s.connectionStatus),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Settings and diagnostics',
|
|
icon: const Icon(Icons.tune_rounded),
|
|
onPressed: () => _openSettings(s),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Full-screen overlays for the states where the dashboard has nothing
|
|
/// meaningful to show yet.
|
|
Widget? _overlayFor(ScooterState s) {
|
|
switch (s.connectionStatus) {
|
|
case ScooterConnectionStatus.connecting:
|
|
return _Overlay(child: _ConnectingCard(step: s.connectionStep ?? 0));
|
|
case ScooterConnectionStatus.connected:
|
|
case ScooterConnectionStatus.authenticating:
|
|
return _Overlay(child: _pinCard(s));
|
|
case ScooterConnectionStatus.error:
|
|
case ScooterConnectionStatus.disconnected:
|
|
return _Overlay(
|
|
child: _MessageCard(
|
|
icon: Icons.bluetooth_disabled_rounded,
|
|
title: s.connectionStatus == ScooterConnectionStatus.error
|
|
? 'Connection problem'
|
|
: 'Disconnected',
|
|
message: s.errorMessage ?? 'The scooter is not connected.',
|
|
actions: [
|
|
FilledButton(
|
|
onPressed: _busy ? null : _reconnect,
|
|
child: const Text('Reconnect'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
case ScooterConnectionStatus.authenticated:
|
|
return const _Overlay(
|
|
dim: 0.7,
|
|
child: _MessageCard(
|
|
icon: Icons.podcasts_rounded,
|
|
title: 'PIN accepted',
|
|
message: 'Waiting for the scooter to start streaming telemetry.',
|
|
body: _Spinner(label: 'Usually under a second'),
|
|
),
|
|
);
|
|
case ScooterConnectionStatus.ready:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Widget _pinCard(ScooterState s) {
|
|
final authenticating = s.connectionStatus == ScooterConnectionStatus.authenticating;
|
|
return _MessageCard(
|
|
icon: Icons.lock_outline_rounded,
|
|
title: 'Enter scooter PIN',
|
|
message: 'The six-digit Bluetooth PIN from your scooter.',
|
|
body: TextField(
|
|
controller: _pin,
|
|
enabled: !authenticating && !_busy,
|
|
autofocus: !_hasSavedPin,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 6,
|
|
obscureText: true,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 30, letterSpacing: 14, fontWeight: FontWeight.w700),
|
|
decoration: InputDecoration(counterText: '', hintText: '••••••', errorText: _pinError),
|
|
onSubmitted: (_) => _authenticate(),
|
|
),
|
|
actions: [
|
|
FilledButton(
|
|
onPressed: authenticating || _busy ? null : _authenticate,
|
|
child: authenticating
|
|
? const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
|
SizedBox(width: 8),
|
|
Text('Unlocking...'),
|
|
],
|
|
)
|
|
: const Text('Unlock Scooter'),
|
|
),
|
|
if (_hasSavedPin)
|
|
TextButton(onPressed: _forgetPin, child: const Text('Forget saved PIN')),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openSettings(ScooterState s) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: OsColors.surface,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
builder: (ctx) => ListenableBuilder(
|
|
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
|
builder: (ctx, _) => StatefulBuilder(
|
|
builder: (ctx, setSheet) => _SettingsSheet(
|
|
scooter: scooter,
|
|
hasSavedPin: _hasSavedPin,
|
|
onForgetPin: () async {
|
|
await _forgetPin();
|
|
setSheet(() {});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Overlays and shared bits
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _ConnectionPill extends StatelessWidget {
|
|
const _ConnectionPill({required this.status});
|
|
final ScooterConnectionStatus status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (label, color) = switch (status) {
|
|
ScooterConnectionStatus.disconnected => ('Disconnected', OsColors.textDim),
|
|
ScooterConnectionStatus.connecting => ('Connecting', OsColors.warn),
|
|
ScooterConnectionStatus.connected => ('PIN required', OsColors.warn),
|
|
ScooterConnectionStatus.authenticating => ('Checking PIN', OsColors.warn),
|
|
ScooterConnectionStatus.authenticated => ('Waiting for data', OsColors.warn),
|
|
ScooterConnectionStatus.ready => ('Connected', OsColors.good),
|
|
ScooterConnectionStatus.error => ('Error', OsColors.bad),
|
|
};
|
|
return Row(
|
|
children: [
|
|
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
|
const SizedBox(width: 6),
|
|
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 12)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Overlay extends StatelessWidget {
|
|
const _Overlay({required this.child, this.dim = 0.92});
|
|
final Widget child;
|
|
final double dim;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned.fill(
|
|
child: Container(
|
|
color: OsColors.background.withValues(alpha: dim),
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.all(24),
|
|
child: SingleChildScrollView(child: child),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Step-by-step view of what the app is doing while the link comes up.
|
|
class _ConnectingCard extends StatelessWidget {
|
|
const _ConnectingCard({required this.step});
|
|
final int step;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = Theme.of(context).colorScheme.primary;
|
|
final steps = ScooterState.connectionSteps;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Icon(Icons.bluetooth_searching_rounded, size: 40, color: primary),
|
|
const SizedBox(height: 12),
|
|
const Text('Connecting', textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
'This should only take a few moments.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: OsColors.textDim),
|
|
),
|
|
const SizedBox(height: 20),
|
|
for (var i = 0; i < steps.length; i++)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: i < step
|
|
? Icon(Icons.check_circle_rounded, color: OsColors.good, size: 22)
|
|
: i == step
|
|
? CircularProgressIndicator(strokeWidth: 2.5, color: primary)
|
|
: const Icon(Icons.circle_outlined, color: OsColors.track, size: 22),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Text(
|
|
steps[i],
|
|
style: TextStyle(
|
|
color: i <= step ? OsColors.text : OsColors.textDim,
|
|
fontWeight: i == step ? FontWeight.w700 : FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Spinner extends StatelessWidget {
|
|
const _Spinner({required this.label});
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)),
|
|
const SizedBox(height: 20),
|
|
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 16)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageCard extends StatelessWidget {
|
|
const _MessageCard({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.message,
|
|
this.body,
|
|
this.actions = const [],
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String message;
|
|
final Widget? body;
|
|
final List<Widget> actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Icon(icon, size: 40, color: Theme.of(context).colorScheme.primary),
|
|
const SizedBox(height: 12),
|
|
Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
|
const SizedBox(height: 6),
|
|
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
|
|
if (body != null) ...[const SizedBox(height: 20), body!],
|
|
const SizedBox(height: 20),
|
|
...actions,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsSheet extends StatelessWidget {
|
|
const _SettingsSheet({
|
|
required this.scooter,
|
|
required this.hasSavedPin,
|
|
required this.onForgetPin,
|
|
});
|
|
|
|
final ApolloScooter scooter;
|
|
final bool hasSavedPin;
|
|
final VoidCallback onForgetPin;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = scooter.state;
|
|
final settings = AppSettings.instance;
|
|
return SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 12),
|
|
child: Text('Settings', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
|
|
),
|
|
const _SectionLabel('CLUSTER LAYOUT'),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
children: [
|
|
for (final layout in ClusterLayout.values) ...[
|
|
Expanded(
|
|
child: _LayoutChoice(
|
|
layout: layout,
|
|
selected: settings.layout == layout,
|
|
onTap: () => settings.layout = layout,
|
|
),
|
|
),
|
|
if (layout != ClusterLayout.values.last) const SizedBox(width: 10),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const _SectionLabel('ACCENT COLOR'),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
for (final a in AccentColor.values)
|
|
_ColorDot(
|
|
color: a.color,
|
|
label: a.label,
|
|
selected: settings.accent == a,
|
|
onTap: () => settings.accent = a,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const _SectionLabel('UNITS'),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
|
child: SegmentedButton<UnitPreference>(
|
|
showSelectedIcon: false,
|
|
segments: const [
|
|
ButtonSegment(value: UnitPreference.auto, label: Text('Auto')),
|
|
ButtonSegment(value: UnitPreference.metric, label: Text('km')),
|
|
ButtonSegment(value: UnitPreference.imperial, label: Text('mi')),
|
|
],
|
|
selected: {settings.units},
|
|
onSelectionChanged: (v) => settings.units = v.first,
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
|
|
child: Text(
|
|
'Auto follows the scooter. Distances assume native km until a ride confirms it.',
|
|
style: TextStyle(color: OsColors.textDim, fontSize: 12),
|
|
),
|
|
),
|
|
const Divider(height: 24),
|
|
SwitchListTile(
|
|
title: const Text('Keepalive'),
|
|
subtitle: const Text('The scooter only streams data while it receives this every second.'),
|
|
value: scooter.keepaliveInterval != null,
|
|
onChanged: (v) => scooter.setKeepaliveInterval(v ? apolloDefaultKeepaliveInterval : null),
|
|
),
|
|
ListTile(
|
|
title: const Text('Forget saved PIN'),
|
|
enabled: hasSavedPin,
|
|
trailing: const Icon(Icons.delete_outline_rounded),
|
|
onTap: hasSavedPin ? onForgetPin : null,
|
|
),
|
|
ListTile(
|
|
title: const Text('Protocol log'),
|
|
subtitle: const Text('Raw BLE traffic for debugging'),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LogScreen()));
|
|
},
|
|
),
|
|
const Divider(height: 24),
|
|
const _SectionLabel('SCOOTER DETAILS'),
|
|
_kv('Address', scooter.device.id),
|
|
_kv('Speed limits', s.maxSpeedLimit == null ? '--' : 'current gear ${s.speedLimit ?? '--'}, max ${s.maxSpeedLimit}'),
|
|
_kv('Battery temperature', s.batteryTemperature == null ? '--' : '${s.batteryTemperature} °C'),
|
|
_kv('Battery cycles', '${s.batteryCycles ?? '--'}'),
|
|
_kv('Display', '${s.displayId ?? 'none'} ${s.displayVersion ?? ''}'),
|
|
_kv('Scooter unit bit', s.imperial == null ? '--' : (s.imperial! ? 'imperial' : 'metric')),
|
|
_kv('Control writes', scooter.controlWritesEnabled ? (scooter.canWrite ? 'enabled' : 'waiting') : 'read-only build'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _kv(String k, String v) => ListTile(
|
|
dense: true,
|
|
title: Text(k),
|
|
trailing: Text(v, style: const TextStyle(color: OsColors.textDim)),
|
|
);
|
|
}
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
const _SectionLabel(this.text);
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
child: CapsLabel(text),
|
|
);
|
|
}
|
|
|
|
class _LayoutChoice extends StatelessWidget {
|
|
const _LayoutChoice({required this.layout, required this.selected, required this.onTap});
|
|
final ClusterLayout layout;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = Theme.of(context).colorScheme.primary;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(16),
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: OsColors.surfaceHigh,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: selected ? primary : Colors.transparent, width: 2),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: 44, child: _LayoutPreview(layout: layout, color: selected ? primary : OsColors.textDim)),
|
|
const SizedBox(height: 8),
|
|
Text(layout.label, style: const TextStyle(fontWeight: FontWeight.w700)),
|
|
Text(layout.description, style: const TextStyle(color: OsColors.textDim, fontSize: 11)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Tiny schematic of each layout for the picker.
|
|
class _LayoutPreview extends StatelessWidget {
|
|
const _LayoutPreview({required this.layout, required this.color});
|
|
final ClusterLayout layout;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget bar(double w, double h) => Container(
|
|
width: w,
|
|
height: h,
|
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3)),
|
|
);
|
|
return switch (layout) {
|
|
ClusterLayout.arc => Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: color, width: 4)),
|
|
),
|
|
),
|
|
ClusterLayout.digital => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [bar(36, 18), const SizedBox(height: 6), bar(60, 6)],
|
|
),
|
|
ClusterLayout.tiles => Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
|
const SizedBox(height: 4),
|
|
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
|
],
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _ColorDot extends StatelessWidget {
|
|
const _ColorDot({required this.color, required this.label, required this.selected, required this.onTap});
|
|
final Color color;
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: label,
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: selected ? Colors.white : Colors.transparent, width: 3),
|
|
),
|
|
child: selected
|
|
? Icon(Icons.check_rounded, color: color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|