feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Auth Slice
|
||||
|
||||
This slice owns sign-in UI and session lifecycle.
|
||||
|
||||
- `application/session_controller.dart`: async auth session state.
|
||||
- `presentation/sign_in_view.dart`: sign-in form and environment status UI.
|
||||
|
||||
Touch this slice when you change auth UX, local session behavior, or backend
|
||||
sign-in integration.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Auth Application
|
||||
|
||||
This layer coordinates auth state and session lifecycle. Start here when you
|
||||
need to change how the app decides whether a user is signed in.
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/auth/token_store.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
/// Manages authenticated session lifecycle for app-level navigation.
|
||||
class SessionController extends AsyncNotifier<AuthSession?> {
|
||||
@override
|
||||
Future<AuthSession?> build() async {
|
||||
return ref.read(tokenStoreProvider).read();
|
||||
}
|
||||
|
||||
Future<void> signIn({
|
||||
required SignInMethod method,
|
||||
String? email,
|
||||
String? password,
|
||||
String? idToken,
|
||||
}) async {
|
||||
state = const AsyncLoading<AuthSession?>();
|
||||
|
||||
try {
|
||||
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||
final AuthSession session = await gateway.signIn(
|
||||
SignInRequest(
|
||||
method: method,
|
||||
email: email,
|
||||
password: password,
|
||||
idToken: idToken,
|
||||
),
|
||||
);
|
||||
|
||||
// Ensure fake gateway sessions are also persisted.
|
||||
await ref.read(tokenStoreProvider).write(session);
|
||||
state = AsyncData<AuthSession?>(session);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError<AuthSession?>(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||
final TokenStore tokenStore = ref.read(tokenStoreProvider);
|
||||
|
||||
try {
|
||||
await gateway.signOut();
|
||||
} catch (_) {
|
||||
// Offline-first sign-out should always clear local session.
|
||||
}
|
||||
|
||||
await tokenStore.clear();
|
||||
state = const AsyncData<AuthSession?>(null);
|
||||
}
|
||||
|
||||
Future<void> refreshProfile() async {
|
||||
final AuthSession? current = state.asData?.value;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final UserProfile profile = await ref.read(backendGatewayProvider).me();
|
||||
final AuthSession updated = current.copyWith(user: profile);
|
||||
await ref.read(tokenStoreProvider).write(updated);
|
||||
state = AsyncData<AuthSession?>(updated);
|
||||
} catch (_) {
|
||||
// Keep current state if profile refresh fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final AsyncNotifierProvider<SessionController, AuthSession?>
|
||||
sessionControllerProvider =
|
||||
AsyncNotifierProvider<SessionController, AuthSession?>(
|
||||
SessionController.new,
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
# Auth Presentation
|
||||
|
||||
Auth UI belongs here. Keep sign-in screens, auth-specific widgets, and small
|
||||
view helpers in this folder rather than mixing them into app bootstrap code.
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignInView extends ConsumerStatefulWidget {
|
||||
const SignInView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignInView> createState() => _SignInViewState();
|
||||
}
|
||||
|
||||
class _SignInViewState extends ConsumerState<SignInView> {
|
||||
SignInMethod _method = SignInMethod.password;
|
||||
final TextEditingController _emailController = TextEditingController(
|
||||
text: 'demo@example.com',
|
||||
);
|
||||
final TextEditingController _passwordController = TextEditingController(
|
||||
text: 'password123',
|
||||
);
|
||||
final TextEditingController _idTokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_idTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AsyncValue<AuthSession?> session = ref.watch(
|
||||
sessionControllerProvider,
|
||||
);
|
||||
final bool busy = session.isLoading;
|
||||
final Object? error = session.asError?.error;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints viewport) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
child: FrostedCard(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 480;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Sign in to continue',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your local data remains available offline. Backend unlocks sync and signals.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (compact)
|
||||
DropdownButtonFormField<SignInMethod>(
|
||||
initialValue: _method,
|
||||
items: SignInMethod.values
|
||||
.map(
|
||||
(SignInMethod method) =>
|
||||
DropdownMenuItem<SignInMethod>(
|
||||
value: method,
|
||||
child: Text(_labelForMethod(method)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: busy
|
||||
? null
|
||||
: (SignInMethod? value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_method = value;
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Method',
|
||||
),
|
||||
)
|
||||
else
|
||||
SegmentedButton<SignInMethod>(
|
||||
segments: const <ButtonSegment<SignInMethod>>[
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.password,
|
||||
label: Text('Password'),
|
||||
),
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.emailMagicLink,
|
||||
label: Text('Magic Link'),
|
||||
),
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.oidc,
|
||||
label: Text('OIDC'),
|
||||
),
|
||||
],
|
||||
selected: <SignInMethod>{_method},
|
||||
onSelectionChanged: busy
|
||||
? null
|
||||
: (Set<SignInMethod> value) {
|
||||
setState(() {
|
||||
_method = value.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (_method == SignInMethod.password ||
|
||||
_method == SignInMethod.emailMagicLink)
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
),
|
||||
),
|
||||
if (_method == SignInMethod.password)
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
),
|
||||
),
|
||||
if (_method == SignInMethod.oidc)
|
||||
TextField(
|
||||
controller: _idTokenController,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID Token',
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
'Sign-in failed: $error',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: const Color(0xFFC83030)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: <Widget>[
|
||||
FilledButton.icon(
|
||||
onPressed: busy ? null : _submit,
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.login_rounded),
|
||||
label: const Text('Sign In'),
|
||||
),
|
||||
Text(
|
||||
AppConfig.useFakeBackend
|
||||
? 'Fake mode enabled'
|
||||
: 'REST mode (${AppConfig.backendBaseUrl})',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final String? email = _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim();
|
||||
final String? password = _passwordController.text.trim().isEmpty
|
||||
? null
|
||||
: _passwordController.text.trim();
|
||||
final String? idToken = _idTokenController.text.trim().isEmpty
|
||||
? null
|
||||
: _idTokenController.text.trim();
|
||||
|
||||
await ref
|
||||
.read(sessionControllerProvider.notifier)
|
||||
.signIn(
|
||||
method: _method,
|
||||
email: email,
|
||||
password: password,
|
||||
idToken: idToken,
|
||||
);
|
||||
}
|
||||
|
||||
String _labelForMethod(SignInMethod method) {
|
||||
return switch (method) {
|
||||
SignInMethod.password => 'Password',
|
||||
SignInMethod.emailMagicLink => 'Magic Link',
|
||||
SignInMethod.oidc => 'OIDC',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,2 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/auth/token_store.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
/// Manages authenticated session lifecycle for app-level navigation.
|
||||
class SessionController extends AsyncNotifier<AuthSession?> {
|
||||
@override
|
||||
Future<AuthSession?> build() async {
|
||||
return ref.read(tokenStoreProvider).read();
|
||||
}
|
||||
|
||||
Future<void> signIn({
|
||||
required SignInMethod method,
|
||||
String? email,
|
||||
String? password,
|
||||
String? idToken,
|
||||
}) async {
|
||||
state = const AsyncLoading<AuthSession?>();
|
||||
|
||||
try {
|
||||
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||
final AuthSession session = await gateway.signIn(
|
||||
SignInRequest(
|
||||
method: method,
|
||||
email: email,
|
||||
password: password,
|
||||
idToken: idToken,
|
||||
),
|
||||
);
|
||||
|
||||
// Ensure fake gateway sessions are also persisted.
|
||||
await ref.read(tokenStoreProvider).write(session);
|
||||
state = AsyncData<AuthSession?>(session);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError<AuthSession?>(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||
final TokenStore tokenStore = ref.read(tokenStoreProvider);
|
||||
|
||||
try {
|
||||
await gateway.signOut();
|
||||
} catch (_) {
|
||||
// Offline-first sign-out should always clear local session.
|
||||
}
|
||||
|
||||
await tokenStore.clear();
|
||||
state = const AsyncData<AuthSession?>(null);
|
||||
}
|
||||
|
||||
Future<void> refreshProfile() async {
|
||||
final AuthSession? current = state.asData?.value;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final UserProfile profile = await ref.read(backendGatewayProvider).me();
|
||||
final AuthSession updated = current.copyWith(user: profile);
|
||||
await ref.read(tokenStoreProvider).write(updated);
|
||||
state = AsyncData<AuthSession?>(updated);
|
||||
} catch (_) {
|
||||
// Keep current state if profile refresh fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final AsyncNotifierProvider<SessionController, AuthSession?>
|
||||
sessionControllerProvider =
|
||||
AsyncNotifierProvider<SessionController, AuthSession?>(
|
||||
SessionController.new,
|
||||
);
|
||||
// Legacy compatibility export. Prefer the `application` file in this slice.
|
||||
export 'package:relationship_saver/features/auth/application/session_controller.dart';
|
||||
|
||||
@@ -1,228 +1,2 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignInView extends ConsumerStatefulWidget {
|
||||
const SignInView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignInView> createState() => _SignInViewState();
|
||||
}
|
||||
|
||||
class _SignInViewState extends ConsumerState<SignInView> {
|
||||
SignInMethod _method = SignInMethod.password;
|
||||
final TextEditingController _emailController = TextEditingController(
|
||||
text: 'demo@example.com',
|
||||
);
|
||||
final TextEditingController _passwordController = TextEditingController(
|
||||
text: 'password123',
|
||||
);
|
||||
final TextEditingController _idTokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_idTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AsyncValue<AuthSession?> session = ref.watch(
|
||||
sessionControllerProvider,
|
||||
);
|
||||
final bool busy = session.isLoading;
|
||||
final Object? error = session.asError?.error;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints viewport) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
child: FrostedCard(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 480;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Sign in to continue',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your local data remains available offline. Backend unlocks sync and signals.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (compact)
|
||||
DropdownButtonFormField<SignInMethod>(
|
||||
initialValue: _method,
|
||||
items: SignInMethod.values
|
||||
.map(
|
||||
(SignInMethod method) =>
|
||||
DropdownMenuItem<SignInMethod>(
|
||||
value: method,
|
||||
child: Text(_labelForMethod(method)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: busy
|
||||
? null
|
||||
: (SignInMethod? value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_method = value;
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Method',
|
||||
),
|
||||
)
|
||||
else
|
||||
SegmentedButton<SignInMethod>(
|
||||
segments: const <ButtonSegment<SignInMethod>>[
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.password,
|
||||
label: Text('Password'),
|
||||
),
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.emailMagicLink,
|
||||
label: Text('Magic Link'),
|
||||
),
|
||||
ButtonSegment<SignInMethod>(
|
||||
value: SignInMethod.oidc,
|
||||
label: Text('OIDC'),
|
||||
),
|
||||
],
|
||||
selected: <SignInMethod>{_method},
|
||||
onSelectionChanged: busy
|
||||
? null
|
||||
: (Set<SignInMethod> value) {
|
||||
setState(() {
|
||||
_method = value.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (_method == SignInMethod.password ||
|
||||
_method == SignInMethod.emailMagicLink)
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
),
|
||||
),
|
||||
if (_method == SignInMethod.password)
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
),
|
||||
),
|
||||
if (_method == SignInMethod.oidc)
|
||||
TextField(
|
||||
controller: _idTokenController,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID Token',
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
'Sign-in failed: $error',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: const Color(0xFFC83030)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: <Widget>[
|
||||
FilledButton.icon(
|
||||
onPressed: busy ? null : _submit,
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.login_rounded),
|
||||
label: const Text('Sign In'),
|
||||
),
|
||||
Text(
|
||||
AppConfig.useFakeBackend
|
||||
? 'Fake mode enabled'
|
||||
: 'REST mode (${AppConfig.backendBaseUrl})',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final String? email = _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim();
|
||||
final String? password = _passwordController.text.trim().isEmpty
|
||||
? null
|
||||
: _passwordController.text.trim();
|
||||
final String? idToken = _idTokenController.text.trim().isEmpty
|
||||
? null
|
||||
: _idTokenController.text.trim();
|
||||
|
||||
await ref
|
||||
.read(sessionControllerProvider.notifier)
|
||||
.signIn(
|
||||
method: _method,
|
||||
email: email,
|
||||
password: password,
|
||||
idToken: idToken,
|
||||
);
|
||||
}
|
||||
|
||||
String _labelForMethod(SignInMethod method) {
|
||||
return switch (method) {
|
||||
SignInMethod.password => 'Password',
|
||||
SignInMethod.emailMagicLink => 'Magic Link',
|
||||
SignInMethod.oidc => 'OIDC',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Legacy compatibility export. Prefer the `presentation` file in this slice.
|
||||
export 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
|
||||
|
||||
Reference in New Issue
Block a user