Initial commit
This commit is contained in:
27
lib/main.dart
Normal file
27
lib/main.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/models/busylight_color.dart
Normal file
59
lib/models/busylight_color.dart
Normal 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);
|
||||
}
|
||||
37
lib/models/busylight_status.dart
Normal file
37
lib/models/busylight_status.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/models/color_preset.dart
Normal file
42
lib/models/color_preset.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
198
lib/providers/busylight_provider.dart
Normal file
198
lib/providers/busylight_provider.dart
Normal 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),
|
||||
);
|
||||
57
lib/providers/presets_provider.dart
Normal file
57
lib/providers/presets_provider.dart
Normal 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(),
|
||||
);
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/services/autostart_service.dart
Normal file
25
lib/services/autostart_service.dart
Normal 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 (_) {}
|
||||
}
|
||||
}
|
||||
99
lib/services/busylight_service.dart
Normal file
99
lib/services/busylight_service.dart
Normal 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');
|
||||
}
|
||||
46
lib/widgets/brightness_slider.dart
Normal file
46
lib/widgets/brightness_slider.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/widgets/status_button.dart
Normal file
83
lib/widgets/status_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user