feat: native family profiles screen with dossier switcher
Adds a dedicated Family tab (tab index 2) to the bottom nav bar showing all accessible dossiers as native profile cards. Tapping a card navigates the home WebView directly to that dossier. FamilyScreen: - Fetches /api/v1/dashboard on load (pull-to-refresh) - Profile cards: avatar with initials + color-coded background, name, relation + age + sex subtitle, chevron - 'Add family member' CTA card at the bottom (opens /dossier/add in WebView) - Error state with retry button - Loading + empty state handling Navigation: - Tapping a profile sets tab to Home and loads the dossier URL in the existing WebView via loadRequest() — no new WebView instance - Settings moved to tab index 3 (was 2) API: - Uses enriched /api/v1/dashboard fields: initials, color, age, dob, sex, is_self (added in companion server commit)
This commit is contained in:
parent
dd7a998661
commit
add0071650
|
|
@ -0,0 +1,349 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../services/inou_api.dart';
|
||||
|
||||
/// A dossier entry from /api/v1/dashboard
|
||||
class DossierProfile {
|
||||
final String guid;
|
||||
final String name;
|
||||
final String relation;
|
||||
final bool canAdd;
|
||||
final String initials;
|
||||
final String color;
|
||||
final String age;
|
||||
final String dob;
|
||||
final int sex;
|
||||
final bool isSelf;
|
||||
|
||||
const DossierProfile({
|
||||
required this.guid,
|
||||
required this.name,
|
||||
required this.relation,
|
||||
required this.canAdd,
|
||||
required this.initials,
|
||||
required this.color,
|
||||
required this.age,
|
||||
required this.dob,
|
||||
required this.sex,
|
||||
required this.isSelf,
|
||||
});
|
||||
|
||||
factory DossierProfile.fromJson(Map<String, dynamic> j) => DossierProfile(
|
||||
guid: j['guid'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
relation: j['relation'] as String? ?? 'other',
|
||||
canAdd: j['can_add'] as bool? ?? false,
|
||||
initials: j['initials'] as String? ?? '?',
|
||||
color: j['color'] as String? ?? '#7E8FC2',
|
||||
age: j['age'] as String? ?? '',
|
||||
dob: j['dob'] as String? ?? '',
|
||||
sex: j['sex'] as int? ?? 0,
|
||||
isSelf: j['is_self'] as bool? ?? false,
|
||||
);
|
||||
|
||||
String get displayName => isSelf && name.isEmpty ? 'Me' : name;
|
||||
|
||||
String get subtitle {
|
||||
final parts = <String>[];
|
||||
if (!isSelf && relation.isNotEmpty && relation != 'other') {
|
||||
parts.add(_capitalise(relation));
|
||||
}
|
||||
if (age.isNotEmpty) parts.add(age);
|
||||
if (sex == 1) parts.add('M');
|
||||
if (sex == 2) parts.add('F');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
static String _capitalise(String s) =>
|
||||
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
Color get avatarColor {
|
||||
try {
|
||||
final hex = color.replaceFirst('#', '');
|
||||
return Color(int.parse('FF$hex', radix: 16));
|
||||
} catch (_) {
|
||||
return AppTheme.primaryColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native family profiles screen.
|
||||
/// Shows all accessible dossiers as cards; tapping opens the dossier in the
|
||||
/// portal WebView. Also shows an "Add family member" CTA at the bottom.
|
||||
class FamilyScreen extends StatefulWidget {
|
||||
/// Called when a dossier is selected — navigate the WebView to this URL.
|
||||
final void Function(String url)? onOpenDossier;
|
||||
|
||||
const FamilyScreen({super.key, this.onOpenDossier});
|
||||
|
||||
@override
|
||||
State<FamilyScreen> createState() => _FamilyScreenState();
|
||||
}
|
||||
|
||||
class _FamilyScreenState extends State<FamilyScreen> {
|
||||
final _api = InouApi();
|
||||
List<DossierProfile>? _profiles;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final resp = await _api.get('/api/v1/dashboard');
|
||||
if (resp.statusCode == 200) {
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final list = (data['dossiers'] as List<dynamic>? ?? [])
|
||||
.map((e) => DossierProfile.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
setState(() { _profiles = list; _loading = false; });
|
||||
} else {
|
||||
setState(() { _error = 'Could not load profiles (${resp.statusCode})'; _loading = false; });
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() { _error = 'Network error: $e'; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void _openDossier(DossierProfile p) {
|
||||
// webAppUrl is e.g. https://inou.com/app — strip trailing path to get origin
|
||||
final origin = AppConfig.webAppUrl.replaceFirst(RegExp(r'/app.*$'), '');
|
||||
final url = '$origin/dossier/${p.guid}';
|
||||
if (widget.onOpenDossier != null) {
|
||||
widget.onOpenDossier!(url);
|
||||
}
|
||||
}
|
||||
|
||||
void _openAddDossier() {
|
||||
final origin = AppConfig.webAppUrl.replaceFirst(RegExp(r'/app.*$'), '');
|
||||
final url = '$origin/dossier/add';
|
||||
if (widget.onOpenDossier != null) {
|
||||
widget.onOpenDossier!(url);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.backgroundColor,
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: AppTheme.primaryColor,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 8),
|
||||
child: Text(
|
||||
'Family',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null)
|
||||
SliverFillRemaining(
|
||||
child: _ErrorView(message: _error!, onRetry: _load),
|
||||
)
|
||||
else ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final p = _profiles![i];
|
||||
return _ProfileCard(
|
||||
profile: p,
|
||||
onTap: () => _openDossier(p),
|
||||
);
|
||||
},
|
||||
childCount: _profiles!.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: _AddFamilyMemberCard(onTap: _openAddDossier),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileCard extends StatelessWidget {
|
||||
final DossierProfile profile;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ProfileCard({required this.profile, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: AppTheme.surfaceColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: profile.avatarColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
profile.initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
// Name + meta
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textColor,
|
||||
),
|
||||
),
|
||||
if (profile.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
profile.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textColor.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Chevron
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.textColor.withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddFamilyMemberCard extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AddFamilyMemberCard({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: AppTheme.surfaceColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person_add_outlined,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
'Add family member',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.red.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(onPressed: onRetry, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import 'core/auth_gate.dart';
|
|||
import 'features/webview/webview_screen.dart';
|
||||
import 'features/input/input_screen.dart';
|
||||
import 'features/settings/settings_screen.dart';
|
||||
import 'features/family/family_screen.dart';
|
||||
import 'features/auth/login_screen.dart';
|
||||
import 'services/auth_service.dart';
|
||||
|
||||
|
|
@ -180,16 +181,27 @@ class MainScaffold extends StatefulWidget {
|
|||
|
||||
class MainScaffoldState extends State<MainScaffold> {
|
||||
int _currentIndex = 0;
|
||||
final GlobalKey<WebViewScreenState> _webViewKey = GlobalKey<WebViewScreenState>();
|
||||
|
||||
/// Navigate the home WebView to a URL (called by FamilyScreen when a dossier is tapped)
|
||||
void _openInWebView(String url) {
|
||||
setState(() { _currentIndex = 0; });
|
||||
// Give IndexedStack a frame to show the WebView before navigating
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_webViewKey.currentState?.webViewState?.controller.loadRequest(Uri.parse(url));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: const [
|
||||
WebViewScreen(),
|
||||
InputScreen(),
|
||||
SettingsScreen(),
|
||||
children: [
|
||||
WebViewScreen(key: _webViewKey),
|
||||
const InputScreen(),
|
||||
FamilyScreen(onOpenDossier: _openInWebView),
|
||||
const SettingsScreen(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
|
@ -210,6 +222,11 @@ class MainScaffoldState extends State<MainScaffold> {
|
|||
selectedIcon: Icon(Icons.edit),
|
||||
label: 'Input',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.people_outline),
|
||||
selectedIcon: Icon(Icons.people),
|
||||
label: 'Family',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
|
|
|
|||
Loading…
Reference in New Issue