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,11 @@
|
||||
# Core
|
||||
|
||||
`lib/core/` is for truly cross-cutting code:
|
||||
|
||||
- config and theme
|
||||
- auth primitives
|
||||
- network clients and interceptors
|
||||
- small shared utilities
|
||||
- shared presentation primitives
|
||||
|
||||
If a file mainly exists for one slice, keep it in that slice instead.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Core Auth
|
||||
|
||||
Cross-cutting auth primitives live here. Keep this folder limited to reusable
|
||||
infrastructure rather than slice-specific sign-in UI or session flows.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Core Config
|
||||
|
||||
App-wide configuration, feature flags, and theme-level constants live here.
|
||||
Use this folder for global configuration only, not feature-owned defaults.
|
||||
@@ -9,6 +9,11 @@ class AppConfig {
|
||||
|
||||
static String? _baseUrlOverride;
|
||||
|
||||
static const String _defaultSentryDsn = String.fromEnvironment(
|
||||
'SENTRY_DSN',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
/// Returns the configured backend base URL.
|
||||
static String get backendBaseUrl {
|
||||
final String? override = _baseUrlOverride;
|
||||
@@ -48,6 +53,9 @@ class AppConfig {
|
||||
defaultValue: 180,
|
||||
);
|
||||
|
||||
/// Optional Sentry DSN for release crash/error reporting.
|
||||
static String get sentryDsn => _defaultSentryDsn.trim();
|
||||
|
||||
/// Runtime override for backend URL (e.g. local settings screen).
|
||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||
_baseUrlOverride = baseUrl;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||
|
||||
CapturedFactDraft? parseCapturedFactDraftSuggestion(
|
||||
String response, {
|
||||
required SharedPayload fallbackPayload,
|
||||
}) {
|
||||
try {
|
||||
final int jsonStart = response.indexOf('{');
|
||||
final int jsonEnd = response.lastIndexOf('}');
|
||||
if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> json =
|
||||
jsonDecode(response.substring(jsonStart, jsonEnd + 1))
|
||||
as Map<String, dynamic>;
|
||||
final String typeName = '${json['type'] ?? CapturedFactType.note.name}';
|
||||
final DateTime? parsedDate = _parseDate(json['dateValue']);
|
||||
final double? confidence = _parseConfidence(json['confidence']);
|
||||
|
||||
return CapturedFactDraft(
|
||||
type: CapturedFactType.values.firstWhere(
|
||||
(CapturedFactType type) => type.name == typeName,
|
||||
orElse: () => CapturedFactType.note,
|
||||
),
|
||||
text: '${json['text'] ?? fallbackPayload.rawText}'.trim(),
|
||||
label: _trimToNull(json['label'] as String?),
|
||||
dateValue: parsedDate,
|
||||
confidence: confidence,
|
||||
isSensitive: json['isSensitive'] as bool? ?? false,
|
||||
needsReview: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _trimToNull(String? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String value = '$raw'.trim();
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value)?.toLocal();
|
||||
}
|
||||
|
||||
double? _parseConfidence(Object? raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final double? value = switch (raw) {
|
||||
final num number => number.toDouble(),
|
||||
final String text => double.tryParse(text),
|
||||
_ => null,
|
||||
};
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final num clamped = value.clamp(0, 1);
|
||||
return clamped.toDouble();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const String _llmApiKeyStorageKey = 'llm_api_key';
|
||||
const String _llmProviderStorageKey = 'llm_provider';
|
||||
|
||||
enum LlmProvider { openai, anthropic, google }
|
||||
|
||||
class LlmConfigState {
|
||||
const LlmConfigState({
|
||||
this.apiKeyConfigured = false,
|
||||
this.provider = LlmProvider.openai,
|
||||
this.isConfigured = false,
|
||||
});
|
||||
|
||||
final bool apiKeyConfigured;
|
||||
final LlmProvider provider;
|
||||
final bool isConfigured;
|
||||
|
||||
LlmConfigState copyWith({
|
||||
bool? apiKeyConfigured,
|
||||
LlmProvider? provider,
|
||||
bool? isConfigured,
|
||||
}) {
|
||||
return LlmConfigState(
|
||||
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
|
||||
provider: provider ?? this.provider,
|
||||
isConfigured: isConfigured ?? this.isConfigured,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LlmConfigNotifier extends Notifier<LlmConfigState> {
|
||||
Future<void>? _initializeFuture;
|
||||
|
||||
@override
|
||||
LlmConfigState build() {
|
||||
return const LlmConfigState();
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
final Future<void>? inFlight = _initializeFuture;
|
||||
if (inFlight != null) {
|
||||
return inFlight;
|
||||
}
|
||||
_initializeFuture = _initializeFromStorage();
|
||||
return _initializeFuture;
|
||||
}
|
||||
|
||||
Future<void> _initializeFromStorage() async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
final String? storedKey = await secureStorage.read(
|
||||
key: _llmApiKeyStorageKey,
|
||||
);
|
||||
final String? storedProvider = await secureStorage.read(
|
||||
key: _llmProviderStorageKey,
|
||||
);
|
||||
|
||||
state = LlmConfigState(
|
||||
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
|
||||
provider: storedProvider != null
|
||||
? LlmProvider.values.firstWhere(
|
||||
(LlmProvider p) => p.name == storedProvider,
|
||||
orElse: () => LlmProvider.openai,
|
||||
)
|
||||
: LlmProvider.openai,
|
||||
isConfigured: storedKey != null && storedKey.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setApiKey(String apiKey) async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
if (apiKey.isEmpty) {
|
||||
await secureStorage.delete(key: _llmApiKeyStorageKey);
|
||||
} else {
|
||||
await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey);
|
||||
}
|
||||
state = state.copyWith(
|
||||
apiKeyConfigured: apiKey.isNotEmpty,
|
||||
isConfigured: apiKey.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setProvider(LlmProvider provider) async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
await secureStorage.write(
|
||||
key: _llmProviderStorageKey,
|
||||
value: provider.name,
|
||||
);
|
||||
state = state.copyWith(provider: provider);
|
||||
}
|
||||
|
||||
Future<String?> getApiKey() async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
return secureStorage.read(key: _llmApiKeyStorageKey);
|
||||
}
|
||||
|
||||
Future<void> clearApiKey() async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
await secureStorage.delete(key: _llmApiKeyStorageKey);
|
||||
state = state.copyWith(apiKeyConfigured: false, isConfigured: false);
|
||||
}
|
||||
}
|
||||
|
||||
final llmConfigProvider = NotifierProvider<LlmConfigNotifier, LlmConfigState>(
|
||||
LlmConfigNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_config.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||
|
||||
class LlmService {
|
||||
LlmService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<LlmConfigState> get _config async {
|
||||
final state = _ref.read(llmConfigProvider);
|
||||
if (!state.isConfigured) {
|
||||
throw Exception('LLM not configured');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
Future<List<GeneratedSignal>> generateSignals(
|
||||
List<PersonProfile> people,
|
||||
) async {
|
||||
final config = await _config;
|
||||
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
throw Exception('No API key available');
|
||||
}
|
||||
|
||||
final systemPrompt =
|
||||
'''You are a relationship assistant that helps users maintain meaningful connections with their friends and family.
|
||||
Based on the person's profile data, generate helpful signals such as:
|
||||
- Check-in reminders
|
||||
- Gift ideas
|
||||
- Follow-up suggestions
|
||||
- Relationship maintenance tips
|
||||
|
||||
Return your response as a JSON array of signals, each with:
|
||||
- title: short actionable title
|
||||
- description: brief explanation
|
||||
- type: "recommendation", "gift", "followup", or "reminder"
|
||||
- personId: the person's ID (use a valid ID from the input if applicable)
|
||||
|
||||
Only return the JSON array, no other text.''';
|
||||
|
||||
final peopleContext = people
|
||||
.map(
|
||||
(p) =>
|
||||
'- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}',
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
final userPrompt =
|
||||
'''Here are the people in my life:
|
||||
$peopleContext
|
||||
|
||||
Generate 3-5 helpful relationship signals for these people.''';
|
||||
|
||||
try {
|
||||
final response = await _callLlm(
|
||||
provider: config.provider,
|
||||
apiKey: apiKey,
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
);
|
||||
|
||||
return _parseSignalsResponse(response, people);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to generate signals: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> completeText({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
}) async {
|
||||
final config = await _config;
|
||||
final String? apiKey = await _ref
|
||||
.read(llmConfigProvider.notifier)
|
||||
.getApiKey();
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
throw Exception('No API key available');
|
||||
}
|
||||
|
||||
return _callLlm(
|
||||
provider: config.provider,
|
||||
apiKey: apiKey,
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
Future<CapturedFactDraft?> suggestCaptureDraft(SharedPayload payload) async {
|
||||
final config = await _config;
|
||||
final String? apiKey = await _ref
|
||||
.read(llmConfigProvider.notifier)
|
||||
.getApiKey();
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
throw Exception('No API key available');
|
||||
}
|
||||
|
||||
final String systemPrompt =
|
||||
'''You analyze shared messages and links for a relationship-tracking app.
|
||||
Return exactly one JSON object with:
|
||||
- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc"
|
||||
- text: cleaned user-facing summary
|
||||
- label: short optional label
|
||||
- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null
|
||||
- confidence: number from 0 to 1
|
||||
- isSensitive: true only if the content looks private or sensitive
|
||||
|
||||
Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''';
|
||||
|
||||
final String userPrompt =
|
||||
'''Source app: ${payload.sourceApp}
|
||||
Sender: ${payload.sourceDisplayName ?? "unknown"}
|
||||
Platform: ${payload.platform}
|
||||
URL: ${payload.url ?? "none"}
|
||||
Raw text:
|
||||
${payload.rawText}''';
|
||||
|
||||
try {
|
||||
final String response = await _callLlm(
|
||||
provider: config.provider,
|
||||
apiKey: apiKey,
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
);
|
||||
return parseCapturedFactDraftSuggestion(
|
||||
response,
|
||||
fallbackPayload: payload,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to suggest share capture draft: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _callLlm({
|
||||
required LlmProvider provider,
|
||||
required String apiKey,
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
}) async {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
sendTimeout: const Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
|
||||
switch (provider) {
|
||||
case LlmProvider.openai:
|
||||
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt);
|
||||
case LlmProvider.anthropic:
|
||||
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt);
|
||||
case LlmProvider.google:
|
||||
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _callOpenAI(
|
||||
Dio dio,
|
||||
String apiKey,
|
||||
String systemPrompt,
|
||||
String userPrompt,
|
||||
) async {
|
||||
final Response<Map<String, dynamic>> response = await dio
|
||||
.post<Map<String, dynamic>>(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
data: jsonEncode({
|
||||
'model': 'gpt-4o-mini',
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
{'role': 'user', 'content': userPrompt},
|
||||
],
|
||||
'max_tokens': 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
final Map<String, dynamic> responseData = response.data!;
|
||||
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
|
||||
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
|
||||
final Map<String, dynamic> message =
|
||||
firstChoice['message'] as Map<String, dynamic>;
|
||||
final String content = message['content'] as String;
|
||||
return content;
|
||||
}
|
||||
|
||||
Future<String> _callAnthropic(
|
||||
Dio dio,
|
||||
String apiKey,
|
||||
String systemPrompt,
|
||||
String userPrompt,
|
||||
) async {
|
||||
final Response<Map<String, dynamic>> response = await dio
|
||||
.post<Map<String, dynamic>>(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
options: Options(
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
data: jsonEncode({
|
||||
'model': 'claude-3-haiku-20240307',
|
||||
'system': systemPrompt,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': userPrompt},
|
||||
],
|
||||
'max_tokens': 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
final Map<String, dynamic> responseData = response.data!;
|
||||
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
|
||||
final Map<String, dynamic> firstContent =
|
||||
contentList[0] as Map<String, dynamic>;
|
||||
final String content = firstContent['text'] as String;
|
||||
return content;
|
||||
}
|
||||
|
||||
Future<String> _callGoogleAI(
|
||||
Dio dio,
|
||||
String apiKey,
|
||||
String systemPrompt,
|
||||
String userPrompt,
|
||||
) async {
|
||||
final Response<Map<String, dynamic>>
|
||||
response = await dio.post<Map<String, dynamic>>(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
|
||||
options: Options(headers: {'Content-Type': 'application/json'}),
|
||||
queryParameters: {'key': apiKey},
|
||||
data: jsonEncode({
|
||||
'systemInstruction': {
|
||||
'parts': [
|
||||
{'text': systemPrompt},
|
||||
],
|
||||
},
|
||||
'contents': [
|
||||
{
|
||||
'parts': [
|
||||
{'text': userPrompt},
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
||||
}),
|
||||
);
|
||||
|
||||
final Map<String, dynamic> responseData = response.data!;
|
||||
final List<dynamic> candidates =
|
||||
responseData['candidates'] as List<dynamic>;
|
||||
final Map<String, dynamic> firstCandidate =
|
||||
candidates[0] as Map<String, dynamic>;
|
||||
final Map<String, dynamic> content =
|
||||
firstCandidate['content'] as Map<String, dynamic>;
|
||||
final List<dynamic> parts = content['parts'] as List<dynamic>;
|
||||
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
|
||||
final String contentText = firstPart['text'] as String;
|
||||
return contentText;
|
||||
}
|
||||
|
||||
List<GeneratedSignal> _parseSignalsResponse(
|
||||
String response,
|
||||
List<PersonProfile> people,
|
||||
) {
|
||||
try {
|
||||
final jsonStart = response.indexOf('[');
|
||||
final jsonEnd = response.lastIndexOf(']');
|
||||
|
||||
if (jsonStart == -1 || jsonEnd == -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonStr = response.substring(jsonStart, jsonEnd + 1);
|
||||
final List<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
|
||||
|
||||
return items.map((item) {
|
||||
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
|
||||
final String? personId = itemMap['personId'] as String?;
|
||||
final validPersonId =
|
||||
personId != null && people.any((p) => p.id == personId)
|
||||
? personId
|
||||
: (people.isNotEmpty ? people.first.id : null);
|
||||
|
||||
return GeneratedSignal(
|
||||
title: itemMap['title'] as String? ?? 'Check in',
|
||||
description: itemMap['description'] as String? ?? '',
|
||||
type: itemMap['type'] as String? ?? 'recommendation',
|
||||
personId: validPersonId,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeneratedSignal {
|
||||
const GeneratedSignal({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
this.personId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String type;
|
||||
final String? personId;
|
||||
}
|
||||
|
||||
final llmServiceProvider = Provider<LlmService>((Ref ref) {
|
||||
return LlmService(ref);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
# Core Network
|
||||
|
||||
This folder owns shared networking infrastructure. It is the right place for
|
||||
transport concerns like reachability and interceptors, not feature business logic.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Network Interceptors
|
||||
|
||||
Request and response interception belongs here. Keep these classes stateless and
|
||||
transport-focused so features do not need to know about client plumbing.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Reachability
|
||||
|
||||
Network availability checks and platform-specific reachability code live here.
|
||||
This folder should answer "can we talk to the network?" and nothing more.
|
||||
@@ -30,19 +30,17 @@ class IoNetworkReachability implements NetworkReachability {
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
|
||||
final Stream<dynamic> changes =
|
||||
_connectivity.onConnectivityChanged as Stream<dynamic>;
|
||||
return changes
|
||||
.asyncMap((dynamic event) async {
|
||||
final Iterable<ConnectivityResult> results =
|
||||
_normalizeConnectivityResults(event);
|
||||
final bool hasTransport = results.any(
|
||||
(ConnectivityResult result) => result != ConnectivityResult.none,
|
||||
);
|
||||
if (!hasTransport) {
|
||||
return false;
|
||||
}
|
||||
return isReachable(timeout: timeout);
|
||||
})
|
||||
.distinct();
|
||||
return changes.asyncMap((dynamic event) async {
|
||||
final Iterable<ConnectivityResult> results =
|
||||
_normalizeConnectivityResults(event);
|
||||
final bool hasTransport = results.any(
|
||||
(ConnectivityResult result) => result != ConnectivityResult.none,
|
||||
);
|
||||
if (!hasTransport) {
|
||||
return false;
|
||||
}
|
||||
return isReachable(timeout: timeout);
|
||||
}).distinct();
|
||||
}
|
||||
|
||||
Iterable<ConnectivityResult> _normalizeConnectivityResults(dynamic event) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class SentryInit {
|
||||
SentryInit();
|
||||
|
||||
Future<void>? _initializeFuture;
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize({FutureOr<void> Function()? appRunner}) async {
|
||||
final Future<void>? inFlight = _initializeFuture;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
if (appRunner != null && !_initialized) {
|
||||
await Future<void>.sync(appRunner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_initializeFuture = _initializeInternal(appRunner: appRunner);
|
||||
return _initializeFuture;
|
||||
}
|
||||
|
||||
Future<void> _initializeInternal({
|
||||
FutureOr<void> Function()? appRunner,
|
||||
}) async {
|
||||
if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) {
|
||||
if (appRunner != null) {
|
||||
await Future<void>.sync(appRunner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await SentryFlutter.init((options) {
|
||||
options.dsn = AppConfig.sentryDsn;
|
||||
options.environment = AppConfig.useFakeBackend
|
||||
? 'development'
|
||||
: 'production';
|
||||
options.tracesSampleRate = 0.1;
|
||||
}, appRunner: appRunner);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
void captureException(Object error, {StackTrace? stackTrace}) {
|
||||
if (_initialized) {
|
||||
Sentry.captureException(error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void captureMessage(String message, {SentryLevel level = SentryLevel.info}) {
|
||||
if (_initialized) {
|
||||
Sentry.captureMessage(message, level: level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final sentryInitProvider = Provider<SentryInit>((Ref ref) {
|
||||
return SentryInit();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Core Presentation
|
||||
|
||||
This folder holds shared UI primitives that are not owned by one product
|
||||
feature. Keep widgets here small and reusable.
|
||||
|
||||
`frosted_card.dart` is the canonical shared card surface used across several
|
||||
screens.
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FrostedCard extends StatelessWidget {
|
||||
const FrostedCard({
|
||||
required this.child,
|
||||
super.key,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.6)),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Core Utils
|
||||
|
||||
Use this folder sparingly for tiny reusable helpers that are truly cross-cutting.
|
||||
If a helper is owned by one slice, keep it in that slice instead.
|
||||
Reference in New Issue
Block a user