Implement multi-screen app UI flow and navigation

This commit is contained in:
Rijad Zuzo
2026-02-15 13:57:24 +01:00
parent d5146d6b09
commit ac9577c759
21 changed files with 1648 additions and 60 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ class AppConfig {
/// Enables fake backend implementation for local/offline development.
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).
static void overrideBackendBaseUrl(String? baseUrl) {
+36 -12
View File
@@ -4,31 +4,55 @@ import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
static const Color background = Color(0xFFEDF0F2);
static const Color background = Color(0xFFF3F6F8);
static const Color surface = Color(0xFFFFFFFF);
static const Color primary = Color(0xFF253840);
static const Color accent = Color(0xFF00B6F0);
static const Color primary = Color(0xFF1E3541);
static const Color accent = Color(0xFF1AB6C8);
static const Color textPrimary = Color(0xFF18303A);
static const Color textSecondary = Color(0xFF58707A);
/// Builds the Material [ThemeData].
static ThemeData light() {
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: primary,
primary: primary,
secondary: accent,
surface: surface,
brightness: Brightness.light,
final ColorScheme colorScheme = ColorScheme.fromSeed(seedColor: primary);
final TextTheme textTheme = ThemeData.light().textTheme.apply(
fontFamily: 'WorkSans',
bodyColor: textPrimary,
displayColor: textPrimary,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
colorScheme: colorScheme.copyWith(
primary: primary,
secondary: accent,
surface: surface,
),
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(
centerTitle: false,
backgroundColor: surface,
foregroundColor: primary,
backgroundColor: Colors.transparent,
foregroundColor: textPrimary,
elevation: 0,
),
cardTheme: CardThemeData(
color: surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}
+199
View File
@@ -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';
}
+268
View File
@@ -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;
}
+65
View File
@@ -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;
}
+105
View File
@@ -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();
});
+116
View File
@@ -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,
),
),
],
),
);
},
),
),
],
),
);
}
}
+273
View File
@@ -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,
),
),
);
}
}
+84
View File
@@ -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),
),
],
);
}
}
+40
View File
@@ -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,
);
+158
View File
@@ -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()),
),
),
],
),
);
}
}
+153
View File
@@ -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
View File
@@ -1,7 +1,7 @@
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/home/app_shell.dart';
void main() {
runApp(const ProviderScope(child: RelationshipSaverApp()));
@@ -16,40 +16,7 @@ class RelationshipSaverApp extends StatelessWidget {
title: 'Relationship Saver',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: const _LandingScreen(),
);
}
}
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,
),
],
),
),
home: const AppShell(),
);
}
}