Initial commit

This commit is contained in:
2026-03-21 01:34:22 +01:00
parent 8ec17d5ed4
commit 6805f4a3f4
144 changed files with 7312 additions and 13 deletions

27
lib/main.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../screens/home_screen.dart';
void main() {
runApp(const ProviderScope(child: BusylightApp()));
}
class BusylightApp extends StatelessWidget {
const BusylightApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BusyLight',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(
primary: Colors.amber,
secondary: Colors.amber.shade700,
),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class BusylightColor {
final int r;
final int g;
final int b;
final double brightness;
const BusylightColor({
required this.r,
required this.g,
required this.b,
this.brightness = 1.0,
});
factory BusylightColor.fromJson(Map<String, dynamic> json) {
// GET /api/color returns { "colors": { r, g, b }, "brightness": 0.3 }
final colors = json['colors'] as Map<String, dynamic>? ?? json;
return BusylightColor(
r: (colors['r'] as num?)?.toInt() ?? 0,
g: (colors['g'] as num?)?.toInt() ?? 0,
b: (colors['b'] as num?)?.toInt() ?? 0,
brightness: (json['brightness'] as num?)?.toDouble() ?? 0.3,
);
}
Map<String, dynamic> toJson() => {
'r': r,
'g': g,
'b': b,
'brightness': brightness,
};
Color toFlutterColor() => Color.fromARGB(255, r, g, b);
factory BusylightColor.fromFlutterColor(Color color, {double brightness = 1.0}) {
return BusylightColor(
r: color.red,
g: color.green,
b: color.blue,
brightness: brightness,
);
}
BusylightColor copyWith({int? r, int? g, int? b, double? brightness}) {
return BusylightColor(
r: r ?? this.r,
g: g ?? this.g,
b: b ?? this.b,
brightness: brightness ?? this.brightness,
);
}
static const green = BusylightColor(r: 0, g: 255, b: 0);
static const red = BusylightColor(r: 255, g: 0, b: 0);
static const yellow = BusylightColor(r: 255, g: 200, b: 0);
static const white = BusylightColor(r: 255, g: 255, b: 255);
static const off = BusylightColor(r: 0, g: 0, b: 0, brightness: 0);
}

View File

@@ -0,0 +1,37 @@
enum BusylightStatus {
on,
off,
available,
away,
busy,
colored;
String get apiPath {
switch (this) {
case BusylightStatus.on: return '/api/status/on';
case BusylightStatus.off: return '/api/status/off';
case BusylightStatus.available: return '/api/status/available';
case BusylightStatus.away: return '/api/status/away';
case BusylightStatus.busy: return '/api/status/busy';
case BusylightStatus.colored: return '/api/status';
}
}
String get label {
switch (this) {
case BusylightStatus.on: return 'On';
case BusylightStatus.off: return 'Off';
case BusylightStatus.available: return 'Available';
case BusylightStatus.away: return 'Away';
case BusylightStatus.busy: return 'Busy';
case BusylightStatus.colored: return 'Custom';
}
}
static BusylightStatus fromString(String value) {
return BusylightStatus.values.firstWhere(
(e) => e.name == value,
orElse: () => BusylightStatus.off,
);
}
}

View File

