817 lines
28 KiB
Dart
817 lines
28 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../models/busylight_color.dart';
|
|
import '../models/busylight_status.dart';
|
|
import '../models/color_preset.dart';
|
|
import '../providers/busylight_provider.dart';
|
|
import '../providers/presets_provider.dart';
|
|
import '../widgets/brightness_slider.dart';
|
|
import '../widgets/status_button.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
// ── App bar shared between screens ───────────────────────────────────────────
|
|
|
|
AppBar _buildAppBar(BuildContext context) => AppBar(
|
|
backgroundColor: Colors.black,
|
|
title: const Text('BusyLight', style: TextStyle(color: Colors.white)),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.settings_outlined, color: Colors.white),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
// ── HomeScreen ────────────────────────────────────────────────────────────────
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final snapshot = ref.watch(busylightSnapshotProvider);
|
|
final statusAsync = ref.watch(busylightStatusProvider);
|
|
|
|
// Start background polling (no-op if already running)
|
|
ref.watch(pollingProvider);
|
|
|
|
if (!snapshot.hasValue && !snapshot.hasError) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: _buildAppBar(context),
|
|
body: const Center(child: CircularProgressIndicator(color: Colors.amber)),
|
|
);
|
|
}
|
|
if (snapshot.hasError && !statusAsync.hasValue) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: _buildAppBar(context),
|
|
body: _ErrorView(
|
|
message: snapshot.error.toString(),
|
|
onRetry: () => ref.invalidate(busylightSnapshotProvider),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: _buildAppBar(context),
|
|
// _Body reads all providers itself — no props passed down
|
|
body: statusAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator(color: Colors.amber)),
|
|
error: (e, _) => _ErrorView(
|
|
message: e.toString(),
|
|
onRetry: () => ref.read(busylightStatusProvider.notifier).refresh(),
|
|
),
|
|
data: (_) => const _Body(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Body ──────────────────────────────────────────────────────────────────────
|
|
// ConsumerStatefulWidget so ref is stable across rebuilds and dialogs.
|
|
// Reads all providers itself — receives NO props from HomeScreen.
|
|
|
|
class _Body extends ConsumerStatefulWidget {
|
|
const _Body();
|
|
|
|
@override
|
|
ConsumerState<_Body> createState() => _BodyState();
|
|
}
|
|
|
|
class _BodyState extends ConsumerState<_Body> {
|
|
BusylightStatus? _pendingStatus;
|
|
String? _pendingPresetId;
|
|
|
|
Color _statusColor(BusylightStatus status, BusylightColor color) {
|
|
switch (status) {
|
|
case BusylightStatus.available: return Colors.green;
|
|
case BusylightStatus.away: return Colors.orange;
|
|
case BusylightStatus.busy: return Colors.red;
|
|
case BusylightStatus.on: return Colors.white;
|
|
case BusylightStatus.off: return Colors.grey.shade900;
|
|
case BusylightStatus.colored: return color.toFlutterColor();
|
|
}
|
|
}
|
|
|
|
Future<void> _setStatus(BusylightStatus s) async {
|
|
setState(() => _pendingStatus = s);
|
|
await ref.read(busylightStatusProvider.notifier).setStatus(s);
|
|
if (mounted) setState(() => _pendingStatus = null);
|
|
}
|
|
|
|
Future<void> _applyPreset(ColorPreset preset) async {
|
|
setState(() => _pendingPresetId = preset.id);
|
|
await ref.read(colorProvider.notifier).set(preset.color);
|
|
ref.read(busylightStatusProvider.notifier).setLocalStatus(BusylightStatus.colored);
|
|
if (mounted) setState(() => _pendingPresetId = null);
|
|
}
|
|
|
|
void _editPreset(ColorPreset preset) {
|
|
_openColorPicker(preset.color, ref.read(brightnessProvider), editingPreset: preset);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final statusAsync = ref.watch(busylightStatusProvider);
|
|
final brightness = ref.watch(brightnessProvider);
|
|
final color = ref.watch(colorProvider);
|
|
final presets = ref.watch(presetsProvider);
|
|
|
|
final status = statusAsync.valueOrNull ?? BusylightStatus.off;
|
|
final displayColor = _statusColor(status, color);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
// Live color preview dot
|
|
Center(
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 400),
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: status == BusylightStatus.off
|
|
? Colors.grey.shade900
|
|
: displayColor.withOpacity(brightness),
|
|
boxShadow: status != BusylightStatus.off
|
|
? [BoxShadow(
|
|
color: displayColor.withOpacity(0.5 * brightness),
|
|
blurRadius: 40,
|
|
spreadRadius: 8,
|
|
)]
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Center(
|
|
child: Text(
|
|
status.label.toUpperCase(),
|
|
style: const TextStyle(color: Colors.grey, letterSpacing: 2, fontSize: 13),
|
|
),
|
|
),
|
|
const SizedBox(height: 36),
|
|
|
|
// Quick status
|
|
const _SectionLabel('Quick status'),
|
|
const SizedBox(height: 12),
|
|
GridView.count(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
crossAxisCount: 3,
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.1,
|
|
children: [
|
|
BusylightStatus.available,
|
|
BusylightStatus.away,
|
|
BusylightStatus.busy,
|
|
BusylightStatus.on,
|
|
BusylightStatus.off,
|
|
].map((s) => StatusButton(
|
|
status: s,
|
|
isActive: status == s,
|
|
isPending: _pendingStatus == s,
|
|
onTap: _pendingStatus == null ? () => _setStatus(s) : () {},
|
|
)).toList(),
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Custom presets + add button (horizontal scroll, never pushes content down)
|
|
_PresetsScroller(
|
|
presets: presets,
|
|
pendingPresetId: _pendingPresetId,
|
|
onPresetTap: (_pendingStatus == null && _pendingPresetId == null)
|
|
? _applyPreset
|
|
: (_) {},
|
|
onPresetDelete: (preset) => ref.read(presetsProvider.notifier).remove(preset.id),
|
|
onPresetEdit: _editPreset,
|
|
onAddTap: () => _openColorPicker(color, brightness),
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Brightness
|
|
const _SectionLabel('Brightness'),
|
|
const SizedBox(height: 8),
|
|
BrightnessSlider(
|
|
value: brightness,
|
|
onChanged: (v) => ref.read(brightnessProvider.notifier).set(v),
|
|
),
|
|
const SizedBox(height: 40),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ── Color picker dialog ───────────────────────────────────────────────────
|
|
|
|
void _openColorPicker(BusylightColor currentColor, double currentBrightness, {ColorPreset? editingPreset}) {
|
|
Color pickerColor = currentColor.toFlutterColor();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, setDialogState) => AlertDialog(
|
|
backgroundColor: Colors.grey.shade900,
|
|
title: Text(
|
|
editingPreset != null ? 'Edit color' : 'Pick a color',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ColorPicker(
|
|
pickerColor: pickerColor,
|
|
onColorChanged: (c) => setDialogState(() => pickerColor = c),
|
|
pickerAreaHeightPercent: 0.7,
|
|
enableAlpha: false,
|
|
displayThumbColor: true,
|
|
labelTypes: const [],
|
|
),
|
|
SlidePicker(
|
|
pickerColor: pickerColor,
|
|
onColorChanged: (c) => setDialogState(() => pickerColor = c),
|
|
colorModel: ColorModel.rgb,
|
|
enableAlpha: false,
|
|
displayThumbColor: true,
|
|
showParams: true,
|
|
showIndicator: false,
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Buttons stacked vertically — avoids collision on narrow macOS dialogs
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.grey,
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
if (editingPreset == null)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton(
|
|
onPressed: () {
|
|
final picked = BusylightColor.fromFlutterColor(
|
|
pickerColor, brightness: currentBrightness,
|
|
);
|
|
Navigator.pop(ctx);
|
|
ref.read(colorProvider.notifier).set(picked);
|
|
ref.read(busylightStatusProvider.notifier).setLocalStatus(BusylightStatus.colored);
|
|
},
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
side: BorderSide(color: Colors.grey.shade600),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: const Text('Apply only'),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
final picked = BusylightColor.fromFlutterColor(
|
|
pickerColor, brightness: currentBrightness,
|
|
);
|
|
Navigator.pop(ctx);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_openNameDialog(picked, editingPreset: editingPreset);
|
|
});
|
|
},
|
|
icon: const Icon(Icons.bookmark_outline, size: 16, color: Colors.black),
|
|
label: Text(
|
|
editingPreset != null ? 'Save' : 'Save & Apply',
|
|
style: const TextStyle(color: Colors.black),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.amber,
|
|
shape: const StadiumBorder(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Preset name dialog ────────────────────────────────────────────────────
|
|
|
|
void _openNameDialog(BusylightColor color, {ColorPreset? editingPreset}) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => _NamePresetDialog(
|
|
initialName: editingPreset?.name ?? '',
|
|
onSave: (name) {
|
|
if (editingPreset != null) {
|
|
// Edit mode: only update the saved preset, do not touch the BusyLight
|
|
ref.read(presetsProvider.notifier).update(editingPreset.id, name, color);
|
|
} else {
|
|
// Create mode: save preset and apply color to BusyLight
|
|
ref.read(presetsProvider.notifier).add(name, color);
|
|
ref.read(colorProvider.notifier).set(color);
|
|
ref.read(busylightStatusProvider.notifier).setLocalStatus(BusylightStatus.colored);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Presets scroller with overflow indicator ──────────────────────────────────
|
|
|
|
class _PresetsScroller extends StatefulWidget {
|
|
final List<ColorPreset> presets;
|
|
final String? pendingPresetId;
|
|
final ValueChanged<ColorPreset> onPresetTap;
|
|
final ValueChanged<ColorPreset> onPresetDelete;
|
|
final ValueChanged<ColorPreset> onPresetEdit;
|
|
final VoidCallback onAddTap;
|
|
|
|
const _PresetsScroller({
|
|
required this.presets,
|
|
required this.onPresetTap,
|
|
required this.onPresetDelete,
|
|
required this.onPresetEdit,
|
|
required this.onAddTap,
|
|
this.pendingPresetId,
|
|
});
|
|
|
|
@override
|
|
State<_PresetsScroller> createState() => _PresetsScrollerState();
|
|
}
|
|
|
|
class _PresetsScrollerState extends State<_PresetsScroller> {
|
|
final _scrollController = ScrollController();
|
|
bool _hasOverflow = false;
|
|
int _hiddenCount = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateOverflow());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_PresetsScroller old) {
|
|
super.didUpdateWidget(old);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateOverflow());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() => _updateOverflow();
|
|
|
|
void _updateOverflow() {
|
|
if (!_scrollController.hasClients) return;
|
|
final pos = _scrollController.position;
|
|
final overflow = pos.maxScrollExtent - pos.pixels;
|
|
final hasMore = overflow > 10;
|
|
|
|
// Estimate hidden count: avg chip ~110px wide + 10px gap
|
|
// Subtract 1 to exclude the "+ New" chip from the count
|
|
final hidden = hasMore ? ((overflow / 120).ceil() - 1).clamp(0, 999) : 0;
|
|
|
|
if (hasMore != _hasOverflow || hidden != _hiddenCount) {
|
|
setState(() {
|
|
_hasOverflow = hasMore;
|
|
_hiddenCount = hidden;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header row: "Custom" label + overflow count on the same line
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Custom',
|
|
style: TextStyle(
|
|
color: Colors.grey.shade300,
|
|
fontSize: 13,
|
|
letterSpacing: 0.3,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (_hasOverflow && _hiddenCount > 0) ...[
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'· +$_hiddenCount more',
|
|
style: TextStyle(
|
|
color: Colors.grey.shade500,
|
|
fontSize: 12,
|
|
letterSpacing: 1.2,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
controller: _scrollController,
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
...widget.presets.map((preset) => Padding(
|
|
padding: const EdgeInsets.only(right: 10),
|
|
child: _PresetChip(
|
|
preset: preset,
|
|
isPending: widget.pendingPresetId == preset.id,
|
|
onTap: () => widget.onPresetTap(preset),
|
|
onDelete: () => widget.onPresetDelete(preset),
|
|
onEdit: widget.onPresetEdit,
|
|
),
|
|
)),
|
|
GestureDetector(
|
|
onTap: widget.onAddTap,
|
|
child: Container(
|
|
height: 44,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade800, width: 1.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.add, color: Colors.grey.shade600, size: 16),
|
|
const SizedBox(width: 6),
|
|
Text('New', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Fade + arrow overlay on the right edge
|
|
if (_hasOverflow)
|
|
Positioned(
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: IgnorePointer(
|
|
child: Container(
|
|
width: 60,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: [Colors.black.withOpacity(0), Colors.black],
|
|
),
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: Icon(Icons.chevron_right, color: Colors.grey.shade500, size: 20),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Name preset dialog ────────────────────────────────────────────────────────
|
|
|
|
class _NamePresetDialog extends StatefulWidget {
|
|
final ValueChanged<String> onSave;
|
|
final String initialName;
|
|
const _NamePresetDialog({required this.onSave, this.initialName = ''});
|
|
|
|
@override
|
|
State<_NamePresetDialog> createState() => _NamePresetDialogState();
|
|
}
|
|
|
|
class _NamePresetDialogState extends State<_NamePresetDialog> {
|
|
late final TextEditingController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController(text: widget.initialName);
|
|
// Select all text so user can type a new name immediately
|
|
_controller.selection = TextSelection(
|
|
baseOffset: 0, extentOffset: widget.initialName.length,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _submit() {
|
|
final name = _controller.text.trim();
|
|
if (name.isEmpty) return;
|
|
Navigator.pop(context);
|
|
widget.onSave(name);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
backgroundColor: Colors.grey.shade900,
|
|
title: Text(
|
|
widget.initialName.isNotEmpty ? 'Rename preset' : 'Name this preset',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
content: TextField(
|
|
controller: _controller,
|
|
autofocus: true,
|
|
maxLength: 20,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: InputDecoration(
|
|
hintText: 'e.g. Love, Focus, Chill…',
|
|
hintStyle: TextStyle(color: Colors.grey.shade600),
|
|
counterStyle: TextStyle(color: Colors.grey.shade600),
|
|
filled: true,
|
|
fillColor: Colors.grey.shade800,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
onSubmitted: (_) => _submit(),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel', style: TextStyle(color: Colors.grey)),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _submit,
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber),
|
|
child: const Text('Save', style: TextStyle(color: Colors.black)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Preset chip ───────────────────────────────────────────────────────────────
|
|
|
|
class _PresetChip extends StatelessWidget {
|
|
final ColorPreset preset;
|
|
final bool isPending;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onDelete;
|
|
final ValueChanged<ColorPreset> onEdit;
|
|
|
|
const _PresetChip({
|
|
required this.preset,
|
|
required this.onTap,
|
|
required this.onDelete,
|
|
required this.onEdit,
|
|
this.isPending = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final chipColor = preset.color.toFlutterColor();
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
onLongPress: () => _showOptions(context),
|
|
child: Container(
|
|
height: 44,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: chipColor.withOpacity(0.08),
|
|
border: Border.all(color: chipColor.withOpacity(0.5), width: 1.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
isPending
|
|
? SizedBox(
|
|
width: 9,
|
|
height: 9,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 1.5,
|
|
valueColor: AlwaysStoppedAnimation(chipColor),
|
|
),
|
|
)
|
|
: Container(
|
|
width: 9,
|
|
height: 9,
|
|
decoration: BoxDecoration(color: chipColor, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(preset.name, style: TextStyle(color: Colors.grey.shade200, fontSize: 13)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showOptions(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.grey.shade900,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder: (ctx) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 36, height: 4,
|
|
margin: const EdgeInsets.only(top: 12, bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade700,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 10, height: 10,
|
|
decoration: BoxDecoration(
|
|
color: preset.color.toFlutterColor(),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
preset.name,
|
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 15),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Divider(height: 1, color: Color(0xFF2a2a2a)),
|
|
ListTile(
|
|
leading: const Icon(Icons.edit_outlined, color: Colors.white),
|
|
title: const Text('Edit', style: TextStyle(color: Colors.white)),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
onEdit(preset);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.delete_outline, color: Colors.red.shade400),
|
|
title: Text('Delete', style: TextStyle(color: Colors.red.shade400)),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
onDelete();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Section label ─────────────────────────────────────────────────────────────
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
final String text;
|
|
const _SectionLabel(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Text(
|
|
text,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade300,
|
|
fontSize: 13,
|
|
letterSpacing: 0.3,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Error view ────────────────────────────────────────────────────────────────
|
|
|
|
class _ErrorView extends StatefulWidget {
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
const _ErrorView({required this.message, required this.onRetry});
|
|
|
|
@override
|
|
State<_ErrorView> createState() => _ErrorViewState();
|
|
}
|
|
|
|
class _ErrorViewState extends State<_ErrorView> {
|
|
bool _showDetails = false;
|
|
|
|
String get _friendlyMessage {
|
|
const hint = '\nAlso double-check the device address in ⚙ Settings.';
|
|
final m = widget.message.toLowerCase();
|
|
if (m.contains('socket') || m.contains('network') || m.contains('connection refused'))
|
|
return 'Make sure your BusyLight is powered on and connected to the same Wi-Fi network.$hint';
|
|
if (m.contains('timeout'))
|
|
return 'Connection timed out. Your BusyLight may be out of range or busy.$hint';
|
|
if (m.contains('404') || m.contains('not found'))
|
|
return 'BusyLight was reached but returned an unexpected response.$hint';
|
|
if (m.contains('host') || m.contains('lookup'))
|
|
return 'Could not find your BusyLight on the network.$hint';
|
|
return 'Could not connect to your BusyLight.$hint';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.wifi_off, color: Colors.grey, size: 48),
|
|
const SizedBox(height: 16),
|
|
const Text('Cannot reach BusyLight',
|
|
style: TextStyle(color: Colors.white, fontSize: 18)),
|
|
const SizedBox(height: 8),
|
|
Text(_friendlyMessage,
|
|
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
|
|
textAlign: TextAlign.center),
|
|
const SizedBox(height: 16),
|
|
|
|
// Collapsible details
|
|
GestureDetector(
|
|
onTap: () => setState(() => _showDetails = !_showDetails),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Details',
|
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
_showDetails ? Icons.expand_less : Icons.expand_more,
|
|
color: Colors.grey.shade600,
|
|
size: 16,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_showDetails) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade900,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey.shade800),
|
|
),
|
|
child: Text(
|
|
widget.message,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade400,
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: widget.onRetry,
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber),
|
|
child: const Text('Retry', style: TextStyle(color: Colors.black)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} |