Initial version
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user