feat: Phase 1 foundation - core infrastructure and navigation

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 17:15:54 +01:00
co-authored by Claude Opus 4.5
parent 7d13a02382
commit 3af4fd16f8
18 changed files with 1005 additions and 52 deletions
+2
View File
@@ -5,6 +5,8 @@ build/
.pub-cache/
.pub/
pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
# Generated files
*.freezed.dart
+18
View File
@@ -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
+33
View File
@@ -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,
);
}
}
+55
View File
@@ -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;
}
+137
View File
@@ -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<String, dynamic>) {
final error = data['error'];
if (error is Map<String, dynamic>) {
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,
);
}
}
}
+127
View File
@@ -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<AuthState> build() async {
return _loadStoredAuth();
}
Future<AuthState> _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<void> 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<void> signOut() async {
await _clearStoredAuth();
state = const AsyncData(AuthState());
developer.log('Signed out', name: 'auth');
}
/// Update auth state (called after successful OIDC flow).
Future<void> 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<void> _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);
}
}
+26
View File
@@ -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)));
}
}
+40
View File
@@ -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);
}
+53
View File
@@ -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<String, String> fieldErrors;
}
+64
View File
@@ -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);
}
+55
View File
@@ -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<void> _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<void> 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,
};
}
@@ -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,
),
),
],
),
),
);
}
}
+7 -34
View File
@@ -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(),
),
);
}
+107
View File
@@ -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,
),
),
],
),
);
}
}
+72
View File
@@ -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);
}
}
+3 -3
View File
@@ -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';
}
+39 -10
View File
@@ -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
+12 -5
View File
@@ -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);
});
}