feat(front-hall): implement three-mode layout with quick links panel
Phase 2 of Organizr Migration - Front Hall restructure: - Add FrontHallState provider with three modes (dashboard, iframe, settings) - Create QuickLinksPanel widget with categorized links and overflow menus - Add IframeView with platform-aware implementation (web iframe, mobile fallback) - Extract DashboardContent from FrontHallPage - Add QuickLinkSettingsContent placeholder for Phase 4 link editor - Implement QuickLink entity and data layer with Core API datasource - Add default quick links fallback when API unavailable - Update UI_LAYOUT.md with Front Hall panel configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4f8d0f521f
commit
40db92d9d5
+72
-4
@@ -191,13 +191,81 @@ The NavPanel widget receives items and auto-generates section headers based on u
|
||||
## Panel Configurations by Room
|
||||
|
||||
### Front Hall
|
||||
|
||||
Front Hall uses a **three-mode content system** with a persistent QuickLinks panel:
|
||||
|
||||
| Mode | QuickLinks Panel | Content Area |
|
||||
|------|------------------|--------------|
|
||||
| **Dashboard** | Normal navigation | Dashboard widgets (stats, weather) |
|
||||
| **Iframe** | Normal navigation | Embedded iframe content |
|
||||
| **Settings** | Edit mode (back button, add new, overflow menus) | QuickLinkPage (EntityPageScaffold) |
|
||||
|
||||
#### Dashboard Mode (default)
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┬────────────┐
|
||||
│ PRIMARY CONTENT (no nav panel) │ CHAT DOCK │
|
||||
│ Dashboard cards, activity feed │ (expanded) │
|
||||
└────────────────────────────────────────────────────────────────┴────────────┘
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ QUICK LINKS │ PRIMARY CONTENT │ CHAT DOCK │
|
||||
│ │ │ (expanded) │
|
||||
│ ▌HOME │ SYSTEM STATS │ │
|
||||
│ Jellyfin │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
|
||||
│ WebUI │ │ CPU │ │ MEM │ │ DISK │ │ NET │ │ │
|
||||
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
|
||||
│ ▌AMP │ │ │
|
||||
│ AMP Home │ WEATHER AIR QUALITY │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ ▌INFRA │ │ 72°F Sunny │ │ AQI: 42 Good │ │ │
|
||||
│ Netdata │ └────────────────┘ └────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ [⚙ Settings] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
#### Iframe Mode
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ QUICK LINKS │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
|
||||
│ │ │ Jellyfin [↗] [⟳] [✕] │ │ (collapsed)│
|
||||
│ ▌HOME │ ├─────────────────────────────────────────────┤ │ │
|
||||
│ ▸Jellyfin │ │ │ │ │
|
||||
│ WebUI │ │ <iframe src="jellyfin.url"> │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌AMP │ │ │ │ │
|
||||
│ AMP Home │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌INFRA │ │ │ │ │
|
||||
│ Netdata │ │ │ │ │
|
||||
│ │ └─────────────────────────────────────────────┘ │ │
|
||||
│ [⚙ Settings] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
#### Settings Mode
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ [←] Links │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
|
||||
│ │ │ ← Back Edit Quick Link [Save] │ │ (collapsed)│
|
||||
│ [+ Add New] │ ├─────────────────────────────────────────────┤ │ │
|
||||
├──────────────┤ │ │ │ │
|
||||
│ ▌HOME │ │ Name: [Jellyfin_________________] │ │ │
|
||||
│ ▸Jellyfin ⋮ │ │ │ │ │
|
||||
│ WebUI ⋮ │ │ URL: [https://jellyfin.schweitz...] │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌AMP │ │ Category: [Home ▼] │ │ │
|
||||
│ AMP Home ⋮ │ │ │ │ │
|
||||
│ │ │ Type: ◉ Iframe ○ New Tab │ │ │
|
||||
│ ▌INFRA │ │ │ │ │
|
||||
│ Netdata ⋮ │ │ Icon: [🎬 Pick...] │ │ │
|
||||
│ Portainer⋮ │ │ │ │ │
|
||||
│ │ │ Active: [✓] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
**Settings mode panel behavior:**
|
||||
- Back button in header (exits settings mode → dashboard)
|
||||
- "+ Add New Link" button at top
|
||||
- Each item has overflow menu (Edit, Delete, Move Up/Down)
|
||||
- Clicking item selects it for editing in content area
|
||||
- Uses EntityPageScaffold pattern (no modals)
|
||||
|
||||
### Control Room
|
||||
```
|
||||
┌───────────┬──────────────────────────────────────────────────────┬──────────┐
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/quick_link_model.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
|
||||
part 'quick_links_datasource.g.dart';
|
||||
|
||||
/// Data source for quick link operations.
|
||||
///
|
||||
/// Provides CRUD operations for Front Hall quick links via Core API.
|
||||
class QuickLinksDatasource {
|
||||
QuickLinksDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/front-hall/quick-links';
|
||||
|
||||
/// Gets all quick links.
|
||||
Future<List<QuickLink>> getQuickLinks() async {
|
||||
final response = await _dio.get<List<dynamic>>(_basePath);
|
||||
final data = response.data ?? [];
|
||||
|
||||
return data
|
||||
.map((json) => QuickLinkModel.fromJson(json as Map<String, dynamic>))
|
||||
.map((model) => model.toEntity())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Gets a single quick link by ID.
|
||||
Future<QuickLink> getQuickLink(String id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('$_basePath/$id');
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Quick link not found: $id');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Creates a new quick link.
|
||||
Future<QuickLink> createQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
data: model.toJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to create quick link');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Updates an existing quick link.
|
||||
Future<QuickLink> updateQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final response = await _dio.put<Map<String, dynamic>>(
|
||||
'$_basePath/${link.id}',
|
||||
data: model.toJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to update quick link');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Deletes a quick link.
|
||||
Future<void> deleteQuickLink(String id) async {
|
||||
await _dio.delete<void>('$_basePath/$id');
|
||||
}
|
||||
|
||||
/// Reorders quick links by updating sort orders.
|
||||
Future<void> reorderQuickLinks(List<String> orderedIds) async {
|
||||
await _dio.put<void>(
|
||||
'$_basePath/reorder',
|
||||
data: {'ids': orderedIds},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the quick links datasource.
|
||||
@riverpod
|
||||
QuickLinksDatasource quickLinksDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return QuickLinksDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
|
||||
part 'quick_link_model.freezed.dart';
|
||||
part 'quick_link_model.g.dart';
|
||||
|
||||
/// Quick link data model for API serialization.
|
||||
@freezed
|
||||
sealed class QuickLinkModel with _$QuickLinkModel {
|
||||
const factory QuickLinkModel({
|
||||
required String id,
|
||||
required String name,
|
||||
required String url,
|
||||
@JsonKey(name: 'icon_name') required String iconName,
|
||||
String? category,
|
||||
@Default('iframe') String type,
|
||||
@JsonKey(name: 'sort_order') @Default(0) int sortOrder,
|
||||
@JsonKey(name: 'is_active') @Default(true) bool isActive,
|
||||
}) = _QuickLinkModel;
|
||||
|
||||
const QuickLinkModel._();
|
||||
|
||||
factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$QuickLinkModelFromJson(json);
|
||||
|
||||
/// Converts to domain entity.
|
||||
QuickLink toEntity() {
|
||||
return QuickLink(
|
||||
id: id,
|
||||
name: name,
|
||||
url: url,
|
||||
iconName: iconName,
|
||||
category: category,
|
||||
type: _parseType(type),
|
||||
sortOrder: sortOrder,
|
||||
isActive: isActive,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates model from domain entity.
|
||||
factory QuickLinkModel.fromEntity(QuickLink entity) {
|
||||
return QuickLinkModel(
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
url: entity.url,
|
||||
iconName: entity.iconName,
|
||||
category: entity.category,
|
||||
type: entity.type == QuickLinkType.iframe ? 'iframe' : 'new_tab',
|
||||
sortOrder: entity.sortOrder,
|
||||
isActive: entity.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
QuickLinkType _parseType(String type) {
|
||||
return switch (type.toLowerCase()) {
|
||||
'iframe' => QuickLinkType.iframe,
|
||||
'new_tab' || 'newtab' => QuickLinkType.newTab,
|
||||
_ => QuickLinkType.iframe,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'quick_link.freezed.dart';
|
||||
|
||||
/// Quick link type for navigation behavior.
|
||||
enum QuickLinkType {
|
||||
/// Opens in embedded iframe within the app.
|
||||
iframe,
|
||||
|
||||
/// Opens in a new browser tab.
|
||||
newTab,
|
||||
}
|
||||
|
||||
/// Quick link entity for Front Hall navigation.
|
||||
///
|
||||
/// Represents a shortcut link that can either open embedded content
|
||||
/// in an iframe or navigate to an external URL in a new tab.
|
||||
@freezed
|
||||
sealed class QuickLink with _$QuickLink {
|
||||
const factory QuickLink({
|
||||
/// Unique identifier.
|
||||
required String id,
|
||||
|
||||
/// Display name.
|
||||
required String name,
|
||||
|
||||
/// Target URL.
|
||||
required String url,
|
||||
|
||||
/// Material icon name (e.g., 'home', 'settings', 'movie').
|
||||
required String iconName,
|
||||
|
||||
/// Category for grouping (e.g., 'Home', 'AMP', 'Coding', 'Infrastructure').
|
||||
String? category,
|
||||
|
||||
/// Navigation behavior type.
|
||||
@Default(QuickLinkType.iframe) QuickLinkType type,
|
||||
|
||||
/// Sort order within category.
|
||||
@Default(0) int sortOrder,
|
||||
|
||||
/// Whether the link is active/visible.
|
||||
@Default(true) bool isActive,
|
||||
}) = _QuickLink;
|
||||
|
||||
const QuickLink._();
|
||||
|
||||
/// Whether this link opens in an iframe.
|
||||
bool get isIframe => type == QuickLinkType.iframe;
|
||||
|
||||
/// Whether this link opens in a new tab.
|
||||
bool get isNewTab => type == QuickLinkType.newTab;
|
||||
}
|
||||
@@ -1,142 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/version.g.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/widgets/dashboard_content.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/widgets/iframe_view.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_link_settings_content.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_links_panel.dart';
|
||||
|
||||
/// Front Hall - estate overview and quick access.
|
||||
class FrontHallPage extends StatelessWidget {
|
||||
///
|
||||
/// Three-mode content area:
|
||||
/// - Dashboard: Stats, weather, welcome message
|
||||
/// - Iframe: Embedded external content (web only)
|
||||
/// - Settings: Quick link management
|
||||
class FrontHallPage extends ConsumerWidget {
|
||||
const FrontHallPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final frontHallState = ref.watch(frontHallStateProvider);
|
||||
|
||||
// No Scaffold/AppBar needed - header is provided by AppScaffold
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Welcome card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waving_hand,
|
||||
size: 32,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Welcome to Tatlock',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Your homelab dashboard is ready. More features coming soon.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
return Row(
|
||||
children: [
|
||||
// Left panel - Quick Links
|
||||
const QuickLinksPanel(),
|
||||
|
||||
// Quick stats placeholder
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.memory,
|
||||
label: 'CPU',
|
||||
value: '--',
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.storage,
|
||||
label: 'Memory',
|
||||
value: '--',
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.dns,
|
||||
label: 'Containers',
|
||||
value: '--',
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Version info
|
||||
Center(
|
||||
child: Text(
|
||||
'${AppVersion.name} v${AppVersion.fullVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 32, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Vertical divider
|
||||
VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
|
||||
// Main content area - switches based on mode
|
||||
Expanded(
|
||||
child: _buildContent(context, ref, frontHallState),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
FrontHallState state,
|
||||
) {
|
||||
switch (state.mode) {
|
||||
case FrontHallMode.dashboard:
|
||||
return const DashboardContent();
|
||||
|
||||
case FrontHallMode.iframe:
|
||||
return IframeView(
|
||||
url: state.activeIframeUrl!,
|
||||
title: state.activeIframeTitle ?? 'External Content',
|
||||
onClose: () =>
|
||||
ref.read(frontHallStateProvider.notifier).showDashboard(),
|
||||
);
|
||||
|
||||
case FrontHallMode.settings:
|
||||
return const QuickLinkSettingsContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'front_hall_state_provider.freezed.dart';
|
||||
part 'front_hall_state_provider.g.dart';
|
||||
|
||||
/// Front Hall content display mode.
|
||||
enum FrontHallMode {
|
||||
/// Shows dashboard widgets (stats, weather, etc.).
|
||||
dashboard,
|
||||
|
||||
/// Shows embedded iframe content.
|
||||
iframe,
|
||||
|
||||
/// Shows link management/settings UI.
|
||||
settings,
|
||||
}
|
||||
|
||||
/// State for Front Hall content area.
|
||||
@freezed
|
||||
sealed class FrontHallState with _$FrontHallState {
|
||||
const factory FrontHallState({
|
||||
/// Current display mode.
|
||||
@Default(FrontHallMode.dashboard) FrontHallMode mode,
|
||||
|
||||
/// Active iframe URL (when mode is iframe).
|
||||
String? activeIframeUrl,
|
||||
|
||||
/// Active iframe title (when mode is iframe).
|
||||
String? activeIframeTitle,
|
||||
|
||||
/// Selected link ID for editing (when mode is settings).
|
||||
/// Null means creating a new link.
|
||||
String? selectedLinkId,
|
||||
}) = _FrontHallState;
|
||||
|
||||
const FrontHallState._();
|
||||
}
|
||||
|
||||
/// Controller for Front Hall state.
|
||||
@riverpod
|
||||
class FrontHallStateNotifier extends _$FrontHallStateNotifier {
|
||||
@override
|
||||
FrontHallState build() => const FrontHallState();
|
||||
|
||||
/// Shows the dashboard (default mode).
|
||||
void showDashboard() {
|
||||
state = const FrontHallState();
|
||||
}
|
||||
|
||||
/// Shows an iframe with the given URL and title.
|
||||
void showIframe(String url, String title) {
|
||||
state = FrontHallState(
|
||||
mode: FrontHallMode.iframe,
|
||||
activeIframeUrl: url,
|
||||
activeIframeTitle: title,
|
||||
);
|
||||
}
|
||||
|
||||
/// Enters settings mode, optionally selecting a link for editing.
|
||||
void enterSettings({String? linkId}) {
|
||||
state = FrontHallState(
|
||||
mode: FrontHallMode.settings,
|
||||
selectedLinkId: linkId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Selects a link for editing (while in settings mode).
|
||||
void selectLink(String linkId) {
|
||||
state = state.copyWith(selectedLinkId: linkId);
|
||||
}
|
||||
|
||||
/// Clears the selected link (for creating new).
|
||||
void clearSelectedLink() {
|
||||
state = state.copyWith(selectedLinkId: null);
|
||||
}
|
||||
|
||||
/// Exits settings mode and returns to dashboard.
|
||||
void exitSettings() {
|
||||
state = const FrontHallState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/datasources/quick_links_datasource.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
|
||||
part 'quick_links_provider.g.dart';
|
||||
|
||||
/// Provides the list of all quick links.
|
||||
/// Falls back to default links if API is unavailable.
|
||||
@riverpod
|
||||
Future<List<QuickLink>> quickLinks(Ref ref) async {
|
||||
try {
|
||||
final datasource = ref.watch(quickLinksDatasourceProvider);
|
||||
final links = await datasource.getQuickLinks();
|
||||
// Return defaults if API returns empty list
|
||||
return links.isEmpty ? getDefaultQuickLinks() : links;
|
||||
} catch (_) {
|
||||
// Return default links if API fails (e.g., backend not running)
|
||||
return getDefaultQuickLinks();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides a single quick link by ID.
|
||||
/// Falls back to default links if API is unavailable.
|
||||
@riverpod
|
||||
Future<QuickLink> quickLink(Ref ref, String id) async {
|
||||
// First try to find in defaults (works offline)
|
||||
final defaults = getDefaultQuickLinks();
|
||||
final defaultLink = defaults.where((link) => link.id == id).firstOrNull;
|
||||
if (defaultLink != null) {
|
||||
return defaultLink;
|
||||
}
|
||||
|
||||
// If not in defaults, try API
|
||||
try {
|
||||
final datasource = ref.watch(quickLinksDatasourceProvider);
|
||||
return await datasource.getQuickLink(id);
|
||||
} catch (_) {
|
||||
throw Exception('Link not found: $id');
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller for quick link CRUD operations.
|
||||
@riverpod
|
||||
class QuickLinkActions extends _$QuickLinkActions {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncValue.data(null);
|
||||
|
||||
/// Creates a new quick link.
|
||||
Future<QuickLink?> create(QuickLink link) async {
|
||||
state = const AsyncValue.loading();
|
||||
QuickLink? result;
|
||||
state = await AsyncValue.guard(() async {
|
||||
final datasource = ref.read(quickLinksDatasourceProvider);
|
||||
result = await datasource.createQuickLink(link);
|
||||
ref.invalidate(quickLinksProvider);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Updates an existing quick link.
|
||||
Future<QuickLink?> update(QuickLink link) async {
|
||||
state = const AsyncValue.loading();
|
||||
QuickLink? result;
|
||||
state = await AsyncValue.guard(() async {
|
||||
final datasource = ref.read(quickLinksDatasourceProvider);
|
||||
result = await datasource.updateQuickLink(link);
|
||||
ref.invalidate(quickLinksProvider);
|
||||
ref.invalidate(quickLinkProvider(link.id));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Deletes a quick link.
|
||||
Future<void> delete(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final datasource = ref.read(quickLinksDatasourceProvider);
|
||||
await datasource.deleteQuickLink(id);
|
||||
ref.invalidate(quickLinksProvider);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reorders quick links.
|
||||
Future<void> reorder(List<String> orderedIds) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final datasource = ref.read(quickLinksDatasourceProvider);
|
||||
await datasource.reorderQuickLinks(orderedIds);
|
||||
ref.invalidate(quickLinksProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Default seed data for quick links (used when API returns empty).
|
||||
List<QuickLink> getDefaultQuickLinks() {
|
||||
return [
|
||||
// Home category
|
||||
const QuickLink(
|
||||
id: 'jellyfin',
|
||||
name: 'Jellyfin',
|
||||
url: 'https://jellyfin.schweitz.net',
|
||||
iconName: 'movie',
|
||||
category: 'Home',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 0,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'webui',
|
||||
name: 'Open WebUI',
|
||||
url: 'https://webui.schweitz.net',
|
||||
iconName: 'chat',
|
||||
category: 'Home',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 1,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'searxng',
|
||||
name: 'SearXNG',
|
||||
url: 'https://search.schweitz.net',
|
||||
iconName: 'search',
|
||||
category: 'Home',
|
||||
type: QuickLinkType.newTab,
|
||||
sortOrder: 2,
|
||||
),
|
||||
// AMP category
|
||||
const QuickLink(
|
||||
id: 'amp',
|
||||
name: 'AMP Home',
|
||||
url: 'https://amp.schweitz.net',
|
||||
iconName: 'videogame_asset',
|
||||
category: 'AMP',
|
||||
type: QuickLinkType.newTab,
|
||||
sortOrder: 0,
|
||||
),
|
||||
// Coding category
|
||||
const QuickLink(
|
||||
id: 'gitea',
|
||||
name: 'Gitea',
|
||||
url: 'https://git.schweitz.net',
|
||||
iconName: 'code',
|
||||
category: 'Coding',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 0,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'cloud-ide',
|
||||
name: 'Cloud IDE',
|
||||
url: 'https://code.schweitz.net',
|
||||
iconName: 'terminal',
|
||||
category: 'Coding',
|
||||
type: QuickLinkType.newTab,
|
||||
sortOrder: 1,
|
||||
),
|
||||
// Infrastructure category
|
||||
const QuickLink(
|
||||
id: 'netdata',
|
||||
name: 'Netdata',
|
||||
url: 'https://netdata.schweitz.net',
|
||||
iconName: 'monitoring',
|
||||
category: 'Infrastructure',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 0,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'portainer',
|
||||
name: 'Portainer',
|
||||
url: 'https://portainer.schweitz.net',
|
||||
iconName: 'dns',
|
||||
category: 'Infrastructure',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 1,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'npm',
|
||||
name: 'Proxy Manager',
|
||||
url: 'https://npm.schweitz.net',
|
||||
iconName: 'public',
|
||||
category: 'Infrastructure',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 2,
|
||||
),
|
||||
const QuickLink(
|
||||
id: 'core-api',
|
||||
name: 'Core API',
|
||||
url: 'https://api.schweitz.net/docs',
|
||||
iconName: 'api',
|
||||
category: 'Infrastructure',
|
||||
type: QuickLinkType.iframe,
|
||||
sortOrder: 3,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/version.g.dart';
|
||||
|
||||
/// Dashboard content shown in Front Hall when mode is dashboard.
|
||||
///
|
||||
/// Displays system stats, welcome message, and version info.
|
||||
/// Will be expanded with weather and air quality widgets in Phase 3.
|
||||
class DashboardContent extends StatelessWidget {
|
||||
const DashboardContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Welcome card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waving_hand,
|
||||
size: 32,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Welcome to Tatlock',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Your homelab dashboard is ready. More features coming soon.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Quick stats placeholder
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.memory,
|
||||
label: 'CPU',
|
||||
value: '--',
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.storage,
|
||||
label: 'Memory',
|
||||
value: '--',
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: _StatCard(
|
||||
icon: Icons.dns,
|
||||
label: 'Containers',
|
||||
value: '--',
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Version info
|
||||
Center(
|
||||
child: Text(
|
||||
'${AppVersion.name} v${AppVersion.fullVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 32, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Conditional export for platform-specific iframe implementation.
|
||||
// Web uses actual iframe embedding, other platforms open in browser.
|
||||
export 'iframe_view_stub.dart'
|
||||
if (dart.library.html) 'iframe_view_web.dart';
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Stub implementation for non-web platforms.
|
||||
///
|
||||
/// Since iframes are web-only, this shows a message and offers
|
||||
/// to open the link in an external browser.
|
||||
class IframeView extends StatelessWidget {
|
||||
const IframeView({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String title;
|
||||
final VoidCallback onClose;
|
||||
|
||||
Future<void> _openInBrowser() async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Auto-open in browser and show message
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openInBrowser();
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.open_in_browser,
|
||||
size: 64,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Opening in Browser',
|
||||
style: textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Embedded views are not supported on this platform.\n"$title" is opening in your browser.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openInBrowser,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open Again'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onClose,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Back to Dashboard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:html' as html;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Embedded iframe view for displaying external content (web only).
|
||||
///
|
||||
/// Uses HtmlElementView for Flutter web to embed an iframe.
|
||||
/// Includes a header bar with title, refresh, open in new tab, and close actions.
|
||||
class IframeView extends StatefulWidget {
|
||||
const IframeView({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
/// URL to display in the iframe.
|
||||
final String url;
|
||||
|
||||
/// Title displayed in the header bar.
|
||||
final String title;
|
||||
|
||||
/// Callback when the close button is pressed.
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<IframeView> createState() => _IframeViewState();
|
||||
}
|
||||
|
||||
class _IframeViewState extends State<IframeView> {
|
||||
late final String _viewType;
|
||||
late html.IFrameElement _iframe;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewType = 'iframe-${widget.url.hashCode}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
_createIframe();
|
||||
}
|
||||
|
||||
void _createIframe() {
|
||||
_iframe = html.IFrameElement()
|
||||
..src = widget.url
|
||||
..style.border = 'none'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..allow = 'fullscreen'
|
||||
..onLoad.listen((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
});
|
||||
|
||||
// Register the view factory
|
||||
ui_web.platformViewRegistry.registerViewFactory(
|
||||
_viewType,
|
||||
(int viewId) => _iframe,
|
||||
);
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
setState(() => _isLoading = true);
|
||||
// Reload the iframe by setting src again
|
||||
_iframe.src = widget.url;
|
||||
}
|
||||
|
||||
Future<void> _openInNewTab() async {
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header bar
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Title
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
IconButton(
|
||||
icon: const Icon(Icons.open_in_new, size: 20),
|
||||
tooltip: 'Open in new tab',
|
||||
onPressed: _openInNewTab,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
tooltip: 'Close',
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Iframe content
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
HtmlElementView(viewType: _viewType),
|
||||
// Loading overlay
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: colorScheme.surface,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
||||
|
||||
/// Settings content for Quick Link management.
|
||||
///
|
||||
/// Shows either:
|
||||
/// - Link editor form (when a link is selected)
|
||||
/// - Empty state with "Add New Link" prompt (when no link selected)
|
||||
///
|
||||
/// Full implementation in Phase 4.
|
||||
class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
const QuickLinkSettingsContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final frontHallState = ref.watch(frontHallStateProvider);
|
||||
final selectedLinkId = frontHallState.selectedLinkId;
|
||||
|
||||
// No link selected - show empty state
|
||||
if (selectedLinkId == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_link,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No link selected',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a link from the panel to edit, or click "Add New Link" to create one.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Create new link - will show form in Phase 4
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link editor coming in Phase 4'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create New Link'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Link selected - show placeholder for editor (Phase 4)
|
||||
final linkAsync = ref.watch(quickLinkProvider(selectedLinkId));
|
||||
|
||||
return linkAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error loading link: $error'),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.invalidate(quickLinkProvider(selectedLinkId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (link) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.clearSelectedLink(),
|
||||
),
|
||||
title: Text('Edit: ${link.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Save link - Phase 4
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Save functionality coming in Phase 4'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Placeholder form fields (Phase 4)
|
||||
_PlaceholderField(label: 'Name', value: link.name),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(label: 'URL', value: link.url),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(
|
||||
label: 'Category',
|
||||
value: link.category ?? 'None',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(
|
||||
label: 'Type',
|
||||
value: link.isIframe ? 'Iframe' : 'New Tab',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(label: 'Icon', value: link.iconName),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Full link editor form will be implemented in Phase 4.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceholderField extends StatelessWidget {
|
||||
const _PlaceholderField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/panel_header.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Left-side panel for quick link navigation in Front Hall.
|
||||
///
|
||||
/// Shows categorized links that can open in iframe or new tab.
|
||||
/// Adapts behavior based on current mode (normal vs settings).
|
||||
class QuickLinksPanel extends ConsumerWidget {
|
||||
const QuickLinksPanel({
|
||||
super.key,
|
||||
this.width = 280,
|
||||
});
|
||||
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final frontHallState = ref.watch(frontHallStateProvider);
|
||||
final quickLinksAsync = ref.watch(quickLinksProvider);
|
||||
final isSettingsMode = frontHallState.mode == FrontHallMode.settings;
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header changes based on mode
|
||||
if (isSettingsMode)
|
||||
_SettingsModeHeader(
|
||||
onBack: () =>
|
||||
ref.read(frontHallStateProvider.notifier).exitSettings(),
|
||||
)
|
||||
else
|
||||
PanelHeader(
|
||||
title: 'Quick Links',
|
||||
icon: Icons.link,
|
||||
dockToBottom: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.invalidate(quickLinksProvider),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Add new link button (settings mode only)
|
||||
if (isSettingsMode) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.clearSelectedLink(),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add New Link'),
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
],
|
||||
|
||||
// Links list
|
||||
Expanded(
|
||||
child: quickLinksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
error: error,
|
||||
onRetry: () => ref.invalidate(quickLinksProvider),
|
||||
),
|
||||
data: (links) {
|
||||
// Use default links if empty
|
||||
final displayLinks =
|
||||
links.isEmpty ? getDefaultQuickLinks() : links;
|
||||
return _QuickLinksList(
|
||||
links: displayLinks,
|
||||
selectedLinkId: frontHallState.selectedLinkId,
|
||||
isSettingsMode: isSettingsMode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Settings button (normal mode only)
|
||||
if (!isSettingsMode) ...[
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.enterSettings(),
|
||||
icon: const Icon(Icons.settings, size: 18),
|
||||
label: const Text('Settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header for settings mode with back button.
|
||||
class _SettingsModeHeader extends StatelessWidget {
|
||||
const _SettingsModeHeader({required this.onBack});
|
||||
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back to Dashboard',
|
||||
onPressed: onBack,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.link, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Quick Links',
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// List of quick links grouped by category.
|
||||
class _QuickLinksList extends ConsumerWidget {
|
||||
const _QuickLinksList({
|
||||
required this.links,
|
||||
required this.selectedLinkId,
|
||||
required this.isSettingsMode,
|
||||
});
|
||||
|
||||
final List<QuickLink> links;
|
||||
final String? selectedLinkId;
|
||||
final bool isSettingsMode;
|
||||
|
||||
/// Groups links by category, preserving order.
|
||||
List<(String?, List<QuickLink>)> get _groupedLinks {
|
||||
final groups = <String?, List<QuickLink>>{};
|
||||
final order = <String?>[];
|
||||
|
||||
for (final link in links) {
|
||||
if (!groups.containsKey(link.category)) {
|
||||
groups[link.category] = [];
|
||||
order.add(link.category);
|
||||
}
|
||||
groups[link.category]!.add(link);
|
||||
}
|
||||
|
||||
return order.map((category) => (category, groups[category]!)).toList();
|
||||
}
|
||||
|
||||
bool get _showCategoryHeaders {
|
||||
final categories =
|
||||
links.map((l) => l.category).where((c) => c != null).toSet();
|
||||
return categories.length > 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: _buildLinkList(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildLinkList(BuildContext context, WidgetRef ref) {
|
||||
final widgets = <Widget>[];
|
||||
final showHeaders = _showCategoryHeaders;
|
||||
|
||||
for (final (category, categoryLinks) in _groupedLinks) {
|
||||
// Add category header if multiple categories exist
|
||||
if (showHeaders && category != null) {
|
||||
widgets.add(_CategoryHeader(title: category));
|
||||
}
|
||||
|
||||
// Add links
|
||||
for (final link in categoryLinks) {
|
||||
widgets.add(
|
||||
_QuickLinkTile(
|
||||
link: link,
|
||||
isSelected: link.id == selectedLinkId,
|
||||
isSettingsMode: isSettingsMode,
|
||||
onTap: () => _handleLinkTap(ref, link),
|
||||
onEdit: isSettingsMode
|
||||
? () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.selectLink(link.id)
|
||||
: null,
|
||||
onDelete: isSettingsMode
|
||||
? () => _handleDelete(context, ref, link)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
void _handleLinkTap(WidgetRef ref, QuickLink link) {
|
||||
if (isSettingsMode) {
|
||||
// In settings mode, select link for editing
|
||||
ref.read(frontHallStateProvider.notifier).selectLink(link.id);
|
||||
} else {
|
||||
// In normal mode, open link
|
||||
if (link.isIframe) {
|
||||
ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.showIframe(link.url, link.name);
|
||||
} else {
|
||||
// Open in new tab
|
||||
_openInNewTab(link.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openInNewTab(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDelete(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
QuickLink link,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Link'),
|
||||
content: Text('Delete "${link.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(quickLinkActionsProvider.notifier).delete(link.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Category header with left accent bar.
|
||||
class _CategoryHeader extends StatelessWidget {
|
||||
const _CategoryHeader({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual quick link tile.
|
||||
class _QuickLinkTile extends StatelessWidget {
|
||||
const _QuickLinkTile({
|
||||
required this.link,
|
||||
required this.isSelected,
|
||||
required this.isSettingsMode,
|
||||
required this.onTap,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final QuickLink link;
|
||||
final bool isSelected;
|
||||
final bool isSettingsMode;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
IconData _getIconData(String iconName) {
|
||||
// Map common icon names to Material icons
|
||||
return switch (iconName.toLowerCase()) {
|
||||
'home' => Icons.home,
|
||||
'movie' => Icons.movie,
|
||||
'chat' => Icons.chat,
|
||||
'search' => Icons.search,
|
||||
'videogame_asset' => Icons.videogame_asset,
|
||||
'code' => Icons.code,
|
||||
'terminal' => Icons.terminal,
|
||||
'monitoring' => Icons.show_chart,
|
||||
'dns' => Icons.dns,
|
||||
'public' => Icons.public,
|
||||
'api' => Icons.api,
|
||||
'settings' => Icons.settings,
|
||||
'storage' => Icons.storage,
|
||||
'cloud' => Icons.cloud,
|
||||
'security' => Icons.security,
|
||||
'person' => Icons.person,
|
||||
'folder' => Icons.folder,
|
||||
'link' => Icons.link,
|
||||
_ => Icons.link,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getIconData(link.iconName),
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
link.name,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
isSelected ? colorScheme.primary : colorScheme.onSurface,
|
||||
fontWeight: isSelected ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
if (isSettingsMode)
|
||||
Text(
|
||||
link.isNewTab ? 'Opens in new tab' : 'Opens in iframe',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// New tab indicator (normal mode)
|
||||
if (!isSettingsMode && link.isNewTab)
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 14,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
// Overflow menu (settings mode)
|
||||
if (isSettingsMode)
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
onEdit?.call();
|
||||
case 'delete':
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Error state widget.
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Failed to load links',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user