From 3af4fd16f8fbb93052fc43e7a5ddb171cafd4617 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 30 Dec 2025 17:15:54 +0100 Subject: [PATCH] feat: Phase 1 foundation - core infrastructure and navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean Architecture structure: - core/config - Environment configuration - core/theme - Material 3 theming with FlexColorScheme - core/error - Typed exception hierarchy - core/api - Dio HTTP clients with interceptors - core/auth - Authentication state management - routing - go_router with shell navigation - shared/layouts - Adaptive scaffold Dependencies added: - flutter_riverpod, riverpod_annotation, riverpod_generator - freezed, freezed_annotation, json_serializable - dio, go_router, shared_preferences - flex_color_scheme, flutter_adaptive_scaffold Features: - Responsive navigation (rail on desktop, bottom on mobile) - Dashboard placeholder with welcome card - Theme switching infrastructure - API client ready for Core API and Tatlock API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 + CHANGELOG.md | 18 ++ lib/app.dart | 33 ++++ lib/core/api/api_client.dart | 55 +++++++ lib/core/api/api_interceptors.dart | 137 ++++++++++++++++ lib/core/auth/auth_provider.dart | 127 ++++++++++++++ lib/core/auth/auth_state.dart | 26 +++ lib/core/config/app_config.dart | 40 +++++ lib/core/error/app_exception.dart | 53 ++++++ lib/core/theme/app_theme.dart | 64 ++++++++ lib/core/theme/theme_provider.dart | 55 +++++++ .../presentation/pages/dashboard_page.dart | 155 ++++++++++++++++++ lib/main.dart | 41 +---- lib/routing/app_router.dart | 107 ++++++++++++ lib/shared/layouts/app_scaffold.dart | 72 ++++++++ lib/version.g.dart | 6 +- pubspec.yaml | 49 ++++-- test/widget_test.dart | 17 +- 18 files changed, 1005 insertions(+), 52 deletions(-) create mode 100644 lib/app.dart create mode 100644 lib/core/api/api_client.dart create mode 100644 lib/core/api/api_interceptors.dart create mode 100644 lib/core/auth/auth_provider.dart create mode 100644 lib/core/auth/auth_state.dart create mode 100644 lib/core/config/app_config.dart create mode 100644 lib/core/error/app_exception.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/core/theme/theme_provider.dart create mode 100644 lib/features/dashboard/presentation/pages/dashboard_page.dart create mode 100644 lib/routing/app_router.dart create mode 100644 lib/shared/layouts/app_scaffold.dart diff --git a/.gitignore b/.gitignore index 2adf488..6ce245c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build/ .pub-cache/ .pub/ pubspec.lock +.flutter-plugins +.flutter-plugins-dependencies # Generated files *.freezed.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dfaa056..0d599e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2024-12-30 + +### Added +- **Phase 1: Foundation complete** +- Clean Architecture folder structure (`core/`, `features/`, `shared/`, `routing/`) +- Full dependency stack: Riverpod, Dio, go_router, freezed, flex_color_scheme +- Core infrastructure: + - `core/config/app_config.dart` - Environment configuration + - `core/theme/app_theme.dart` - Material 3 theming with FlexColorScheme + - `core/theme/theme_provider.dart` - Theme state with persistence + - `core/error/app_exception.dart` - Typed exception hierarchy + - `core/api/api_client.dart` - Dio HTTP clients for Core API and Tatlock API + - `core/api/api_interceptors.dart` - Auth, logging, error interceptors + - `core/auth/auth_provider.dart` - Authentication state management +- Routing with go_router and shell route for navigation +- Adaptive scaffold with responsive navigation (rail/bottom nav) +- Dashboard page placeholder with welcome card and stat cards + ## [0.1.0] - 2024-12-30 ### Added diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..078b9f7 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/theme/app_theme.dart'; +import 'core/theme/theme_provider.dart'; +import 'routing/app_router.dart'; + +/// Root application widget. +class TatlockApp extends ConsumerWidget { + const TatlockApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeAsync = ref.watch(themeNotifierProvider); + + // Get theme mode, defaulting to system while loading + final themeMode = switch (themeAsync) { + ThemeSetting.system => ThemeMode.system, + ThemeSetting.light => ThemeMode.light, + ThemeSetting.dark => ThemeMode.dark, + }; + + return MaterialApp.router( + title: 'Tatlock UI', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + routerConfig: router, + ); + } +} diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart new file mode 100644 index 0000000..0d556c7 --- /dev/null +++ b/lib/core/api/api_client.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../config/app_config.dart'; +import 'api_interceptors.dart'; + +part 'api_client.g.dart'; + +/// Provides the Dio instance for Core API. +@riverpod +Dio coreApiClient(CoreApiClientRef ref) { + final dio = Dio( + BaseOptions( + baseUrl: AppConfig.coreApiUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + dio.interceptors.addAll([ + AuthInterceptor(ref), + LoggingInterceptor(), + ErrorInterceptor(), + ]); + + return dio; +} + +/// Provides the Dio instance for Tatlock API. +@riverpod +Dio tatlockApiClient(TatlockApiClientRef ref) { + final dio = Dio( + BaseOptions( + baseUrl: AppConfig.tatlockApiUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + dio.interceptors.addAll([ + AuthInterceptor(ref), + LoggingInterceptor(), + ErrorInterceptor(), + ]); + + return dio; +} diff --git a/lib/core/api/api_interceptors.dart b/lib/core/api/api_interceptors.dart new file mode 100644 index 0000000..2973596 --- /dev/null +++ b/lib/core/api/api_interceptors.dart @@ -0,0 +1,137 @@ +import 'dart:developer' as developer; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import '../error/app_exception.dart'; + +/// Adds authentication token to requests. +class AuthInterceptor extends Interceptor { + AuthInterceptor(this._ref); + + final Ref _ref; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final authState = _ref.read(authNotifierProvider); + + authState.whenData((auth) { + if (auth.isAuthenticated && auth.accessToken != null) { + options.headers['Authorization'] = 'Bearer ${auth.accessToken}'; + } + }); + + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.response?.statusCode == 401) { + // Token expired - trigger re-authentication + _ref.read(authNotifierProvider.notifier).signOut(); + } + handler.next(err); + } +} + +/// Logs requests and responses in debug mode. +class LoggingInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + developer.log( + '→ ${options.method} ${options.uri}', + name: 'api', + ); + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + developer.log( + '← ${response.statusCode} ${response.requestOptions.uri}', + name: 'api', + ); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + developer.log( + '✗ ${err.response?.statusCode ?? 'NETWORK'} ${err.requestOptions.uri}: ${err.message}', + name: 'api', + error: err, + ); + handler.next(err); + } +} + +/// Converts Dio errors to AppException types. +class ErrorInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final exception = _mapToAppException(err); + handler.reject( + DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: exception, + ), + ); + } + + AppException _mapToAppException(DioException err) { + switch (err.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException( + message: 'Connection timed out', + cause: err, + ); + + case DioExceptionType.connectionError: + return NetworkException( + message: 'Unable to connect to server', + cause: err, + ); + + case DioExceptionType.badResponse: + final statusCode = err.response?.statusCode ?? 0; + final data = err.response?.data; + + String message = 'Request failed'; + String? code; + + if (data is Map) { + final error = data['error']; + if (error is Map) { + message = error['message'] as String? ?? message; + code = error['code'] as String?; + } else if (error is String) { + message = error; + } + } + + return ApiException( + message: message, + statusCode: statusCode, + code: code, + cause: err, + ); + + case DioExceptionType.cancel: + return const NetworkException(message: 'Request cancelled'); + + case DioExceptionType.badCertificate: + return const NetworkException(message: 'Invalid SSL certificate'); + + case DioExceptionType.unknown: + return NetworkException( + message: err.message ?? 'Unknown error', + cause: err, + ); + } + } +} diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart new file mode 100644 index 0000000..b0ccbc1 --- /dev/null +++ b/lib/core/auth/auth_provider.dart @@ -0,0 +1,127 @@ +import 'dart:developer' as developer; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth_state.dart'; + +part 'auth_provider.g.dart'; + +/// Provides authentication state and operations. +/// +/// Note: Full OIDC implementation with flutter_appauth requires +/// native platform configuration. For now, this provides the +/// state management infrastructure. +@riverpod +class AuthNotifier extends _$AuthNotifier { + static const _accessTokenKey = 'auth_access_token'; + static const _refreshTokenKey = 'auth_refresh_token'; + static const _expiresAtKey = 'auth_expires_at'; + static const _userIdKey = 'auth_user_id'; + static const _userNameKey = 'auth_user_name'; + static const _userEmailKey = 'auth_user_email'; + + @override + Future build() async { + return _loadStoredAuth(); + } + + Future _loadStoredAuth() async { + try { + final prefs = await SharedPreferences.getInstance(); + + final accessToken = prefs.getString(_accessTokenKey); + if (accessToken == null) { + return const AuthState(); + } + + final expiresAtMs = prefs.getInt(_expiresAtKey); + final expiresAt = expiresAtMs != null + ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) + : null; + + final authState = AuthState( + isAuthenticated: true, + accessToken: accessToken, + refreshToken: prefs.getString(_refreshTokenKey), + expiresAt: expiresAt, + userId: prefs.getString(_userIdKey), + userName: prefs.getString(_userNameKey), + userEmail: prefs.getString(_userEmailKey), + ); + + // Check if token is expired + if (authState.isTokenExpired) { + developer.log('Stored token expired, clearing auth', name: 'auth'); + await _clearStoredAuth(); + return const AuthState(); + } + + developer.log('Restored auth for ${authState.userName}', name: 'auth'); + return authState; + } catch (e) { + developer.log('Failed to load stored auth: $e', name: 'auth'); + return const AuthState(); + } + } + + /// Sign in with OIDC (placeholder for flutter_appauth integration). + Future signIn() async { + // TODO: Implement OIDC flow with flutter_appauth + // For now, this is a placeholder that will be implemented + // when native platform configuration is complete. + developer.log('Sign in requested - OIDC not yet configured', name: 'auth'); + } + + /// Sign out and clear stored credentials. + Future signOut() async { + await _clearStoredAuth(); + state = const AsyncData(AuthState()); + developer.log('Signed out', name: 'auth'); + } + + /// Update auth state (called after successful OIDC flow). + Future setAuthenticated({ + required String accessToken, + String? refreshToken, + DateTime? expiresAt, + String? userId, + String? userName, + String? userEmail, + }) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString(_accessTokenKey, accessToken); + if (refreshToken != null) { + await prefs.setString(_refreshTokenKey, refreshToken); + } + if (expiresAt != null) { + await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch); + } + if (userId != null) await prefs.setString(_userIdKey, userId); + if (userName != null) await prefs.setString(_userNameKey, userName); + if (userEmail != null) await prefs.setString(_userEmailKey, userEmail); + + state = AsyncData(AuthState( + isAuthenticated: true, + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: expiresAt, + userId: userId, + userName: userName, + userEmail: userEmail, + )); + + developer.log('Authenticated as $userName', name: 'auth'); + } + + Future _clearStoredAuth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_accessTokenKey); + await prefs.remove(_refreshTokenKey); + await prefs.remove(_expiresAtKey); + await prefs.remove(_userIdKey); + await prefs.remove(_userNameKey); + await prefs.remove(_userEmailKey); + } +} diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart new file mode 100644 index 0000000..900ad51 --- /dev/null +++ b/lib/core/auth/auth_state.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_state.freezed.dart'; + +/// Authentication state. +@freezed +class AuthState with _$AuthState { + const factory AuthState({ + @Default(false) bool isAuthenticated, + String? accessToken, + String? refreshToken, + DateTime? expiresAt, + String? userId, + String? userName, + String? userEmail, + }) = _AuthState; + + const AuthState._(); + + /// Check if token is expired or will expire soon. + bool get isTokenExpired { + if (expiresAt == null) return true; + // Consider expired if less than 1 minute remaining + return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1))); + } +} diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..76946be --- /dev/null +++ b/lib/core/config/app_config.dart @@ -0,0 +1,40 @@ +/// Application configuration from compile-time environment variables. +/// +/// Set via: flutter build --dart-define=API_URL=https://... +class AppConfig { + AppConfig._(); + + /// Core API base URL + static const coreApiUrl = String.fromEnvironment( + 'CORE_API_URL', + defaultValue: 'https://api.schweitz.net', + ); + + /// Tatlock API base URL + static const tatlockApiUrl = String.fromEnvironment( + 'TATLOCK_API_URL', + defaultValue: 'https://tatlock.schweitz.net', + ); + + /// Authentik OIDC discovery URL + static const authDiscoveryUrl = String.fromEnvironment( + 'AUTH_DISCOVERY_URL', + defaultValue: + 'https://auth.schweitz.net/application/o/tatlock-ui/.well-known/openid-configuration', + ); + + /// Authentik client ID + static const authClientId = String.fromEnvironment( + 'AUTH_CLIENT_ID', + defaultValue: 'tatlock-ui', + ); + + /// Authentik redirect URI scheme + static const authRedirectScheme = String.fromEnvironment( + 'AUTH_REDIRECT_SCHEME', + defaultValue: 'net.schweitz.tatlock', + ); + + /// Whether running in debug mode + static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false); +} diff --git a/lib/core/error/app_exception.dart b/lib/core/error/app_exception.dart new file mode 100644 index 0000000..68b39b6 --- /dev/null +++ b/lib/core/error/app_exception.dart @@ -0,0 +1,53 @@ +/// Base exception for all application errors. +sealed class AppException implements Exception { + const AppException({required this.message, this.cause}); + + final String message; + final Object? cause; + + @override + String toString() => 'AppException: $message'; +} + +/// Network-related errors (connection, timeout, etc.) +class NetworkException extends AppException { + const NetworkException({required super.message, super.cause}); +} + +/// API errors with HTTP status codes. +class ApiException extends AppException { + const ApiException({ + required super.message, + required this.statusCode, + this.code, + super.cause, + }); + + final int statusCode; + final String? code; + + bool get isUnauthorized => statusCode == 401; + bool get isForbidden => statusCode == 403; + bool get isNotFound => statusCode == 404; + bool get isServerError => statusCode >= 500; +} + +/// Authentication-related errors. +class AuthException extends AppException { + const AuthException({required super.message, super.cause}); +} + +/// Cache/storage errors. +class CacheException extends AppException { + const CacheException({required super.message, super.cause}); +} + +/// Validation errors. +class ValidationException extends AppException { + const ValidationException({ + required super.message, + this.fieldErrors = const {}, + }); + + final Map fieldErrors; +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..28b5bdb --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,64 @@ +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; + +/// Application theme configuration using Material 3 and FlexColorScheme. +class AppTheme { + AppTheme._(); + + /// Light theme + static ThemeData get light => FlexThemeData.light( + scheme: FlexScheme.aquaBlue, + surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold, + blendLevel: 9, + subThemesData: const FlexSubThemesData( + blendOnLevel: 10, + blendOnColors: false, + useM2StyleDividerInM3: true, + inputDecoratorBorderType: FlexInputBorderType.outline, + inputDecoratorRadius: 8.0, + chipRadius: 8.0, + dialogRadius: 16.0, + cardRadius: 12.0, + ), + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + fontFamily: null, // Use system font + ); + + /// Dark theme + static ThemeData get dark => FlexThemeData.dark( + scheme: FlexScheme.aquaBlue, + surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold, + blendLevel: 15, + subThemesData: const FlexSubThemesData( + blendOnLevel: 20, + useM2StyleDividerInM3: true, + inputDecoratorBorderType: FlexInputBorderType.outline, + inputDecoratorRadius: 8.0, + chipRadius: 8.0, + dialogRadius: 16.0, + cardRadius: 12.0, + ), + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + fontFamily: null, + ); +} + +/// Semantic color extensions for domain-specific colors. +extension SemanticColors on ColorScheme { + /// Success state color (green) + Color get success => brightness == Brightness.light + ? const Color(0xFF2E7D32) + : const Color(0xFF81C784); + + /// Warning state color (orange) + Color get warning => brightness == Brightness.light + ? const Color(0xFFF57C00) + : const Color(0xFFFFB74D); + + /// Info state color (blue) + Color get info => brightness == Brightness.light + ? const Color(0xFF1976D2) + : const Color(0xFF64B5F6); +} diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart new file mode 100644 index 0000000..6cbca9d --- /dev/null +++ b/lib/core/theme/theme_provider.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'theme_provider.g.dart'; + +/// User's theme preference setting. +enum ThemeSetting { + /// Follow system preference + system, + + /// Always light mode + light, + + /// Always dark mode + dark, +} + +/// Provider for theme setting state. +@riverpod +class ThemeNotifier extends _$ThemeNotifier { + static const _prefsKey = 'theme_setting'; + + @override + ThemeSetting build() { + _loadSavedSetting(); + return ThemeSetting.system; + } + + Future _loadSavedSetting() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getString(_prefsKey); + if (value != null) { + try { + state = ThemeSetting.values.byName(value); + } catch (_) { + // Invalid value, keep default + } + } + } + + /// Update theme setting and persist to storage. + Future setSetting(ThemeSetting setting) async { + state = setting; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, setting.name); + } + + /// Get the ThemeMode for MaterialApp. + ThemeMode get themeMode => switch (state) { + ThemeSetting.system => ThemeMode.system, + ThemeSetting.light => ThemeMode.light, + ThemeSetting.dark => ThemeMode.dark, + }; +} diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart new file mode 100644 index 0000000..562fe0e --- /dev/null +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../../../../version.g.dart'; + +/// Dashboard home page - overview of the homelab. +class DashboardPage extends StatelessWidget { + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + // TODO: Refresh data + }, + ), + ], + ), + body: 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 29a41fa..6b1178c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; import 'version.g.dart'; void main() { @@ -12,38 +14,9 @@ void main() { name: 'tatlock_ui', ); - runApp(const TatlockApp()); -} - -class TatlockApp extends StatelessWidget { - const TatlockApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Tatlock UI', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), - useMaterial3: true, - ), - home: const HomePage(), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text('Tatlock UI'), - ), - body: const Center( - child: Text('Welcome to Tatlock UI'), - ), - ); - } + runApp( + const ProviderScope( + child: TatlockApp(), + ), + ); } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart new file mode 100644 index 0000000..0ae71b6 --- /dev/null +++ b/lib/routing/app_router.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../shared/layouts/app_scaffold.dart'; +import '../features/dashboard/presentation/pages/dashboard_page.dart'; + +part 'app_router.g.dart'; + +/// Route paths as constants. +abstract class AppRoutes { + static const dashboard = '/'; + static const chat = '/chat'; + static const containers = '/containers'; + static const containerDetail = '/containers/:id'; + static const housekeeping = '/housekeeping'; + static const settings = '/settings'; +} + +/// Provides the GoRouter instance. +@riverpod +GoRouter appRouter(AppRouterRef ref) { + return GoRouter( + initialLocation: AppRoutes.dashboard, + debugLogDiagnostics: true, + routes: [ + ShellRoute( + builder: (context, state, child) => AppScaffold(child: child), + routes: [ + GoRoute( + path: AppRoutes.dashboard, + name: 'dashboard', + builder: (context, state) => const DashboardPage(), + ), + GoRoute( + path: AppRoutes.chat, + name: 'chat', + builder: (context, state) => const _PlaceholderPage(title: 'Chat'), + ), + GoRoute( + path: AppRoutes.containers, + name: 'containers', + builder: (context, state) => + const _PlaceholderPage(title: 'Containers'), + routes: [ + GoRoute( + path: ':id', + name: 'containerDetail', + builder: (context, state) { + final id = state.pathParameters['id']!; + return _PlaceholderPage(title: 'Container: $id'); + }, + ), + ], + ), + GoRoute( + path: AppRoutes.housekeeping, + name: 'housekeeping', + builder: (context, state) => + const _PlaceholderPage(title: 'Housekeeping'), + ), + GoRoute( + path: AppRoutes.settings, + name: 'settings', + builder: (context, state) => + const _PlaceholderPage(title: 'Settings'), + ), + ], + ), + ], + ); +} + +/// Placeholder page for routes not yet implemented. +class _PlaceholderPage extends StatelessWidget { + const _PlaceholderPage({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.construction, + size: 64, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Coming soon', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart new file mode 100644 index 0000000..8323ff9 --- /dev/null +++ b/lib/shared/layouts/app_scaffold.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:go_router/go_router.dart'; + +import '../../routing/app_router.dart'; + +/// Main application scaffold with adaptive navigation. +class AppScaffold extends StatelessWidget { + const AppScaffold({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return AdaptiveScaffold( + selectedIndex: _selectedIndex(context), + onSelectedIndexChange: (index) => _onNavSelected(context, index), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Dashboard', + ), + NavigationDestination( + icon: Icon(Icons.chat_outlined), + selectedIcon: Icon(Icons.chat), + label: 'Tatlock', + ), + NavigationDestination( + icon: Icon(Icons.dns_outlined), + selectedIcon: Icon(Icons.dns), + label: 'Containers', + ), + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Housekeeping', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + body: (_) => child, + smallBody: (_) => child, + useDrawer: false, + ); + } + + int _selectedIndex(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + + if (location.startsWith(AppRoutes.containers)) return 2; + if (location.startsWith(AppRoutes.chat)) return 1; + if (location.startsWith(AppRoutes.housekeeping)) return 3; + if (location.startsWith(AppRoutes.settings)) return 4; + return 0; // Dashboard + } + + void _onNavSelected(BuildContext context, int index) { + final route = switch (index) { + 0 => AppRoutes.dashboard, + 1 => AppRoutes.chat, + 2 => AppRoutes.containers, + 3 => AppRoutes.housekeeping, + 4 => AppRoutes.settings, + _ => AppRoutes.dashboard, + }; + context.go(route); + } +} diff --git a/lib/version.g.dart b/lib/version.g.dart index 18c512e..5fbc6ce 100644 --- a/lib/version.g.dart +++ b/lib/version.g.dart @@ -7,7 +7,7 @@ class AppVersion { static const String name = 'tatlock_ui'; static const String description = 'Tatlock - a Home Lab AI'; - static const String version = '0.1.0'; - static const int buildNumber = 1; - static const String fullVersion = '0.1.0+1'; + static const String version = '0.2.0'; + static const int buildNumber = 2; + static const String fullVersion = '0.2.0+2'; } diff --git a/pubspec.yaml b/pubspec.yaml index e632e00..c1590a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.0+1 +version: 0.2.0+2 environment: sdk: ^3.10.4 @@ -31,22 +31,51 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + # State Management + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + hooks_riverpod: ^2.6.1 + flutter_hooks: ^0.20.5 + + # Code Generation Support + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + + # Networking + dio: ^5.7.0 + + # Routing + go_router: ^14.6.2 + + # Storage + shared_preferences: ^2.3.3 + + # UI + flex_color_scheme: ^8.1.0 + flutter_adaptive_scaffold: ^0.3.1 + flutter_markdown: ^0.7.4 + fl_chart: ^0.69.2 + + # Icons cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 + # Linting + flutter_lints: ^5.0.0 - # For build-time version generation from pubspec.yaml + # Code Generation + build_runner: ^2.4.13 + freezed: ^2.5.7 + json_serializable: ^6.8.0 + riverpod_generator: ^2.6.3 + + # Testing + mocktail: ^1.0.4 + + # Build tools yaml: ^3.1.0 # For information on the generic Dart part of this file, see the diff --git a/test/widget_test.dart b/test/widget_test.dart index eddec0b..8b4ebdc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,12 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tatlock_ui/main.dart'; +import 'package:tatlock_ui/app.dart'; void main() { - testWidgets('App renders home page', (WidgetTester tester) async { - await tester.pumpWidget(const TatlockApp()); + testWidgets('App renders dashboard', (WidgetTester tester) async { + await tester.pumpWidget( + const ProviderScope( + child: TatlockApp(), + ), + ); - expect(find.text('Tatlock UI'), findsOneWidget); - expect(find.text('Welcome to Tatlock UI'), findsOneWidget); + // Wait for async operations + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Tatlock'), findsOneWidget); }); }