Initial commit
This commit is contained in:
817
lib/screens/home_screen.dart
Normal file
817
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,817 @@
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
200
lib/screens/settings_screen.dart
Normal file
200
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../providers/busylight_provider.dart';
|
||||
import '../services/autostart_service.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
late TextEditingController _hostController;
|
||||
late int _pollInterval;
|
||||
bool _startWithSession = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hostController = TextEditingController(text: ref.read(deviceHostProvider));
|
||||
_pollInterval = ref.read(pollIntervalProvider);
|
||||
_loadAutostart();
|
||||
}
|
||||
|
||||
Future<void> _loadAutostart() async {
|
||||
final enabled = await AutostartService.isEnabled();
|
||||
if (mounted) setState(() => _startWithSession = enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final host = _hostController.text.trim();
|
||||
if (host.isEmpty) return;
|
||||
|
||||
ref.read(deviceHostProvider.notifier).state = host;
|
||||
ref.read(pollIntervalProvider.notifier).state = _pollInterval;
|
||||
ref.read(pollingProvider.notifier).restart();
|
||||
|
||||
await AutostartService.setEnabled(_startWithSession);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('busylight_host', host);
|
||||
await prefs.setInt('busylight_poll_interval', _pollInterval);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Settings saved')),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
String _intervalLabel(int seconds) {
|
||||
if (seconds == 0) return 'Off';
|
||||
if (seconds < 60) return '${seconds}s';
|
||||
return '${seconds ~/ 60}m';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Settings', style: TextStyle(color: Colors.white)),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Device address ──────────────────────────────────────────────
|
||||
Text('Device address',
|
||||
style: TextStyle(color: Colors.grey.shade300, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _hostController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'http://igox-busylight.local',
|
||||
hintStyle: TextStyle(color: Colors.grey.shade700),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade900,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Use hostname (igox-busylight.local) or IP address (http://192.168.x.x)',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Polling interval ────────────────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Status polling',
|
||||
style: TextStyle(color: Colors.grey.shade300, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
_intervalLabel(_pollInterval),
|
||||
style: const TextStyle(color: Colors.amber, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: Colors.amber,
|
||||
thumbColor: Colors.amber,
|
||||
inactiveTrackColor: Colors.grey.shade800,
|
||||
overlayColor: Colors.amber.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: _pollInterval.toDouble(),
|
||||
min: 0,
|
||||
max: 60,
|
||||
divisions: 12,
|
||||
onChanged: (v) => setState(() => _pollInterval = v.round()),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Off', style: TextStyle(color: Colors.grey.shade600, fontSize: 11)),
|
||||
Text('5s', style: TextStyle(color: Colors.grey.shade600, fontSize: 11)),
|
||||
Text('10s', style: TextStyle(color: Colors.grey.shade600, fontSize: 11)),
|
||||
Text('30s', style: TextStyle(color: Colors.grey.shade600, fontSize: 11)),
|
||||
Text('1m', style: TextStyle(color: Colors.grey.shade600, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_pollInterval == 0
|
||||
? 'Polling is disabled. Status will only refresh manually.'
|
||||
: 'Status is pulled from the device every $_pollInterval seconds.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
||||
),
|
||||
|
||||
// ── Start with session (macOS + Windows only) ───────────────────
|
||||
if (AutostartService.isSupported) ...[
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Start with session',
|
||||
style: TextStyle(color: Colors.grey.shade300, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Launch automatically at login',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Switch(
|
||||
value: _startWithSession,
|
||||
onChanged: (v) => setState(() => _startWithSession = v),
|
||||
activeColor: Colors.amber,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.amber,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user