Implement multi-screen app UI flow and navigation
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
-5
@@ -1,6 +1,56 @@
|
|||||||
# Relationship Saver Progress Log
|
# Relationship Saver Progress Log
|
||||||
|
|
||||||
Updated: 2026-02-14
|
Updated: 2026-02-15
|
||||||
|
|
||||||
|
## Latest Milestone (2026-02-15): Product UI Flow Implemented
|
||||||
|
|
||||||
|
The app now has a real multi-screen user experience on macOS (and other platforms), not just a placeholder screen.
|
||||||
|
|
||||||
|
Delivered UI architecture:
|
||||||
|
|
||||||
|
- App shell with responsive navigation:
|
||||||
|
- desktop: left sidebar + content pane
|
||||||
|
- compact: bottom navigation
|
||||||
|
- Views implemented:
|
||||||
|
- Dashboard
|
||||||
|
- People
|
||||||
|
- Moments
|
||||||
|
- Signals
|
||||||
|
- Sync
|
||||||
|
- Settings
|
||||||
|
- Reference-aligned visual style:
|
||||||
|
- WorkSans typography (copied from the UI reference assets)
|
||||||
|
- soft gradients, rounded glass-like cards, accent palette similar to the reference repo
|
||||||
|
- Offline-safe UX:
|
||||||
|
- fake backend mode now defaults to `true` unless overridden via `--dart-define=USE_FAKE_BACKEND=false`
|
||||||
|
- signals view gracefully falls back to local recommendations when backend calls fail
|
||||||
|
|
||||||
|
Key new files:
|
||||||
|
|
||||||
|
- `lib/features/home/app_shell.dart`
|
||||||
|
- `lib/features/dashboard/dashboard_view.dart`
|
||||||
|
- `lib/features/people/people_view.dart`
|
||||||
|
- `lib/features/moments/moments_view.dart`
|
||||||
|
- `lib/features/signals/signals_controller.dart`
|
||||||
|
- `lib/features/signals/signals_view.dart`
|
||||||
|
- `lib/features/sync/sync_view.dart`
|
||||||
|
- `lib/features/settings/settings_view.dart`
|
||||||
|
- `lib/features/shared/frosted_card.dart`
|
||||||
|
- `lib/features/local/local_models.dart`
|
||||||
|
- `lib/features/local/local_repository.dart`
|
||||||
|
- `assets/fonts/WorkSans-*.ttf`
|
||||||
|
|
||||||
|
Other updates:
|
||||||
|
|
||||||
|
- `lib/main.dart` now launches the new app shell.
|
||||||
|
- `lib/core/config/app_theme.dart` updated to a stronger visual system.
|
||||||
|
- `pubspec.yaml` includes local WorkSans font family.
|
||||||
|
- `test/widget_test.dart` updated for the new flow.
|
||||||
|
|
||||||
|
Validation status after UI milestone:
|
||||||
|
|
||||||
|
- `flutter analyze` -> pass
|
||||||
|
- `flutter test` -> pass
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
@@ -165,10 +215,10 @@ Most recent local checks:
|
|||||||
|
|
||||||
### Priority 3: App Wiring + UX
|
### Priority 3: App Wiring + UX
|
||||||
|
|
||||||
1. Wire `BackendGateway` into app services/use cases (not UI widgets directly).
|
1. Replace local demo repository data with actual local DB repositories.
|
||||||
2. Add session state provider (signed in / expired / signed out).
|
2. Add create/edit flows for people, captures, reminders, and ideas (forms + validation).
|
||||||
3. Add explicit offline indicators and non-blocking sync status UI.
|
3. Add session state and auth entry screens (sign-in, expired-session recovery).
|
||||||
4. Start consuming fake gateway in feature screens before live backend.
|
4. Add explicit offline badges and sync conflict resolution UI.
|
||||||
|
|
||||||
### Priority 4: Security + Hardening
|
### Priority 4: Security + Hardening
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AppConfig {
|
|||||||
|
|
||||||
/// Enables fake backend implementation for local/offline development.
|
/// Enables fake backend implementation for local/offline development.
|
||||||
static bool get useFakeBackend =>
|
static bool get useFakeBackend =>
|
||||||
const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: false);
|
const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: true);
|
||||||
|
|
||||||
/// Runtime override for backend URL (e.g. local settings screen).
|
/// Runtime override for backend URL (e.g. local settings screen).
|
||||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||||
|
|||||||
@@ -4,31 +4,55 @@ import 'package:flutter/material.dart';
|
|||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
static const Color background = Color(0xFFEDF0F2);
|
static const Color background = Color(0xFFF3F6F8);
|
||||||
static const Color surface = Color(0xFFFFFFFF);
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
static const Color primary = Color(0xFF253840);
|
static const Color primary = Color(0xFF1E3541);
|
||||||
static const Color accent = Color(0xFF00B6F0);
|
static const Color accent = Color(0xFF1AB6C8);
|
||||||
|
static const Color textPrimary = Color(0xFF18303A);
|
||||||
|
static const Color textSecondary = Color(0xFF58707A);
|
||||||
|
|
||||||
/// Builds the Material [ThemeData].
|
/// Builds the Material [ThemeData].
|
||||||
static ThemeData light() {
|
static ThemeData light() {
|
||||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
final ColorScheme colorScheme = ColorScheme.fromSeed(seedColor: primary);
|
||||||
seedColor: primary,
|
final TextTheme textTheme = ThemeData.light().textTheme.apply(
|
||||||
primary: primary,
|
fontFamily: 'WorkSans',
|
||||||
secondary: accent,
|
bodyColor: textPrimary,
|
||||||
surface: surface,
|
displayColor: textPrimary,
|
||||||
brightness: Brightness.light,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme.copyWith(
|
||||||
|
primary: primary,
|
||||||
|
secondary: accent,
|
||||||
|
surface: surface,
|
||||||
|
),
|
||||||
scaffoldBackgroundColor: background,
|
scaffoldBackgroundColor: background,
|
||||||
|
textTheme: textTheme.copyWith(
|
||||||
|
headlineMedium: textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
titleLarge: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
titleMedium: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: surface,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: primary,
|
foregroundColor: textPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: surface,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
|
||||||
|
class DashboardView extends ConsumerWidget {
|
||||||
|
const DashboardView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final LocalRepository repository = ref.watch(localRepositoryProvider);
|
||||||
|
final DashboardSummary summary = repository.summary();
|
||||||
|
final List<DashboardTask> tasks = repository.tasks();
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text('Today', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Focus on consistency over intensity. Small moments compound.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: <Widget>[
|
||||||
|
_StatCard(
|
||||||
|
label: 'Active People',
|
||||||
|
value: '${summary.activePeople}',
|
||||||
|
accent: const Color(0xFF16B8CA),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
label: 'Upcoming Plans',
|
||||||
|
value: '${summary.upcomingPlans}',
|
||||||
|
accent: const Color(0xFF2B7FFF),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
label: 'Pending Ideas',
|
||||||
|
value: '${summary.pendingIdeas}',
|
||||||
|
accent: const Color(0xFFFFA447),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
label: 'Weekly Rhythm',
|
||||||
|
value: '${summary.weeklyConsistency}%',
|
||||||
|
accent: const Color(0xFF30B66A),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Next Moves',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
...tasks.map((DashboardTask task) => _TaskRow(task: task)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatCard extends StatelessWidget {
|
||||||
|
const _StatCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.accent,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color accent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FrostedCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 188,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
height: 8,
|
||||||
|
width: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskRow extends StatelessWidget {
|
||||||
|
const _TaskRow({required this.task});
|
||||||
|
|
||||||
|
final DashboardTask task;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5FAFB),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 2),
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1AB6C8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
task.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_shortDate(task.when),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shortDate(DateTime value) {
|
||||||
|
const List<String> weekdays = <String>[
|
||||||
|
'Mon',
|
||||||
|
'Tue',
|
||||||
|
'Wed',
|
||||||
|
'Thu',
|
||||||
|
'Fri',
|
||||||
|
'Sat',
|
||||||
|
'Sun',
|
||||||
|
];
|
||||||
|
final String weekday = weekdays[value.weekday - 1];
|
||||||
|
final String day = value.day.toString().padLeft(2, '0');
|
||||||
|
return '$weekday $day';
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
|
||||||
|
import 'package:relationship_saver/features/moments/moments_view.dart';
|
||||||
|
import 'package:relationship_saver/features/people/people_view.dart';
|
||||||
|
import 'package:relationship_saver/features/settings/settings_view.dart';
|
||||||
|
import 'package:relationship_saver/features/signals/signals_view.dart';
|
||||||
|
import 'package:relationship_saver/features/sync/sync_view.dart';
|
||||||
|
|
||||||
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
|
const AppShell({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AppShell> createState() => _AppShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppShellState extends ConsumerState<AppShell> {
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
static const List<_ShellDestination> _destinations = <_ShellDestination>[
|
||||||
|
_ShellDestination('Dashboard', Icons.dashboard_rounded),
|
||||||
|
_ShellDestination('People', Icons.people_alt_rounded),
|
||||||
|
_ShellDestination('Moments', Icons.auto_awesome_rounded),
|
||||||
|
_ShellDestination('Signals', Icons.tips_and_updates_rounded),
|
||||||
|
_ShellDestination('Sync', Icons.sync_rounded),
|
||||||
|
_ShellDestination('Settings', Icons.settings_rounded),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Widget body = _screenFor(_index);
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: <Color>[
|
||||||
|
Color(0xFFF3F8FB),
|
||||||
|
Color(0xFFEAF1F8),
|
||||||
|
Color(0xFFF8FAFC),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
if (constraints.maxWidth < 1000) {
|
||||||
|
return _CompactShell(
|
||||||
|
index: _index,
|
||||||
|
body: body,
|
||||||
|
destinations: _destinations,
|
||||||
|
onChange: (int value) {
|
||||||
|
setState(() {
|
||||||
|
_index = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
_Sidebar(
|
||||||
|
index: _index,
|
||||||
|
destinations: _destinations,
|
||||||
|
onChange: (int value) {
|
||||||
|
setState(() {
|
||||||
|
_index = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
child: KeyedSubtree(
|
||||||
|
key: ValueKey<int>(_index),
|
||||||
|
child: body,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _screenFor(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return const DashboardView();
|
||||||
|
case 1:
|
||||||
|
return const PeopleView();
|
||||||
|
case 2:
|
||||||
|
return const MomentsView();
|
||||||
|
case 3:
|
||||||
|
return const SignalsView();
|
||||||
|
case 4:
|
||||||
|
return const SyncView();
|
||||||
|
case 5:
|
||||||
|
return const SettingsView();
|
||||||
|
default:
|
||||||
|
return const DashboardView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Sidebar extends StatelessWidget {
|
||||||
|
const _Sidebar({
|
||||||
|
required this.index,
|
||||||
|
required this.destinations,
|
||||||
|
required this.onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final List<_ShellDestination> destinations;
|
||||||
|
final ValueChanged<int> onChange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 290,
|
||||||
|
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.78),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Relationship Saver',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
...destinations.indexed.map(((int, _ShellDestination) tuple) {
|
||||||
|
final int destinationIndex = tuple.$1;
|
||||||
|
final _ShellDestination destination = tuple.$2;
|
||||||
|
final bool selected = destinationIndex == index;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onChange(destinationIndex),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFFEAF7FB)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
destination.icon,
|
||||||
|
size: 20,
|
||||||
|
color: selected
|
||||||
|
? AppTheme.primary
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
destination.label,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: selected
|
||||||
|
? AppTheme.primary
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF2F8FB),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Tip: Use Signals daily and convert at least one into a concrete plan.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactShell extends StatelessWidget {
|
||||||
|
const _CompactShell({
|
||||||
|
required this.index,
|
||||||
|
required this.body,
|
||||||
|
required this.destinations,
|
||||||
|
required this.onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final Widget body;
|
||||||
|
final List<_ShellDestination> destinations;
|
||||||
|
final ValueChanged<int> onChange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(child: body),
|
||||||
|
NavigationBar(
|
||||||
|
selectedIndex: index,
|
||||||
|
onDestinationSelected: onChange,
|
||||||
|
destinations: destinations
|
||||||
|
.map(
|
||||||
|
(_ShellDestination destination) => NavigationDestination(
|
||||||
|
icon: Icon(destination.icon),
|
||||||
|
label: destination.label,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShellDestination {
|
||||||
|
const _ShellDestination(this.label, this.icon);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
class PersonProfile {
|
||||||
|
const PersonProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.relationship,
|
||||||
|
required this.affinityScore,
|
||||||
|
required this.nextMoment,
|
||||||
|
required this.tags,
|
||||||
|
required this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String relationship;
|
||||||
|
final int affinityScore;
|
||||||
|
final DateTime nextMoment;
|
||||||
|
final List<String> tags;
|
||||||
|
final String notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelationshipMoment {
|
||||||
|
const RelationshipMoment({
|
||||||
|
required this.id,
|
||||||
|
required this.personId,
|
||||||
|
required this.title,
|
||||||
|
required this.summary,
|
||||||
|
required this.at,
|
||||||
|
required this.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String personId;
|
||||||
|
final String title;
|
||||||
|
final String summary;
|
||||||
|
final DateTime at;
|
||||||
|
final String type;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardTask {
|
||||||
|
const DashboardTask({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.when,
|
||||||
|
this.done = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final DateTime when;
|
||||||
|
final bool done;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardSummary {
|
||||||
|
const DashboardSummary({
|
||||||
|
required this.activePeople,
|
||||||
|
required this.upcomingPlans,
|
||||||
|
required this.pendingIdeas,
|
||||||
|
required this.weeklyConsistency,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int activePeople;
|
||||||
|
final int upcomingPlans;
|
||||||
|
final int pendingIdeas;
|
||||||
|
final int weeklyConsistency;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
|
|
||||||
|
class LocalRepository {
|
||||||
|
const LocalRepository();
|
||||||
|
|
||||||
|
List<PersonProfile> people() {
|
||||||
|
return <PersonProfile>[
|
||||||
|
PersonProfile(
|
||||||
|
id: 'p-ava',
|
||||||
|
name: 'Ava Hart',
|
||||||
|
relationship: 'Partner',
|
||||||
|
affinityScore: 92,
|
||||||
|
nextMoment: DateTime(2026, 2, 16, 19, 30),
|
||||||
|
tags: <String>['coffee', 'books', 'slow mornings'],
|
||||||
|
notes: 'Prefers thoughtful plans over expensive plans.',
|
||||||
|
),
|
||||||
|
PersonProfile(
|
||||||
|
id: 'p-jordan',
|
||||||
|
name: 'Jordan Lee',
|
||||||
|
relationship: 'Close Friend',
|
||||||
|
affinityScore: 78,
|
||||||
|
nextMoment: DateTime(2026, 2, 18, 18, 0),
|
||||||
|
tags: <String>['running', 'street food'],
|
||||||
|
notes: 'Has a race on Sunday; ask how training is going.',
|
||||||
|
),
|
||||||
|
PersonProfile(
|
||||||
|
id: 'p-mila',
|
||||||
|
name: 'Mila Stone',
|
||||||
|
relationship: 'Sister',
|
||||||
|
affinityScore: 84,
|
||||||
|
nextMoment: DateTime(2026, 2, 20, 12, 0),
|
||||||
|
tags: <String>['plants', 'retro music'],
|
||||||
|
notes: 'Birthday prep should start this week.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RelationshipMoment> moments() {
|
||||||
|
return <RelationshipMoment>[
|
||||||
|
RelationshipMoment(
|
||||||
|
id: 'm-1',
|
||||||
|
personId: 'p-ava',
|
||||||
|
title: 'Sunday Walk Tradition',
|
||||||
|
summary: 'Short walk + no-phone hour worked really well.',
|
||||||
|
at: DateTime(2026, 2, 9, 9, 30),
|
||||||
|
type: 'ritual',
|
||||||
|
),
|
||||||
|
RelationshipMoment(
|
||||||
|
id: 'm-2',
|
||||||
|
personId: 'p-jordan',
|
||||||
|
title: 'Post-workout Check-in',
|
||||||
|
summary: 'Shared training playlist and grabbed smoothies.',
|
||||||
|
at: DateTime(2026, 2, 11, 19, 0),
|
||||||
|
type: 'support',
|
||||||
|
),
|
||||||
|
RelationshipMoment(
|
||||||
|
id: 'm-3',
|
||||||
|
personId: 'p-mila',
|
||||||
|
title: 'Gift Idea Captured',
|
||||||
|
summary: 'Found a vintage lamp from her saved style board.',
|
||||||
|
at: DateTime(2026, 2, 12, 14, 20),
|
||||||
|
type: 'gift',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DashboardTask> tasks() {
|
||||||
|
return <DashboardTask>[
|
||||||
|
DashboardTask(
|
||||||
|
title: 'Plan Friday dinner with Ava',
|
||||||
|
description: 'Keep it low-key: ramen + bookstore stop.',
|
||||||
|
when: DateTime(2026, 2, 16, 17, 0),
|
||||||
|
),
|
||||||
|
DashboardTask(
|
||||||
|
title: 'Send race-day encouragement to Jordan',
|
||||||
|
description: 'Voice note before 8:00 AM.',
|
||||||
|
when: DateTime(2026, 2, 17, 7, 30),
|
||||||
|
),
|
||||||
|
DashboardTask(
|
||||||
|
title: 'Finalize Mila birthday shortlist',
|
||||||
|
description: 'Pick 1 meaningful gift and 1 backup.',
|
||||||
|
when: DateTime(2026, 2, 18, 20, 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardSummary summary() {
|
||||||
|
return DashboardSummary(
|
||||||
|
activePeople: people().length,
|
||||||
|
upcomingPlans: people()
|
||||||
|
.where(
|
||||||
|
(PersonProfile p) => p.nextMoment.isAfter(DateTime(2026, 2, 14)),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
pendingIdeas: 6,
|
||||||
|
weeklyConsistency: 82,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Provider<LocalRepository> localRepositoryProvider =
|
||||||
|
Provider<LocalRepository>((Ref ref) {
|
||||||
|
return const LocalRepository();
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
|
||||||
|
class MomentsView extends ConsumerWidget {
|
||||||
|
const MomentsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final LocalRepository repository = ref.watch(localRepositoryProvider);
|
||||||
|
final List<RelationshipMoment> moments = repository.moments();
|
||||||
|
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
|
||||||
|
for (final PersonProfile person in repository.people()) person.id: person,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text('Moments', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Capture wins and signals from everyday interactions.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText:
|
||||||
|
'Quick capture: what happened, how it felt, what to repeat...',
|
||||||
|
hintStyle: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('Add Capture'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: moments.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final RelationshipMoment moment = moments[index];
|
||||||
|
final PersonProfile? person = peopleById[moment.personId];
|
||||||
|
return FrostedCard(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
margin: const EdgeInsets.only(top: 6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1AB6C8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
moment.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge
|
||||||
|
?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
moment.summary,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'${moment.at.month}/${moment.at.day}',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
|
||||||
|
class SelectedPersonIdNotifier extends Notifier<String?> {
|
||||||
|
@override
|
||||||
|
String? build() => null;
|
||||||
|
|
||||||
|
void select(String id) {
|
||||||
|
state = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final NotifierProvider<SelectedPersonIdNotifier, String?>
|
||||||
|
selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
|
||||||
|
SelectedPersonIdNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class PeopleView extends ConsumerWidget {
|
||||||
|
const PeopleView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final List<PersonProfile> people = ref
|
||||||
|
.watch(localRepositoryProvider)
|
||||||
|
.people();
|
||||||
|
final String selectedId =
|
||||||
|
ref.watch(selectedPersonIdProvider) ?? people.first.id;
|
||||||
|
final PersonProfile selected = people.firstWhere(
|
||||||
|
(PersonProfile person) => person.id == selectedId,
|
||||||
|
orElse: () => people.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'People',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Track what matters to each relationship and follow through.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: people.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final PersonProfile person = people[index];
|
||||||
|
final bool isSelected = person.id == selected.id;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(selectedPersonIdProvider.notifier)
|
||||||
|
.select(person.id);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: FrostedCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
_AvatarSeed(name: person.name),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
person.name,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
person.relationship,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ScorePill(score: person.affinityScore),
|
||||||
|
if (isSelected)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Color(0xFF1AB6C8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: FrostedCard(child: _PersonDetail(person: selected)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonDetail extends StatelessWidget {
|
||||||
|
const _PersonDetail({required this.person});
|
||||||
|
|
||||||
|
final PersonProfile person;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
_AvatarSeed(name: person.name, size: 64),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
person.name,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
person.relationship,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ScorePill(score: person.affinityScore),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Text('Next moment', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_DetailTag(
|
||||||
|
label:
|
||||||
|
'${person.nextMoment.year}-${person.nextMoment.month.toString().padLeft(2, '0')}-${person.nextMoment.day.toString().padLeft(2, '0')} ${person.nextMoment.hour.toString().padLeft(2, '0')}:${person.nextMoment.minute.toString().padLeft(2, '0')}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text('Preferences', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: person.tags
|
||||||
|
.map((String tag) => _DetailTag(label: tag))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text('Notes', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
person.notes,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailTag extends StatelessWidget {
|
||||||
|
const _DetailTag({required this.label});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF2F8FA),
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarSeed extends StatelessWidget {
|
||||||
|
const _AvatarSeed({required this.name, this.size = 48});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final String initials = name
|
||||||
|
.split(' ')
|
||||||
|
.where((String part) => part.isNotEmpty)
|
||||||
|
.take(2)
|
||||||
|
.map((String part) => part[0].toUpperCase())
|
||||||
|
.join();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF2274E0)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScorePill extends StatelessWidget {
|
||||||
|
const _ScorePill({required this.score});
|
||||||
|
|
||||||
|
final int score;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEEF7F3),
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$score%',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: const Color(0xFF1D9C66),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_config.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
|
||||||
|
class SettingsView extends StatelessWidget {
|
||||||
|
const SettingsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text('Settings', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Environment and integration configuration for this build.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
_SettingRow(
|
||||||
|
label: 'Gateway mode',
|
||||||
|
value: AppConfig.useFakeBackend
|
||||||
|
? 'Fake (offline-safe)'
|
||||||
|
: 'REST',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_SettingRow(label: 'Base URL', value: AppConfig.backendBaseUrl),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _SettingRow(
|
||||||
|
label: 'Realtime',
|
||||||
|
value: 'Planned later (SSE/WebSocket)',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Text(
|
||||||
|
'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingRow extends StatelessWidget {
|
||||||
|
const _SettingRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,76 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||||
|
@override
|
||||||
|
Future<SignalsFeed> build() async {
|
||||||
|
return _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading<SignalsFeed>();
|
||||||
|
try {
|
||||||
|
final SignalsFeed feed = await _load();
|
||||||
|
state = AsyncData<SignalsFeed>(feed);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
state = AsyncError<SignalsFeed>(error, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> acknowledge(String id, SignalAction action) async {
|
||||||
|
final SignalsFeed? current = state.asData?.value;
|
||||||
|
if (current == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(backendGatewayProvider)
|
||||||
|
.acknowledgeSignal(signalId: id, action: action);
|
||||||
|
|
||||||
|
final List<SignalItem> updated = current.items
|
||||||
|
.where((SignalItem item) => item.id != id)
|
||||||
|
.toList(growable: false);
|
||||||
|
state = AsyncData<SignalsFeed>(current.copyWith(items: updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SignalsFeed> _load() async {
|
||||||
|
try {
|
||||||
|
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
|
||||||
|
} catch (_) {
|
||||||
|
final List<PersonProfile> people = ref
|
||||||
|
.read(localRepositoryProvider)
|
||||||
|
.people();
|
||||||
|
final DateTime now = DateTime.now().toUtc();
|
||||||
|
return SignalsFeed(
|
||||||
|
cursor: 'fallback-signals',
|
||||||
|
items: <SignalItem>[
|
||||||
|
SignalItem(
|
||||||
|
id: 'fallback-1',
|
||||||
|
type: 'recommendation',
|
||||||
|
title: 'Check in with ${people.first.name} tonight',
|
||||||
|
description: 'A short voice memo can keep momentum.',
|
||||||
|
personId: people.first.id,
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
SignalItem(
|
||||||
|
id: 'fallback-2',
|
||||||
|
type: 'trend',
|
||||||
|
title: 'Gift idea: practical + personal is trending',
|
||||||
|
description: 'Combine utility with one sentimental detail.',
|
||||||
|
personId: people.last.id,
|
||||||
|
createdAt: now.subtract(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final AsyncNotifierProvider<SignalsController, SignalsFeed>
|
||||||
|
signalsControllerProvider =
|
||||||
|
AsyncNotifierProvider<SignalsController, SignalsFeed>(
|
||||||
|
SignalsController.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
import 'package:relationship_saver/features/signals/signals_controller.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
class SignalsView extends ConsumerWidget {
|
||||||
|
const SignalsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final AsyncValue<SignalsFeed> signals = ref.watch(
|
||||||
|
signalsControllerProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Signals',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Backend-driven recommendations and trends you can act on.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(signalsControllerProvider.notifier).refresh();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
tooltip: 'Refresh feed',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: signals.when(
|
||||||
|
data: (SignalsFeed feed) {
|
||||||
|
if (feed.items.isEmpty) {
|
||||||
|
return const Center(child: Text('No signals right now.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: feed.items.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final SignalItem item = feed.items[index];
|
||||||
|
return FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF8FB),
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.type.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: AppTheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${item.createdAt.month}/${item.createdAt.day}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (item.description case final String description)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
|
?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(signalsControllerProvider.notifier)
|
||||||
|
.acknowledge(item.id, SignalAction.saved);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.bookmark_add_outlined),
|
||||||
|
label: const Text('Save'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(signalsControllerProvider.notifier)
|
||||||
|
.acknowledge(
|
||||||
|
item.id,
|
||||||
|
SignalAction.dismissed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
label: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (Object error, StackTrace stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Unable to load signals. Try refresh.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class SyncView extends ConsumerStatefulWidget {
|
||||||
|
const SyncView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SyncView> createState() => _SyncViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncViewState extends ConsumerState<SyncView> {
|
||||||
|
static const Uuid _uuid = Uuid();
|
||||||
|
|
||||||
|
bool _busy = false;
|
||||||
|
String _status = 'Ready. Local-first mode keeps app usable without sync.';
|
||||||
|
String? _cursor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Push local changes and pull remote updates when connectivity is available.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: <Widget>[
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _busy ? null : _pushSample,
|
||||||
|
icon: const Icon(Icons.upload_rounded),
|
||||||
|
label: const Text('Push Sample Changes'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _busy ? null : _pullChanges,
|
||||||
|
icon: const Icon(Icons.download_rounded),
|
||||||
|
label: const Text('Pull Changes'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Cursor: ${_cursor ?? 'not set'}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_status,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Text(
|
||||||
|
'Sync policy\n• Core usage never blocks on backend\n• Retries only for transient failures\n• Non-idempotent requests require idempotency key',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pullChanges() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_status = 'Pulling changes...';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final SyncPullResult result = await ref
|
||||||
|
.read(backendGatewayProvider)
|
||||||
|
.pullChanges(cursor: _cursor);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_cursor = result.cursor;
|
||||||
|
_status = 'Pulled ${result.changes.length} changes successfully.';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_status = 'Pull failed. Staying offline-safe. ($error)';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushSample() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_status = 'Pushing local sample changes...';
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
|
||||||
|
ChangeEnvelope(
|
||||||
|
schemaVersion: 1,
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: 'capture-${_uuid.v4()}',
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
modifiedAt: DateTime.now().toUtc(),
|
||||||
|
clientMutationId: _uuid.v4(),
|
||||||
|
payload: const <String, dynamic>{
|
||||||
|
'summary': 'Shared a thoughtful check-in after work.',
|
||||||
|
'sentiment': 'positive',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final SyncPushResult result = await ref
|
||||||
|
.read(backendGatewayProvider)
|
||||||
|
.pushChanges(changes: changes, cursor: _cursor);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_cursor = result.cursor;
|
||||||
|
_status =
|
||||||
|
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_status = 'Push failed. Local data remains safe. ($error)';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-35
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/home/app_shell.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: RelationshipSaverApp()));
|
runApp(const ProviderScope(child: RelationshipSaverApp()));
|
||||||
@@ -16,40 +16,7 @@ class RelationshipSaverApp extends StatelessWidget {
|
|||||||
title: 'Relationship Saver',
|
title: 'Relationship Saver',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
home: const _LandingScreen(),
|
home: const AppShell(),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LandingScreen extends StatelessWidget {
|
|
||||||
const _LandingScreen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Relationship Saver')),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'Backend gateway scaffold is ready.',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Mode: ${AppConfig.useFakeBackend ? 'Fake gateway' : 'REST gateway'}',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Base URL: ${AppConfig.backendBaseUrl}',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- family: WorkSans
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/WorkSans-Regular.ttf
|
||||||
|
- asset: assets/fonts/WorkSans-Medium.ttf
|
||||||
|
weight: 500
|
||||||
|
- asset: assets/fonts/WorkSans-SemiBold.ttf
|
||||||
|
weight: 600
|
||||||
|
- asset: assets/fonts/WorkSans-Bold.ttf
|
||||||
|
weight: 700
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:relationship_saver/main.dart';
|
import 'package:relationship_saver/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders app shell', (WidgetTester tester) async {
|
testWidgets('renders dashboard flow', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const RelationshipSaverApp());
|
await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Relationship Saver'), findsOneWidget);
|
expect(find.text('Today'), findsOneWidget);
|
||||||
expect(
|
expect(find.text('Active People'), findsOneWidget);
|
||||||
find.textContaining('Backend gateway scaffold is ready.'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user