@@ -0,0 +1,42 @@
import 'dart:convert';
import 'busylight_color.dart';
class ColorPreset {
final String id;
final String name;
final BusylightColor color;
const ColorPreset({
required this.id,
required this.name,
required this.color,
});
factory ColorPreset.fromJson(Map<String, dynamic> json) {
return ColorPreset(
id: json['id'] as String,
name: json['name'] as String,
color: BusylightColor.fromJson(json['color'] as Map<String, dynamic>),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'color': {
'r': color.r,
'g': color.g,
'b': color.b,
'brightness': color.brightness,
},
};
static List<ColorPreset> listFromJson(String raw) {
final list = jsonDecode(raw) as List<dynamic>;
return list.map((e) => ColorPreset.fromJson(e as Map<String, dynamic>)).toList();
}
static String listToJson(List<ColorPreset> presets) {
return jsonEncode(presets.map((p) => p.toJson()).toList());
}
}

View File

@@ -0,0 +1,198 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/busylight_color.dart';
import '../models/busylight_status.dart';
import '../services/busylight_service.dart';
// ── Device config ────────────────────────────────────────────────────────────
const _kHostKey = 'busylight_host';
const _kDefaultHost = 'http://igox-busylight.local';
const _kPollIntervalKey = 'busylight_poll_interval';
const _kDefaultPollInterval = 5; // seconds
final sharedPreferencesProvider = FutureProvider<SharedPreferences>(
(_) => SharedPreferences.getInstance(),
);
final deviceHostProvider = StateProvider<String>((ref) {
final prefs = ref.watch(sharedPreferencesProvider).valueOrNull;
return prefs?.getString(_kHostKey) ?? _kDefaultHost;
});
final pollIntervalProvider = StateProvider<int>((ref) {
final prefs = ref.watch(sharedPreferencesProvider).valueOrNull;
return prefs?.getInt(_kPollIntervalKey) ?? _kDefaultPollInterval;
});
// ── Service ──────────────────────────────────────────────────────────────────
final busylightServiceProvider = Provider<BusylightService>((ref) {
final host = ref.watch(deviceHostProvider);
return BusylightService(baseUrl: host);
});
// ── Startup snapshot ─────────────────────────────────────────────────────────
// Loads status + color (which includes brightness) in 2 parallel calls.
// No separate GET /api/brightness needed — the color response already has it.
class BusylightSnapshot {
final BusylightStatus status;
final BusylightColor color;
const BusylightSnapshot({
required this.status,
required this.color,
});
double get brightness => color.brightness;
}
final busylightSnapshotProvider = FutureProvider<BusylightSnapshot>((ref) async {
final service = ref.watch(busylightServiceProvider);
final results = await Future.wait([
service.getStatus(),
service.getColor(),
]);
return BusylightSnapshot(
status: results[0] as BusylightStatus,
color: results[1] as BusylightColor,
);
});
// ── State notifiers ──────────────────────────────────────────────────────────
class BusylightStateNotifier extends StateNotifier<AsyncValue<BusylightStatus>> {
BusylightStateNotifier(this._service, BusylightStatus? initial)
: super(initial != null
? AsyncValue.data(initial)
: const AsyncValue.loading()) {
if (initial == null) refresh();
}
final BusylightService _service;
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_service.getStatus);
}
Future<void> setStatus(BusylightStatus status) async {
// Keep current value visible during the API call — no loading state
final result = await AsyncValue.guard(() => _service.setStatus(status));
state = result;
}
/// Update state locally without making an API call (e.g. for `colored`)
void setLocalStatus(BusylightStatus status) {
state = AsyncValue.data(status);
}
}
final busylightStatusProvider =
StateNotifierProvider<BusylightStateNotifier, AsyncValue<BusylightStatus>>(
(ref) {
final snapshot = ref.watch(busylightSnapshotProvider).valueOrNull;
return BusylightStateNotifier(
ref.watch(busylightServiceProvider),
snapshot?.status,
);
},
);
// ── Brightness ───────────────────────────────────────────────────────────────
class BrightnessNotifier extends StateNotifier<double> {
BrightnessNotifier(this._service, double initial) : super(initial);
final BusylightService _service;
Future<void> set(double value) async {
state = value;
await _service.setBrightness(value);
}
void silentSet(double value) => state = value;
}
final brightnessProvider = StateNotifierProvider<BrightnessNotifier, double>(
(ref) {
final snapshot = ref.watch(busylightSnapshotProvider).valueOrNull;
return BrightnessNotifier(
ref.watch(busylightServiceProvider),
snapshot?.brightness ?? 0.3,
);
},
);
// ── Color ─────────────────────────────────────────────────────────────────────
class ColorNotifier extends StateNotifier<BusylightColor> {
ColorNotifier(this._service, BusylightColor initial) : super(initial);
final BusylightService _service;
Future<void> set(BusylightColor color) async {
state = color;
await _service.setColor(color);
}
void silentSet(BusylightColor color) => state = color;
}
final colorProvider = StateNotifierProvider<ColorNotifier, BusylightColor>(
(ref) {
final snapshot = ref.watch(busylightSnapshotProvider).valueOrNull;
return ColorNotifier(
ref.watch(busylightServiceProvider),
snapshot?.color ?? BusylightColor.white,
);
},
);
// ── Background polling ────────────────────────────────────────────────────────
// Periodically pulls status + color from the device and silently updates state.
class PollingNotifier extends StateNotifier<void> {
PollingNotifier(this._ref) : super(null) {
_start();
}
final Ref _ref;
Timer? _timer;
void _start() {
final interval = _ref.read(pollIntervalProvider);
_timer?.cancel();
if (interval <= 0) return;
_timer = Timer.periodic(Duration(seconds: interval), (_) => _poll());
}
void restart() => _start();
Future<void> _poll() async {
try {
final service = _ref.read(busylightServiceProvider);
final results = await Future.wait([
service.getStatus(),
service.getColor(),
]);
final status = results[0] as BusylightStatus;
final color = results[1] as BusylightColor;
_ref.read(busylightStatusProvider.notifier).setLocalStatus(status);
_ref.read(colorProvider.notifier).silentSet(color);
_ref.read(brightnessProvider.notifier).silentSet(color.brightness);
} catch (_) {
// Silently ignore poll errors — connection issues are shown on manual refresh
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
final pollingProvider = StateNotifierProvider<PollingNotifier, void>(
(ref) => PollingNotifier(ref),
);

View File

@@ -0,0 +1,57 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/color_preset.dart';
import '../models/busylight_color.dart';
const _kPresetsKey = 'busylight_color_presets';
const _uuid = Uuid();
class PresetsNotifier extends StateNotifier<List<ColorPreset>> {
PresetsNotifier() : super([]) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kPresetsKey);
if (raw != null) {
try {
state = ColorPreset.listFromJson(raw);
} catch (_) {
state = [];
}
}
}
Future<void> _save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kPresetsKey, ColorPreset.listToJson(state));
}
Future<void> add(String name, BusylightColor color) async {
final preset = ColorPreset(
id: _uuid.v4(),
name: name.trim(),
color: color,
);
state = [...state, preset];
await _save();
}
Future<void> update(String id, String name, BusylightColor color) async {
state = state.map((p) => p.id == id
? ColorPreset(id: id, name: name.trim(), color: color)
: p).toList();
await _save();
}
Future<void> remove(String id) async {
state = state.where((p) => p.id != id).toList();
await _save();
}
}
final presetsProvider = StateNotifierProvider<PresetsNotifier, List<ColorPreset>>(
(_) => PresetsNotifier(),
);

