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
+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,
};
}