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:
Jeroen Schweitzer
2026-01-02 22:24:20 +01:00
co-authored by Claude Opus 4.5
parent 4f8d0f521f
commit 40db92d9d5
13 changed files with 1693 additions and 133 deletions
+72 -4
View File
@@ -191,13 +191,81 @@ The NavPanel widget receives items and auto-generates section headers based on u
## Panel Configurations by Room ## Panel Configurations by Room
### Front Hall ### 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 │ │ QUICK LINKS │ PRIMARY CONTENT │ CHAT DOCK │
Dashboard cards, activity feed │ (expanded) │ │ (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 ### 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: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. /// 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}); const FrontHallPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final frontHallState = ref.watch(frontHallStateProvider);
// No Scaffold/AppBar needed - header is provided by AppScaffold return Row(
return ListView( children: [
padding: const EdgeInsets.all(16), // Left panel - Quick Links
children: [ const QuickLinksPanel(),
// 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 // Vertical divider
Wrap( VerticalDivider(
spacing: 12, width: 1,
runSpacing: 12, thickness: 1,
children: [ color: colorScheme.outlineVariant,
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,
),
),
],
), ),
),
// 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'),
),
],
),
),
);
}
}