View 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)),
),
],
),
),
);
}

View 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)),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter/services.dart';
class AutostartService {
static const _channel = MethodChannel('com.igox.busylight_buddy/autostart');
/// Returns true only on supported platforms (macOS, Windows)
static bool get isSupported => Platform.isMacOS || Platform.isWindows;
static Future<bool> isEnabled() async {
if (!isSupported) return false;
try {
return await _channel.invokeMethod<bool>('isEnabled') ?? false;
} catch (_) {
return false;
}
}
static Future<void> setEnabled(bool enabled) async {
if (!isSupported) return;
try {
await _channel.invokeMethod('setEnabled', {'enabled': enabled});
} catch (_) {}
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/busylight_color.dart';
import '../models/busylight_status.dart';
class BusylightException implements Exception {
final String message;
const BusylightException(this.message);
@override
String toString() => 'BusylightException: $message';
}
class BusylightService {
final String baseUrl;
final Duration timeout;
BusylightService({
required this.baseUrl,
this.timeout = const Duration(seconds: 5),
});
Uri _uri(String path) => Uri.parse('$baseUrl$path');
Future<Map<String, dynamic>> _get(String path) async {
try {
final res = await http.get(_uri(path)).timeout(timeout);
_checkStatus(res);
return jsonDecode(res.body) as Map<String, dynamic>;
} on BusylightException {
rethrow;
} catch (e) {
throw BusylightException('Network error: $e');
}
}
Future<Map<String, dynamic>> _post(String path, [Map<String, dynamic>? body]) async {
try {
final res = await http
.post(
_uri(path),
headers: {'Content-Type': 'application/json'},
body: body != null ? jsonEncode(body) : null,
)
.timeout(timeout);
_checkStatus(res);
return jsonDecode(res.body) as Map<String, dynamic>;
} on BusylightException {
rethrow;
} catch (e) {
throw BusylightException('Network error: $e');
}
}
void _checkStatus(http.Response res) {
if (res.statusCode < 200 || res.statusCode >= 300) {
throw BusylightException('HTTP ${res.statusCode}: ${res.body}');
}
}
// ── Status ──────────────────────────────────────────────────────────────────
Future<BusylightStatus> getStatus() async {
final json = await _get('/api/status');
return BusylightStatus.fromString(json['status'] as String);
}
Future<BusylightStatus> setStatus(BusylightStatus status) async {
final json = await _post(status.apiPath);
return BusylightStatus.fromString(json['status'] as String);
}
Future<BusylightStatus> turnOn() => setStatus(BusylightStatus.on);
Future<BusylightStatus> turnOff() => setStatus(BusylightStatus.off);
Future<BusylightStatus> setAvailable() => setStatus(BusylightStatus.available);
Future<BusylightStatus> setAway() => setStatus(BusylightStatus.away);
Future<BusylightStatus> setBusy() => setStatus(BusylightStatus.busy);
// ── Color ───────────────────────────────────────────────────────────────────
Future<BusylightColor> getColor() async {
final json = await _get('/api/color');
return BusylightColor.fromJson(json);
}
Future<void> setColor(BusylightColor color) async {
await _post('/api/color', color.toJson());
}
// ── Brightness ──────────────────────────────────────────────────────────────
// Note: brightness is read from GET /api/color response, no separate endpoint needed.
Future<void> setBrightness(double brightness) async {
await _post('/api/brightness', {'brightness': brightness.clamp(0.0, 1.0)});
}
// ── Debug ───────────────────────────────────────────────────────────────────
Future<Map<String, dynamic>> getDebug() => _get('/api/debug');
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
class BrightnessSlider extends StatelessWidget {
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double>? onChangeEnd;
const BrightnessSlider({
super.key,
required this.value,
required this.onChanged,
this.onChangeEnd,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.brightness_low, color: Colors.grey, size: 20),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.amber,
thumbColor: Colors.amber,
inactiveTrackColor: Colors.grey.shade800,
),
child: Slider(
value: value,
min: 0.0,
max: 1.0,
divisions: 20,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
),
),
const Icon(Icons.brightness_high, color: Colors.amber, size: 20),
const SizedBox(width: 8),
Text(
'${(value * 100).round()}%',
style: TextStyle(color: Colors.grey.shade400, fontSize: 13),
),
],
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../models/busylight_status.dart';
class StatusButton extends StatelessWidget {
final BusylightStatus status;
final bool isActive;
final bool isPending;
final VoidCallback onTap;
const StatusButton({
super.key,
required this.status,
required this.isActive,
required this.onTap,
this.isPending = false,
});
Color get _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.shade700;
case BusylightStatus.colored: return Colors.purple;
}
}
IconData get _icon {
switch (status) {
case BusylightStatus.available: return Icons.check_circle_outline;
case BusylightStatus.away: return Icons.schedule;
case BusylightStatus.busy: return Icons.do_not_disturb_on_outlined;
case BusylightStatus.on: return Icons.lightbulb_outline;
case BusylightStatus.off: return Icons.power_settings_new;
case BusylightStatus.colored: return Icons.palette_outlined;
}
}
@override
Widget build(BuildContext context) {
final activeColor = isActive || isPending ? _color : Colors.grey.shade600;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 14),
decoration: BoxDecoration(
color: (isActive || isPending) ? _color.withOpacity(0.08) : Colors.transparent,
border: Border.all(
color: (isActive || isPending) ? _color.withOpacity(0.5) : Colors.grey.shade800,
width: 1.5,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
isPending
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(_color),
),
)
: Icon(_icon, color: activeColor, size: 26),
const SizedBox(height: 7),
Text(
status.label,
style: TextStyle(
color: activeColor,
fontWeight: (isActive || isPending) ? FontWeight.w600 : FontWeight.w400,
fontSize: 12,
),
),
],
),
),
);
}
}