diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d9c08b9..a01657c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,4 @@ +import java.io.File import java.util.Properties plugins { @@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS") val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") ?: keystoreProperties.getProperty("keyPassword") +val releaseStoreFilePath: File? = releaseStoreFile?.let { path -> + val candidate = File(path) + if (candidate.isAbsolute) return@let candidate + + val candidates = listOfNotNull( + rootProject.file(path), + rootProject.projectDir.parentFile?.let { File(it, path) }, + project.file(path), + ) + + candidates.firstOrNull { it.exists() } +} + val hasReleaseKeystore = listOf( - releaseStoreFile, + releaseStoreFilePath?.path, releaseStorePassword, releaseKeyAlias, releaseKeyPassword @@ -48,8 +62,8 @@ android { applicationId = "com.petegregoryy.mileograph_flutter" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion + minSdk = 24 + targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName } @@ -57,7 +71,7 @@ android { signingConfigs { if (hasReleaseKeystore) { create("release") { - storeFile = file(releaseStoreFile!!) + storeFile = releaseStoreFilePath storePassword = releaseStorePassword keyAlias = releaseKeyAlias!! keyPassword = releaseKeyPassword diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index ec4efff..d67756c 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -27,15 +27,20 @@ class _LegCardState extends State { @override Widget build(BuildContext context) { final leg = widget.leg; + final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty; final distanceUnits = context.watch(); final routeSegments = _parseRouteSegments(leg.route); final textTheme = Theme.of(context).textTheme; return Card( - child: ExpansionTile( - onExpansionChanged: (v) => setState(() => _expanded = v), - tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - leading: const Icon(Icons.train), - title: LayoutBuilder( + clipBehavior: Clip.antiAlias, + elevation: 1, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + onExpansionChanged: (v) => setState(() => _expanded = v), + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: const Icon(Icons.train), + title: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; final beginTimeWidget = _timeWithDelay( @@ -108,7 +113,7 @@ class _LegCardState extends State { ); }, ), - subtitle: LayoutBuilder( + subtitle: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; final tractionWrap = !_expanded && leg.locos.isNotEmpty @@ -168,7 +173,7 @@ class _LegCardState extends State { ); }, ), - trailing: Row( + trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Column( @@ -193,6 +198,17 @@ class _LegCardState extends State { ], ], ), + if (isShared) ...[ + const SizedBox(width: 8), + Tooltip( + message: 'Shared entry', + child: Icon( + Icons.share, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], if (widget.showEditButton) ...[ const SizedBox(width: 8), IconButton( @@ -217,44 +233,45 @@ class _LegCardState extends State { ], ], ], - ), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (leg.notes.isNotEmpty) ...[ - Text('Notes', style: textTheme.titleSmall), - const SizedBox(height: 4), - Text(leg.notes), - const SizedBox(height: 12), - ], - if (leg.locos.isNotEmpty) ...[ - Text('Locos', style: textTheme.titleSmall), - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 8, - children: _buildLocoChips(context, leg), - ), - const SizedBox(height: 12), - ], - if (_hasTrainDetails(leg)) ...[ - Text('Train', style: textTheme.titleSmall), - const SizedBox(height: 6), - ..._buildTrainDetails(leg, textTheme), - const SizedBox(height: 12), - ], - if (routeSegments.isNotEmpty) ...[ - Text('Route', style: textTheme.titleSmall), - const SizedBox(height: 6), - _buildRouteList(routeSegments), - ], - ], - ), ), - ], + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leg.notes.isNotEmpty) ...[ + Text('Notes', style: textTheme.titleSmall), + const SizedBox(height: 4), + Text(leg.notes), + const SizedBox(height: 12), + ], + if (leg.locos.isNotEmpty) ...[ + Text('Locos', style: textTheme.titleSmall), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: _buildLocoChips(context, leg), + ), + const SizedBox(height: 12), + ], + if (_hasTrainDetails(leg)) ...[ + Text('Train', style: textTheme.titleSmall), + const SizedBox(height: 6), + ..._buildTrainDetails(leg, textTheme), + const SizedBox(height: 12), + ], + if (routeSegments.isNotEmpty) ...[ + Text('Route', style: textTheme.titleSmall), + const SizedBox(height: 6), + _buildRouteList(routeSegments), + ], + ], + ), + ), + ], + ), ), ); } diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index 511ea46..759e9b2 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -294,6 +294,7 @@ class _RegisterPanelContentState extends State { final _passwordController = TextEditingController(); final _inviteController = TextEditingController(); bool _registering = false; + String? _usernameError; @override void dispose() { @@ -306,10 +307,21 @@ class _RegisterPanelContentState extends State { } Future _register() async { + final username = _usernameController.text.trim(); + if (username.contains(' ') || username.contains('@')) { + setState(() { + _usernameError = 'Username cannot contain spaces or @'; + }); + return; + } else { + setState(() { + _usernameError = null; + }); + } setState(() => _registering = true); try { await widget.authService.register( - username: _usernameController.text.trim(), + username: username, email: _emailController.text.trim(), fullName: _displayNameController.text.trim(), password: _passwordController.text, @@ -363,7 +375,22 @@ class _RegisterPanelContentState extends State { border: OutlineInputBorder(), labelText: "Username", ), + onChanged: (_) { + if (_usernameError != null && + !_usernameController.text.contains(' ') && + !_usernameController.text.contains('@')) { + setState(() => _usernameError = null); + } + }, ), + if (_usernameError != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _usernameError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), const SizedBox(height: 8), TextField( controller: _displayNameController, diff --git a/lib/components/pages/badges.dart b/lib/components/pages/badges.dart new file mode 100644 index 0000000..e827710 --- /dev/null +++ b/lib/components/pages/badges.dart @@ -0,0 +1,705 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class BadgesPage extends StatefulWidget { + const BadgesPage({super.key}); + + @override + State createState() => _BadgesPageState(); +} + +class _BadgesPageState extends State { + bool _initialised = false; + final Map _groupExpanded = {}; + bool _loadingAwards = false; + bool _loadingClassProgress = false; + bool _loadingLocoProgress = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialised) return; + _initialised = true; + _refreshAwards(); + } + + Future _refreshAwards() { + _loadingAwards = false; + _loadingClassProgress = false; + _loadingLocoProgress = false; + final data = context.read(); + return Future.wait([ + data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'), + data.fetchClassClearanceProgress(), + data.fetchLocoClearanceProgress(), + ]); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + final awards = data.badgeAwards; + final loading = data.isBadgeAwardsLoading; + final classProgress = data.classClearanceProgress; + final classProgressLoading = + data.isClassClearanceProgressLoading || _loadingClassProgress; + final locoProgress = data.locoClearanceProgress; + final locoProgressLoading = + data.isLocoClearanceProgressLoading || _loadingLocoProgress; + final hasAnyData = + awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text('Badges'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } else { + context.go('/'); + } + }, + ), + ), + body: RefreshIndicator( + onRefresh: _refreshAwards, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if ((loading || classProgressLoading || locoProgressLoading) && + !hasAnyData) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: CircularProgressIndicator(), + ), + ) + else if (!hasAnyData) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Text('No badges awarded yet.'), + ) + else + ..._buildGroupedAwards( + context, + awards, + classProgress, + locoProgress, + classProgressLoading, + locoProgressLoading, + data.classClearanceHasMore, + data.locoClearanceHasMore, + data.badgeAwardsHasMore, + loading, + ), + ], + ), + ), + ); + } + + List _buildGroupedAwards( + BuildContext context, + List awards, + List classProgress, + List locoProgress, + bool classProgressLoading, + bool locoProgressLoading, + bool classProgressHasMore, + bool locoProgressHasMore, + bool badgeAwardsHasMore, + bool badgeAwardsLoading, + ) { + final grouped = _groupAwards(awards); + if ((classProgress.isNotEmpty || classProgressLoading) && + !grouped.containsKey('class_clearance')) { + grouped['class_clearance'] = []; + } + if ((locoProgress.isNotEmpty || locoProgressLoading) && + !grouped.containsKey('loco_clearance')) { + grouped['loco_clearance'] = []; + } + final codes = _orderedBadgeCodes(grouped.keys.toList()); + + return codes.map((code) { + final items = grouped[code]!; + final expanded = _groupExpanded[code] ?? true; + final title = _formatBadgeName(code); + final isClass = code == 'class_clearance'; + final isLoco = code == 'loco_clearance'; + final classItems = isClass ? classProgress : []; + final locoItems = isLoco ? locoProgress : []; + final awardCount = isLoco + ? locoItems.where((item) => item.awardedTiers.isNotEmpty).length + : items.length; + final isLoadingSection = isClass + ? (classProgressLoading || badgeAwardsLoading || _loadingAwards) + : (isLoco ? locoProgressLoading : false); + + final children = []; + + if (isClass && items.isNotEmpty) { + children.add(_buildSubheading(context, 'Awarded')); + children.addAll( + items.map( + (award) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: _buildAwardCard(context, award, compact: true), + ), + ), + ); + if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) { + children.add( + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), + child: _buildLoadMoreButton( + context, + badgeAwardsLoading || _loadingAwards, + () => _loadMoreAwards(), + ), + ), + ); + } + } else if (!isClass && !isLoco && items.isNotEmpty) { + children.add(_buildSubheading(context, 'Awarded')); + children.addAll( + items.map( + (award) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: _buildAwardCard(context, award, compact: true), + ), + ), + ); + } + + if (isClass) { + children.addAll( + _buildClassProgressSection( + context, + classItems, + classProgressLoading, + classProgressHasMore, + ), + ); + } + if (isLoco) { + children.addAll( + _buildLocoProgressSection( + context, + locoItems, + locoProgressLoading, + locoProgressHasMore, + showHeading: false, + ), + ); + } + + if (children.isEmpty && !isLoadingSection) { + children.add( + const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: Text('No awards'), + ), + ); + } + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ExpansionTile( + key: ValueKey(code), + tilePadding: const EdgeInsets.symmetric(horizontal: 12.0), + title: Row( + children: [ + Expanded(child: Text(title)), + if (isLoadingSection) ...[ + const SizedBox(width: 8), + const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + const SizedBox(width: 8), + _buildCountChip(context, awardCount), + ], + ), + initiallyExpanded: expanded, + onExpansionChanged: (isOpen) { + setState(() => _groupExpanded[code] = isOpen); + }, + children: children, + ), + ); + }).toList(); + } + + Map> _groupAwards(List awards) { + final Map> grouped = {}; + for (final award in awards) { + final code = award.badgeCode.toLowerCase(); + grouped.putIfAbsent(code, () => []).add(award); + } + return grouped; + } + + Widget _buildAwardCard( + BuildContext context, + BadgeAward award, { + bool compact = false, + }) { + final badgeName = _formatBadgeName(award.badgeCode); + final tier = award.badgeTier.isNotEmpty + ? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1) + : ''; + final tierIcon = _buildTierIcon(award.badgeTier); + final scope = _scopeToShow(award); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (tierIcon != null) ...[ + tierIcon, + const SizedBox(width: 8), + ], + Expanded( + child: Text( + '$badgeName • $tier', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (award.awardedAt != null) + Text( + _formatAwardDate(award.awardedAt!), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (scope != null && scope.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + scope, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (award.loco != null) ...[ + const SizedBox(height: 6), + _buildLocoInfo(context, award.loco!), + ], + ], + ); + + if (compact) { + return content; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: content, + ), + ); + } + + Widget _buildLocoInfo(BuildContext context, LocoSummary loco) { + final lines = []; + final classNum = [ + if (loco.locoClass.isNotEmpty) loco.locoClass, + if (loco.number.isNotEmpty) loco.number, + ].join(' '); + if (classNum.isNotEmpty) lines.add(classNum); + if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!); + if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!); + if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.train, size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lines.map((line) { + return Text( + line, + style: Theme.of(context).textTheme.bodyMedium, + ); + }).toList(), + ), + ), + ], + ); + } + + String _formatBadgeName(String code) { + if (code.isEmpty) return 'Badge'; + const known = { + 'class_clearance': 'Class Clearance', + 'loco_clearance': 'Loco Clearance', + }; + final lower = code.toLowerCase(); + if (known.containsKey(lower)) return known[lower]!; + final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty); + return parts + .map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase()) + .join(' '); + } + + List _orderedBadgeCodes(List codes) { + final lowerCodes = codes.map((c) => c.toLowerCase()).toSet(); + final ordered = []; + for (final code in ['loco_clearance', 'class_clearance']) { + if (lowerCodes.remove(code)) ordered.add(code); + } + final remaining = lowerCodes.toList() + ..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b))); + ordered.addAll(remaining); + return ordered; + } + + Widget _buildSubheading(BuildContext context, String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ); + } + + List _buildClassProgressSection( + BuildContext context, + List progress, + bool isLoading, + bool hasMore, + ) { + if (progress.isEmpty && !isLoading && !hasMore) return const []; + return [ + _buildSubheading(context, 'In Progress'), + ...progress.map( + (item) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: _buildClassProgressCard(context, item), + ), + ), + if (hasMore || isLoading) + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), + child: _buildLoadMoreButton( + context, + isLoading, + () => _loadMoreClassProgress(), + ), + ), + if (progress.isNotEmpty) const SizedBox(height: 4), + ]; + } + + List _buildLocoProgressSection( + BuildContext context, + List progress, + bool isLoading, + bool hasMore, + {bool showHeading = true} + ) { + if (progress.isEmpty && !isLoading && !hasMore) return const []; + return [ + if (showHeading) _buildSubheading(context, 'In Progress'), + if (progress.isEmpty && isLoading) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: _buildLoadingIndicator(), + ), + ...progress.map( + (item) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + child: _buildLocoProgressCard(context, item), + ), + ), + if (hasMore || isLoading) + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), + child: _buildLoadMoreButton( + context, + isLoading, + () => _loadMoreLocoProgress(), + ), + ), + if (progress.isNotEmpty) const SizedBox(height: 4), + ]; + } + + Widget _buildClassProgressCard( + BuildContext context, + ClassClearanceProgress progress, + ) { + final pct = progress.percentComplete.clamp(0, 100); + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + progress.className, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Text( + '${pct.toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress.total == 0 ? 0 : pct / 100, + minHeight: 6, + ), + if (progress.total > 0) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + '${progress.completed}/${progress.total}', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: Theme.of(context).hintColor), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLocoProgressCard( + BuildContext context, + LocoClearanceProgress progress, + ) { + final tierIcons = progress.awardedTiers + .map((tier) => _buildTierIcon(tier, size: 18)) + .whereType() + .toList(); + final reachedTopTier = progress.nextTier.isEmpty; + final pct = progress.percent.clamp(0, 100); + final nextTier = progress.nextTier.isNotEmpty + ? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1) + : 'Next'; + final loco = progress.loco; + final title = [ + if (loco.number.isNotEmpty) loco.number, + if (loco.locoClass.isNotEmpty) loco.locoClass, + ].join(' • '); + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title.isNotEmpty ? title : 'Loco', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (tierIcons.isNotEmpty) + Row( + children: tierIcons + .expand((icon) sync* { + yield icon; + yield const SizedBox(width: 4); + }) + .toList() + ..removeLast(), + ), + ], + ), + if ((loco.name ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + loco.name ?? '', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (!reachedTopTier) ...[ + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress.required == 0 ? 0 : pct / 100, + minHeight: 6, + ), + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + '${pct.toStringAsFixed(0)}% to $nextTier award', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildLoadMoreButton( + BuildContext context, + bool isLoading, + Future Function() onPressed, + ) { + return Align( + alignment: Alignment.center, + child: OutlinedButton.icon( + onPressed: isLoading + ? null + : () { + onPressed(); + }, + icon: isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text(isLoading ? 'Loading...' : 'Load more'), + ), + ); + } + + Widget _buildLoadingIndicator() { + return const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + Widget _buildCountChip(BuildContext context, int count) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$count', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ); + } + + Future _loadMoreClassProgress() { + final data = context.read(); + if (data.isClassClearanceProgressLoading || _loadingClassProgress) { + return Future.value(); + } + setState(() => _loadingClassProgress = true); + return data + .fetchClassClearanceProgress( + offset: data.classClearanceProgress.length, + append: true, + ) + .whenComplete(() { + if (mounted) setState(() => _loadingClassProgress = false); + }); + } + + Future _loadMoreLocoProgress() { + final data = context.read(); + if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) { + return Future.value(); + } + setState(() => _loadingLocoProgress = true); + return data + .fetchLocoClearanceProgress( + offset: data.locoClearanceProgress.length, + append: true, + ) + .whenComplete(() { + if (mounted) setState(() => _loadingLocoProgress = false); + }); + } + + Future _loadMoreAwards() { + final data = context.read(); + if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value(); + setState(() => _loadingAwards = true); + return data + .fetchBadgeAwards( + offset: data.badgeAwards.length, + append: true, + badgeCode: 'class_clearance', + limit: 20, + ) + .whenComplete(() { + if (mounted) setState(() => _loadingAwards = false); + }); + } + + String _formatAwardDate(DateTime date) { + final y = date.year.toString().padLeft(4, '0'); + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } + + Widget? _buildTierIcon(String tier, {double size = 24}) { + final lower = tier.toLowerCase(); + Color? color; + switch (lower) { + case 'bronze': + color = const Color(0xFFCD7F32); + break; + case 'silver': + color = const Color(0xFFC0C0C0); + break; + case 'gold': + color = const Color(0xFFFFD700); + break; + } + if (color == null) return null; + return Icon(Icons.emoji_events, color: color, size: size); + } + + String? _scopeToShow(BadgeAward award) { + final scope = award.scopeValue?.trim() ?? ''; + if (scope.isEmpty) return null; + final code = award.badgeCode.toLowerCase(); + if (code == 'loco_clearance') { + // Hide numeric loco IDs; loco details are shown separately. + if (int.tryParse(scope) != null) return null; + } + return scope; + } +} diff --git a/lib/components/pages/more/more_home_page.dart b/lib/components/pages/more/more_home_page.dart index fab1856..e8a9755 100644 --- a/lib/components/pages/more/more_home_page.dart +++ b/lib/components/pages/more/more_home_page.dart @@ -20,10 +20,16 @@ class MoreHomePage extends StatelessWidget { Card( child: Column( children: [ + ListTile( + leading: const Icon(Icons.person), + title: const Text('Profile'), + onTap: () => context.go('/more/profile'), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.emoji_events), title: const Text('Badges'), - onTap: () => context.go('/more/profile'), + onTap: () => context.go('/more/badges'), ), const Divider(height: 1), ListTile( diff --git a/lib/components/pages/new_entry/new_entry.dart b/lib/components/pages/new_entry/new_entry.dart index db6c4ba..1aaa655 100644 --- a/lib/components/pages/new_entry/new_entry.dart +++ b/lib/components/pages/new_entry/new_entry.dart @@ -10,6 +10,7 @@ import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart index 98cb908..5244bfd 100644 --- a/lib/components/pages/new_entry/new_entry_draft_logic.dart +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -4,6 +4,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { Future _handleExitIntent() async { if (!mounted) return false; if (_isEditing) return true; + if (_activeLegShare != null) return true; if (_formIsEmpty()) return true; if (_activeDraftId != null && !_draftChangedFromBaseline()) { return true; @@ -82,6 +83,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { } Future _openDrafts() async { + if (_activeLegShare != null) return; final selected = await Navigator.of(context).push<_StoredDraft>( MaterialPageRoute( builder: (_) => _DraftListPage( @@ -97,6 +99,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { } Future _saveDraftManually() async { + if (_activeLegShare != null) return; if (_savingDraft) return; if (_formIsEmpty()) { ScaffoldMessenger.maybeOf(context)?.showSnackBar( @@ -123,7 +126,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState { } Future _saveDraft() async { - if (_restoringDraft || !_draftPersistenceEnabled) return; + if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) { + return; + } final prefs = await SharedPreferences.getInstance(); final draft = { "date": _selectedDate.toIso8601String(), diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 4be4983..5b8acdf 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -1,9 +1,10 @@ part of 'new_entry.dart'; class NewEntryPage extends StatefulWidget { - const NewEntryPage({super.key, this.editLegId}); + const NewEntryPage({super.key, this.editLegId, this.legShare}); final int? editLegId; + final LegShareData? legShare; @override State createState() => _NewEntryPageState(); @@ -52,9 +53,15 @@ class _NewEntryPageState extends State { const DeepCollectionEquality(); String? _activeDraftId; DistanceUnit? _lastDistanceUnit; + Set _shareUserIds = {}; + List _shareUsers = []; + LegShareData? _activeLegShare; + String? _sharedFromUser; + int? _shareNotificationId; bool get _isEditing => widget.editLegId != null; bool get _draftPersistenceEnabled => + _activeLegShare == null && false; // legacy single draft disabled in favor of draft list @override @@ -69,16 +76,28 @@ class _NewEntryPageState extends State { data.fetchClassList(); data.fetchTripOptions(); _loadStations(); - if (_draftPersistenceEnabled) { + if (_draftPersistenceEnabled && widget.legShare == null) { _loadDraft(); } _loadStations(); if (_isEditing && widget.editLegId != null) { _loadLegForEdit(widget.editLegId!); + } else if (widget.legShare != null) { + _applyLegShare(widget.legShare!); } }); } + @override + void didUpdateWidget(covariant NewEntryPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.legShare != null && + (oldWidget.legShare == null || + widget.legShare!.id != oldWidget.legShare!.id)) { + _applyLegShare(widget.legShare!); + } + } + @override void dispose() { NavigationGuard.unregister(_exitGuard); @@ -134,6 +153,282 @@ class _NewEntryPageState extends State { 0; } + List _friendsFromFriendships( + DataService data, + String? selfId, + ) { + final friends = []; + for (final f in data.friendships) { + final other = _friendFromFriendship(f, selfId); + if (other != null && + other.userId.isNotEmpty && + !friends.any((u) => u.userId == other.userId)) { + friends.add(other); + } + } + return friends; + } + + UserSummary? _friendFromFriendship(Friendship friendship, String? selfId) { + final self = selfId ?? ''; + if (friendship.requesterId == self) return friendship.addressee; + if (friendship.addresseeId == self) return friendship.requester; + if (friendship.addressee != null) return friendship.addressee; + if (friendship.requester != null) return friendship.requester; + return null; + } + + Widget _buildShareSection(BuildContext context) { + final selected = _shareUsers; + final label = selected.isEmpty + ? 'Share with friends' + : 'Shared with ${selected.length} friend${selected.length == 1 ? '' : 's'}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: _submitting ? null : _openShareSheet, + icon: const Icon(Icons.share), + label: Text(label), + ), + if (selected.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: selected + .map( + (u) => InputChip( + label: Text(u.displayName), + avatar: const Icon(Icons.person, size: 16), + onDeleted: _submitting + ? null + : () { + setState(() { + _shareUserIds.remove(u.userId); + _shareUsers.removeWhere( + (item) => item.userId == u.userId, + ); + }); + }, + ), + ) + .toList(), + ), + ], + ], + ); + } + + Widget _buildSharedBanner() { + final from = _sharedFromUser; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.share, color: Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text( + from != null && from.isNotEmpty + ? 'This entry was shared by $from.' + : 'This entry was shared with you.', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Future _openShareSheet() async { + if (_activeLegShare != null) return; + final data = context.read(); + final auth = context.read(); + try { + await data.fetchFriendships(); + } catch (_) {} + if (!mounted) return; + final baseFriends = _friendsFromFriendships(data, auth.userId); + final initialSelectedIds = {..._shareUserIds}; + final initialSelectedUsers = { + for (final u in _shareUsers) u.userId: u, + }; + + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (ctx) { + final searchController = TextEditingController(); + List searchResults = []; + bool searching = false; + String searchTerm = ''; + String? searchError; + final selectedIds = {...initialSelectedIds}; + final selectedUsers = {...initialSelectedUsers}; + + return StatefulBuilder( + builder: (modalContext, setModalState) { + Future runSearch(String term) async { + final trimmed = term.trim(); + if (trimmed.isEmpty) { + setModalState(() { + searchTerm = ''; + searchResults = []; + searchError = null; + }); + return; + } + setModalState(() { + searchTerm = trimmed; + searching = true; + searchError = null; + }); + try { + final results = + await data.searchUsers(trimmed, friendsOnly: true); + setModalState(() { + searchResults = results; + }); + } catch (e) { + setModalState(() { + searchError = 'Search failed'; + }); + } finally { + setModalState(() { + searching = false; + }); + } + } + + final list = searchTerm.isNotEmpty ? searchResults : baseFriends; + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(modalContext).viewInsets.bottom + 16, + left: 16, + right: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Share with friends', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(modalContext).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: searchController, + decoration: InputDecoration( + labelText: 'Search friends', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.search), + onPressed: () => runSearch(searchController.text), + ), + ), + onSubmitted: runSearch, + ), + if (searching) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: LinearProgressIndicator(minHeight: 2), + ), + if (searchError != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + searchError!, + style: const TextStyle(color: Colors.red), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 320, + child: list.isEmpty + ? const Center(child: Text('No friends found')) + : ListView.builder( + itemCount: list.length, + itemBuilder: (_, index) { + final user = list[index]; + final isSelected = + selectedIds.contains(user.userId); + return CheckboxListTile( + value: isSelected, + title: Text(user.displayName), + subtitle: user.username.isNotEmpty + ? Text('@${user.username}') + : null, + onChanged: (checked) { + setModalState(() { + if (checked == true) { + selectedIds.add(user.userId); + selectedUsers[user.userId] = user; + } else { + selectedIds.remove(user.userId); + selectedUsers.remove(user.userId); + } + }); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(modalContext).pop(), + child: const Text('Cancel'), + ), + const Spacer(), + ElevatedButton( + onPressed: () => Navigator.of(modalContext).pop( + selectedIds + .map((id) => selectedUsers[id]) + .whereType() + .toList(), + ), + child: const Text('Save'), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + + if (result != null && mounted) { + setState(() { + _shareUsers = result; + _shareUserIds = result.map((u) => u.userId).toSet(); + }); + } + } + void _syncManualFieldUnit(DistanceUnit currentUnit) { if (!_useManualMileage) { _lastDistanceUnit = currentUnit; @@ -571,6 +866,96 @@ class _NewEntryPageState extends State { } } + void _applyLegShare(LegShareData share) { + final entry = share.entry; + final beginTime = entry.beginTime; + final endTime = entry.endTime; + final routeStations = entry.route; + final mileageVal = entry.mileage; + final units = _distanceUnits(context); + final useManual = routeStations.isEmpty; + final routeResult = useManual + ? null + : RouteResult( + inputRoute: routeStations, + calculatedRoute: routeStations, + costs: const [], + distance: mileageVal, + ); + final tractionItems = _buildTractionFromApi( + entry.locos + .map((l) => { + "loco_id": l.id, + "type": l.type, + "number": l.number, + "class": l.locoClass, + "name": l.name, + "operator": l.operator, + "notes": l.notes, + "evn": l.evn, + "alloc_pos": l.allocPos, + "alloc_powering": l.powering ? 1 : 0, + }) + .toList(), + ); + final beginDelay = entry.beginDelayMinutes ?? 0; + final endDelay = entry.endDelayMinutes ?? 0; + final originTime = entry.originTime; + final destinationTime = entry.destinationTime; + final hasOriginTime = originTime != null; + final hasDestinationTime = destinationTime != null; + final hasEndTime = endTime != null || endDelay != 0; + + _restoringDraft = true; + setState(() { + _activeLegShare = share; + _sharedFromUser = share.sharedFromName; + _shareNotificationId = share.notificationId; + _selectedTripId = null; + _selectedDate = beginTime; + _selectedTime = TimeOfDay.fromDateTime(beginTime); + _selectedEndDate = endTime ?? beginTime; + _selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime); + _hasEndTime = hasEndTime; + _selectedOriginDate = originTime ?? beginTime; + _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); + _selectedDestinationDate = destinationTime ?? endTime ?? beginTime; + _selectedDestinationTime = + TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); + _hasOriginTime = hasOriginTime; + _hasDestinationTime = hasDestinationTime; + _useManualMileage = useManual; + _routeResult = routeResult; + _startController.text = entry.start; + _endController.text = entry.end; + _headcodeController.text = entry.headcode.toUpperCase(); + _notesController.text = entry.notes; + _networkController.text = entry.network.toUpperCase(); + _originController.text = entry.origin; + _destinationController.text = entry.destination; + _beginDelayController.text = beginDelay.toString(); + _endDelayController.text = endDelay.toString(); + _mileageController.text = mileageVal == 0 + ? '' + : _formatDistance( + units, + mileageVal, + decimals: 2, + includeUnit: false, + ); + _tractionItems + ..clear() + ..addAll(tractionItems); + if (_tractionItems.where((e) => e.isMarker).isEmpty) { + _tractionItems.insert(0, _TractionItem.marker()); + } + _lastSubmittedSnapshot = null; + _shareUserIds.clear(); + _shareUsers.clear(); + }); + _restoringDraft = false; + } + List _parseRouteStations(dynamic raw) { if (raw is List) { return raw.map((e) => e.toString()).toList(); @@ -809,7 +1194,8 @@ class _NewEntryPageState extends State { minimumSize: const Size(0, 36), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - onPressed: _isEditing ? null : _openDrafts, + onPressed: + _isEditing || _activeLegShare != null ? null : _openDrafts, icon: const Icon(Icons.list_alt, size: 16), label: const Text('Drafts'), ), @@ -817,23 +1203,26 @@ class _NewEntryPageState extends State { TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: _isEditing || _savingDraft || _submitting - ? null - : _saveDraftManually, - icon: _savingDraft - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _isEditing || + _savingDraft || + _submitting || + _activeLegShare != null + ? null + : _saveDraftManually, + icon: _savingDraft + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_alt, size: 16), label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'), - ), - const Spacer(), - TextButton.icon( + ), + const Spacer(), + TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 36), @@ -844,11 +1233,17 @@ class _NewEntryPageState extends State { : () => _resetFormState(clearDraft: true), icon: const Icon(Icons.clear, size: 16), label: const Text('Clear form'), - ), - ], ), - _buildTripSelector(context), - _dateTimeGroup( + ], + ), + if (_activeLegShare != null) ...[ + const SizedBox(height: 8), + _buildSharedBanner(), + ], + _buildTripSelector(context), + const SizedBox(height: 12), + if (_activeLegShare == null) _buildShareSection(context), + _dateTimeGroup( context, title: 'Departure time', onDateTap: _pickDate, @@ -1100,11 +1495,15 @@ class _NewEntryPageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), - label: Text( - _submitting - ? (_isEditing ? 'Saving...' : 'Submitting...') - : (_isEditing ? 'Save changes' : 'Submit entry'), - ), + label: () { + final shareMode = _activeLegShare != null; + if (_submitting) { + if (shareMode) return const Text('Accepting...'); + return Text(_isEditing ? 'Saving...' : 'Submitting...'); + } + if (shareMode) return const Text('Accept entry'); + return Text(_isEditing ? 'Save changes' : 'Submit entry'); + }(), ), ], ), diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index e20f357..7350287 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -108,6 +108,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { "leg_begin_delay": beginDelay, if (_hasEndTime) "leg_end_delay": endDelay, "locos": tractionPayload, + if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id, + "share_user_ids": _shareUserIds.toList(), }; if (_useManualMileage) { final body = { @@ -136,6 +138,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { if (!mounted) return; dataService.refreshLegs(); await dataService.fetchNotifications(); + if (_shareNotificationId != null) { + await dataService.dismissNotifications([_shareNotificationId!]); + } if (!mounted) return; messenger?.showSnackBar( SnackBar( @@ -188,6 +193,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { "headcode": _headcodeController.text.trim(), "beginDelay": beginDelay, "endDelay": endDelay, + "legShareId": _activeLegShare?.id, + "shareUserIds": _shareUserIds.toList(), "locos": tractionPayload, "routeResult": _routeResult == null ? null @@ -223,6 +230,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } Future _resetFormState({bool clearDraft = false}) async { + final hadShare = _activeLegShare != null || widget.legShare != null; _formKey.currentState?.reset(); _startController.clear(); _endController.clear(); @@ -252,6 +260,11 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { _matchDestinationToEntry = false; _matchUpdateScheduled = false; _routeResult = null; + _activeLegShare = null; + _sharedFromUser = null; + _shareNotificationId = null; + _shareUserIds.clear(); + _shareUsers.clear(); _tractionItems ..clear() ..add(_TractionItem.marker()); @@ -261,6 +274,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { _savingDraft = false; _loadedDraftSnapshot = null; }); + if (hadShare && mounted) { + // Clear any share params from the URL when resetting. + GoRouter.of(context).go('/add'); + } if (clearDraft) { await _clearDraft(); } diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index b562131..92fef75 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({super.key}); @@ -12,694 +12,602 @@ class ProfilePage extends StatefulWidget { } class _ProfilePageState extends State { - bool _initialised = false; - final Map _groupExpanded = {}; - bool _loadingAwards = false; - bool _loadingClassProgress = false; - bool _loadingLocoProgress = false; + final TextEditingController _searchController = TextEditingController(); + List _searchResults = []; + bool _searching = false; + String? _searchError; + bool _fetched = false; + + UserSummary? _selectedUser; + Friendship? _status; + bool _statusLoading = false; + bool _actionLoading = false; @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_initialised) return; - _initialised = true; - _refreshAwards(); + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _fetched) return; + _fetched = true; + final data = context.read(); + data.fetchFriendships(); + data.fetchPendingFriendships(); + }); } - Future _refreshAwards() { - _loadingAwards = false; - _loadingClassProgress = false; - _loadingLocoProgress = false; - final data = context.read(); - return Future.wait([ - data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'), - data.fetchClassClearanceProgress(), - data.fetchLocoClearanceProgress(), - ]); + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _searchUsers() async { + final query = _searchController.text.trim(); + if (query.isEmpty) { + setState(() { + _searchResults = []; + _searchError = null; + }); + return; + } + setState(() { + _searching = true; + _searchError = null; + }); + try { + final results = await context.read().searchUsers(query); + if (!mounted) return; + setState(() { + _searchResults = results; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _searchError = 'Search failed'; + }); + } finally { + if (mounted) setState(() => _searching = false); + } + } + + Future _loadStatus(UserSummary user) async { + setState(() { + _selectedUser = user; + _statusLoading = true; + }); + try { + final status = + await context.read().fetchFriendshipStatus(user.userId); + if (!mounted) return; + setState(() => _status = status); + } catch (_) { + if (!mounted) return; + setState(() => _status = null); + } finally { + if (mounted) setState(() => _statusLoading = false); + } + } + + Future _sendRequest(UserSummary user) async { + setState(() => _actionLoading = true); + try { + final status = await context.read().requestFriendship( + user.userId, + targetUser: user, + ); + if (!mounted) return; + setState(() => _status = status); + _showSnack('Friend request sent'); + } catch (e) { + _showSnack('Failed to send request: $e'); + } finally { + if (mounted) setState(() => _actionLoading = false); + } + } + + Future _cancelRequest(Friendship status) async { + final id = status.id; + if (id == null || id.isEmpty) return; + setState(() => _actionLoading = true); + try { + await context.read().cancelFriendship(id); + if (!mounted) return; + setState(() => _status = status.copyWith(status: 'none')); + _showSnack('Request cancelled'); + } catch (e) { + _showSnack('Failed to cancel: $e'); + } finally { + if (mounted) setState(() => _actionLoading = false); + } + } + + Future _accept(Friendship status) async { + final id = status.id; + if (id == null || id.isEmpty) return; + setState(() => _actionLoading = true); + try { + final updated = await context.read().acceptFriendship(id); + if (!mounted) return; + setState(() => _status = updated); + _showSnack('Friend request accepted'); + } catch (e) { + _showSnack('Failed to accept: $e'); + } finally { + if (mounted) setState(() => _actionLoading = false); + } + } + + Future _reject(Friendship status) async { + final id = status.id; + if (id == null || id.isEmpty) return; + setState(() => _actionLoading = true); + try { + final updated = await context.read().rejectFriendship(id); + if (!mounted) return; + setState(() => _status = updated); + _showSnack('Friend request rejected'); + } catch (e) { + _showSnack('Failed to reject: $e'); + } finally { + if (mounted) setState(() => _actionLoading = false); + } + } + + Future _unfriend(Friendship status) async { + final id = status.id; + if (id == null || id.isEmpty) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove friend'), + content: const Text('Are you sure you want to remove this friend?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirm != true) return; + if (!mounted) return; + setState(() => _actionLoading = true); + try { + await context.read().deleteFriendship(id); + if (!mounted) return; + setState(() => _status = status.copyWith(status: 'none')); + _showSnack('Friend removed'); + } catch (e) { + _showSnack('Failed to remove friend: $e'); + } finally { + if (mounted) setState(() => _actionLoading = false); + } + } + + void _showSnack(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { + final auth = context.watch(); final data = context.watch(); - final awards = data.badgeAwards; - final loading = data.isBadgeAwardsLoading; - final classProgress = data.classClearanceProgress; - final classProgressLoading = - data.isClassClearanceProgressLoading || _loadingClassProgress; - final locoProgress = data.locoClearanceProgress; - final locoProgressLoading = - data.isLocoClearanceProgressLoading || _loadingLocoProgress; - final hasAnyData = - awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty; + final statsUser = data.homepageStats?.user; + final name = auth.fullName?.isNotEmpty == true + ? auth.fullName! + : (statsUser?.fullName ?? ''); + final username = auth.username ?? statsUser?.username ?? ''; + final email = statsUser?.email ?? ''; return Scaffold( appBar: AppBar( - title: const Text('Badges'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.pop(); - } else { - context.go('/'); - } - }, - ), + title: const Text('Profile'), ), body: RefreshIndicator( - onRefresh: _refreshAwards, + onRefresh: () async { + await data.fetchFriendships(); + await data.fetchPendingFriendships(); + if (_selectedUser != null) { + await _loadStatus(_selectedUser!); + } + }, child: ListView( padding: const EdgeInsets.all(16), children: [ - if ((loading || classProgressLoading || locoProgressLoading) && - !hasAnyData) - const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 24.0), - child: CircularProgressIndicator(), - ), - ) - else if (!hasAnyData) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12.0), - child: Text('No badges awarded yet.'), - ) - else - ..._buildGroupedAwards( - context, - awards, - classProgress, - locoProgress, - classProgressLoading, - locoProgressLoading, - data.classClearanceHasMore, - data.locoClearanceHasMore, - data.badgeAwardsHasMore, - loading, - ), + _buildUserCard(name: name, username: username, email: email), + const SizedBox(height: 16), + _buildSearchSection(), + const SizedBox(height: 16), + _buildSelectedUserSection(auth), + const SizedBox(height: 16), + _buildFriendsList(auth), ], ), ), ); } - List _buildGroupedAwards( - BuildContext context, - List awards, - List classProgress, - List locoProgress, - bool classProgressLoading, - bool locoProgressLoading, - bool classProgressHasMore, - bool locoProgressHasMore, - bool badgeAwardsHasMore, - bool badgeAwardsLoading, - ) { - final grouped = _groupAwards(awards); - if ((classProgress.isNotEmpty || classProgressLoading) && - !grouped.containsKey('class_clearance')) { - grouped['class_clearance'] = []; - } - if ((locoProgress.isNotEmpty || locoProgressLoading) && - !grouped.containsKey('loco_clearance')) { - grouped['loco_clearance'] = []; - } - final codes = _orderedBadgeCodes(grouped.keys.toList()); - - return codes.map((code) { - final items = grouped[code]!; - final expanded = _groupExpanded[code] ?? true; - final title = _formatBadgeName(code); - final isClass = code == 'class_clearance'; - final isLoco = code == 'loco_clearance'; - final classItems = isClass ? classProgress : []; - final locoItems = isLoco ? locoProgress : []; - final awardCount = isLoco - ? locoItems.where((item) => item.awardedTiers.isNotEmpty).length - : items.length; - final isLoadingSection = isClass - ? (classProgressLoading || badgeAwardsLoading || _loadingAwards) - : (isLoco ? locoProgressLoading : false); - - final children = []; - - if (isClass && items.isNotEmpty) { - children.add(_buildSubheading(context, 'Awarded')); - children.addAll( - items.map( - (award) => Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: _buildAwardCard(context, award, compact: true), - ), - ), - ); - if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) { - children.add( - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), - child: _buildLoadMoreButton( - context, - badgeAwardsLoading || _loadingAwards, - () => _loadMoreAwards(), - ), - ), - ); - } - } else if (!isClass && !isLoco && items.isNotEmpty) { - children.add(_buildSubheading(context, 'Awarded')); - children.addAll( - items.map( - (award) => Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: _buildAwardCard(context, award, compact: true), - ), - ), - ); - } - - if (isClass) { - children.addAll( - _buildClassProgressSection( - context, - classItems, - classProgressLoading, - classProgressHasMore, - ), - ); - } - if (isLoco) { - children.addAll( - _buildLocoProgressSection( - context, - locoItems, - locoProgressLoading, - locoProgressHasMore, - showHeading: false, - ), - ); - } - - if (children.isEmpty && !isLoadingSection) { - children.add( - const Padding( - padding: EdgeInsets.symmetric(vertical: 6.0), - child: Text('No awards'), - ), - ); - } - - return Card( - margin: const EdgeInsets.symmetric(vertical: 4.0), - child: ExpansionTile( - key: ValueKey(code), - tilePadding: const EdgeInsets.symmetric(horizontal: 12.0), - title: Row( - children: [ - Expanded(child: Text(title)), - if (isLoadingSection) ...[ - const SizedBox(width: 8), - const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - const SizedBox(width: 8), - _buildCountChip(context, awardCount), - ], - ), - initiallyExpanded: expanded, - onExpansionChanged: (isOpen) { - setState(() => _groupExpanded[code] = isOpen); - }, - children: children, - ), - ); - }).toList(); - } - - Map> _groupAwards(List awards) { - final Map> grouped = {}; - for (final award in awards) { - final code = award.badgeCode.toLowerCase(); - grouped.putIfAbsent(code, () => []).add(award); - } - return grouped; - } - - Widget _buildAwardCard( - BuildContext context, - BadgeAward award, { - bool compact = false, + Widget _buildUserCard({ + required String name, + required String username, + required String email, }) { - final badgeName = _formatBadgeName(award.badgeCode); - final tier = award.badgeTier.isNotEmpty - ? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1) - : ''; - final tierIcon = _buildTierIcon(award.badgeTier); - final scope = _scopeToShow(award); - - final content = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Card( + child: ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(name.isNotEmpty ? name : 'You'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (tierIcon != null) ...[ - tierIcon, - const SizedBox(width: 8), - ], - Expanded( - child: Text( - '$badgeName • $tier', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + if (username.isNotEmpty) Text('@$username'), + if (email.isNotEmpty) Text(email), + ], + ), + ), + ); + } + + Widget _buildSearchSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Find a user', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + labelText: 'Search by username, email, or name', + border: OutlineInputBorder(), ), - ), + onSubmitted: (_) => _searchUsers(), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _searching ? null : _searchUsers, + child: + _searching ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Text('Search'), + ), + ], ), - if (award.awardedAt != null) - Text( - _formatAwardDate(award.awardedAt!), - style: Theme.of(context).textTheme.bodySmall, + if (_searchError != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _searchError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (_searchResults.isNotEmpty) const SizedBox(height: 12), + if (_searchResults.isNotEmpty) + ..._searchResults.map( + (user) => ListTile( + leading: const Icon(Icons.person), + title: Text(user.displayName), + subtitle: + user.username.isNotEmpty ? Text('@${user.username}') : null, + trailing: TextButton( + onPressed: () => _loadStatus(user), + child: const Text('View'), + ), + ), ), ], ), - if (scope != null && scope.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - scope, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - if (award.loco != null) ...[ - const SizedBox(height: 6), - _buildLocoInfo(context, award.loco!), - ], - ], + ), ); + } - if (compact) { - return content; - } + Widget _buildSelectedUserSection(AuthService auth) { + final user = _selectedUser; + if (user == null) return const SizedBox.shrink(); + final status = _status; + final loading = _statusLoading; + final isSelf = auth.userId == user.userId; return Card( child: Padding( - padding: const EdgeInsets.all(10.0), - child: content, - ), - ); - } - - Widget _buildLocoInfo(BuildContext context, LocoSummary loco) { - final lines = []; - final classNum = [ - if (loco.locoClass.isNotEmpty) loco.locoClass, - if (loco.number.isNotEmpty) loco.number, - ].join(' '); - if (classNum.isNotEmpty) lines.add(classNum); - if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!); - if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!); - if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.train, size: 20), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: lines.map((line) { - return Text( - line, - style: Theme.of(context).textTheme.bodyMedium, - ); - }).toList(), - ), - ), - ], - ); - } - - String _formatBadgeName(String code) { - if (code.isEmpty) return 'Badge'; - const known = { - 'class_clearance': 'Class Clearance', - 'loco_clearance': 'Loco Clearance', - }; - final lower = code.toLowerCase(); - if (known.containsKey(lower)) return known[lower]!; - final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty); - return parts - .map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase()) - .join(' '); - } - - List _orderedBadgeCodes(List codes) { - final lowerCodes = codes.map((c) => c.toLowerCase()).toSet(); - final ordered = []; - for (final code in ['loco_clearance', 'class_clearance']) { - if (lowerCodes.remove(code)) ordered.add(code); - } - final remaining = lowerCodes.toList() - ..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b))); - ordered.addAll(remaining); - return ordered; - } - - Widget _buildSubheading(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Text( - label, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - ); - } - - List _buildClassProgressSection( - BuildContext context, - List progress, - bool isLoading, - bool hasMore, - ) { - if (progress.isEmpty && !isLoading && !hasMore) return const []; - return [ - _buildSubheading(context, 'In Progress'), - ...progress.map( - (item) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), - child: _buildClassProgressCard(context, item), - ), - ), - if (hasMore || isLoading) - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), - child: _buildLoadMoreButton( - context, - isLoading, - () => _loadMoreClassProgress(), - ), - ), - if (progress.isNotEmpty) const SizedBox(height: 4), - ]; - } - - List _buildLocoProgressSection( - BuildContext context, - List progress, - bool isLoading, - bool hasMore, - {bool showHeading = true} - ) { - if (progress.isEmpty && !isLoading && !hasMore) return const []; - return [ - if (showHeading) _buildSubheading(context, 'In Progress'), - if (progress.isEmpty && isLoading) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: _buildLoadingIndicator(), - ), - ...progress.map( - (item) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - child: _buildLocoProgressCard(context, item), - ), - ), - if (hasMore || isLoading) - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), - child: _buildLoadMoreButton( - context, - isLoading, - () => _loadMoreLocoProgress(), - ), - ), - if (progress.isNotEmpty) const SizedBox(height: 4), - ]; - } - - Widget _buildClassProgressCard( - BuildContext context, - ClassClearanceProgress progress, - ) { - final pct = progress.percentComplete.clamp(0, 100); - return Card( - margin: const EdgeInsets.symmetric(vertical: 4.0), - child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Expanded( - child: Text( - progress.className, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), Text( - '${pct.toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.labelMedium, + user.displayName, + style: Theme.of(context).textTheme.titleMedium, ), + const SizedBox(width: 8), + if (loading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + if (!loading && status != null) _buildStatusChip(status, auth), ], ), - const SizedBox(height: 4), - LinearProgressIndicator( - value: progress.total == 0 ? 0 : pct / 100, - minHeight: 6, - ), - if (progress.total > 0) - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - '${progress.completed}/${progress.total}', - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith(color: Theme.of(context).hintColor), - ), - ), + if (user.username.isNotEmpty) Text('@${user.username}'), + const SizedBox(height: 8), + if (!isSelf) _buildActions(status, user, auth), + if (isSelf) + const Text('This is you.', style: TextStyle(fontStyle: FontStyle.italic)), ], ), ), ); } - Widget _buildLocoProgressCard( - BuildContext context, - LocoClearanceProgress progress, - ) { - final tierIcons = progress.awardedTiers - .map((tier) => _buildTierIcon(tier, size: 18)) - .whereType() - .toList(); - final reachedTopTier = progress.nextTier.isEmpty; - final pct = progress.percent.clamp(0, 100); - final nextTier = progress.nextTier.isNotEmpty - ? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1) - : 'Next'; - final loco = progress.loco; - final title = [ - if (loco.number.isNotEmpty) loco.number, - if (loco.locoClass.isNotEmpty) loco.locoClass, - ].join(' • '); - return Card( - margin: const EdgeInsets.symmetric(vertical: 4.0), - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - title.isNotEmpty ? title : 'Loco', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - if (tierIcons.isNotEmpty) - Row( - children: tierIcons - .expand((icon) sync* { - yield icon; - yield const SizedBox(width: 4); - }) - .toList() - ..removeLast(), - ), - ], - ), - if ((loco.name ?? '').isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - loco.name ?? '', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - if (!reachedTopTier) ...[ - const SizedBox(height: 4), - LinearProgressIndicator( - value: progress.required == 0 ? 0 : pct / 100, - minHeight: 6, - ), - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - '${pct.toStringAsFixed(0)}% to $nextTier award', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ], - ), - ), + Widget _buildStatusChip(Friendship status, AuthService auth) { + String label = status.status; + Color color = Colors.grey; + switch (status.status.toLowerCase()) { + case 'accepted': + label = 'Friends'; + color = Colors.green; + break; + case 'pending': + final isRequester = status.requesterId == auth.userId; + label = isRequester ? 'Pending (you sent)' : 'Pending (waiting on you)'; + color = Colors.orange; + break; + case 'blocked': + color = Colors.red; + label = 'Blocked'; + break; + case 'declined': + case 'rejected': + label = 'Declined'; + break; + default: + label = 'Not friends'; + } + final bg = Color.alphaBlend( + color.withValues(alpha: 0.15), + Theme.of(context).colorScheme.surface, ); - } - - Widget _buildLoadMoreButton( - BuildContext context, - bool isLoading, - Future Function() onPressed, - ) { - return Align( - alignment: Alignment.center, - child: OutlinedButton.icon( - onPressed: isLoading - ? null - : () { - onPressed(); - }, - icon: isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.expand_more), - label: Text(isLoading ? 'Loading...' : 'Load more'), - ), - ); - } - - Widget _buildLoadingIndicator() { - return const Center( - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - - Widget _buildCountChip(BuildContext context, int count) { return Container( + margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - '$count', - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(fontWeight: FontWeight.w700), + color: bg, + borderRadius: BorderRadius.circular(20), ), + child: Text(label), ); } - Future _loadMoreClassProgress() { - final data = context.read(); - if (data.isClassClearanceProgressLoading || _loadingClassProgress) { - return Future.value(); + Widget _buildActions( + Friendship? status, + UserSummary user, + AuthService auth, + ) { + if (status == null) { + return const SizedBox.shrink(); } - setState(() => _loadingClassProgress = true); - return data - .fetchClassClearanceProgress( - offset: data.classClearanceProgress.length, - append: true, - ) - .whenComplete(() { - if (mounted) setState(() => _loadingClassProgress = false); - }); - } + final isRequester = status.requesterId == auth.userId; + final id = status.id; + final buttons = []; - Future _loadMoreLocoProgress() { - final data = context.read(); - if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) { - return Future.value(); + if (status.isNone || status.isDeclined) { + buttons.add( + ElevatedButton.icon( + onPressed: _actionLoading ? null : () => _sendRequest(user), + icon: const Icon(Icons.person_add), + label: const Text('Send friend request'), + ), + ); + } else if (status.isPending) { + if (isRequester) { + buttons.add( + OutlinedButton( + onPressed: _actionLoading || id == null || id.isEmpty + ? null + : () => _cancelRequest(status), + child: const Text('Cancel request'), + ), + ); + } else { + buttons.add( + ElevatedButton( + onPressed: _actionLoading || id == null || id.isEmpty + ? null + : () => _accept(status), + child: const Text('Accept'), + ), + ); + buttons.add( + OutlinedButton( + onPressed: _actionLoading || id == null || id.isEmpty + ? null + : () => _reject(status), + child: const Text('Reject'), + ), + ); + } + } else if (status.isAccepted) { + buttons.add( + ElevatedButton.icon( + onPressed: _actionLoading || id == null || id.isEmpty + ? null + : () => _unfriend(status), + icon: const Icon(Icons.person_remove), + label: const Text('Unfriend'), + ), + ); + // Block action temporarily removed until backend support exists. + } else if (status.isBlocked) { + buttons.add(const Text('User is blocked.')); } - setState(() => _loadingLocoProgress = true); - return data - .fetchLocoClearanceProgress( - offset: data.locoClearanceProgress.length, - append: true, - ) - .whenComplete(() { - if (mounted) setState(() => _loadingLocoProgress = false); - }); + + if (buttons.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: buttons, + ); } - Future _loadMoreAwards() { - final data = context.read(); - if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value(); - setState(() => _loadingAwards = true); - return data - .fetchBadgeAwards( - offset: data.badgeAwards.length, - append: true, - badgeCode: 'class_clearance', - limit: 20, - ) - .whenComplete(() { - if (mounted) setState(() => _loadingAwards = false); - }); + Widget _buildFriendsList(AuthService auth) { + final data = context.watch(); + final friends = data.friendships; + final incoming = data.pendingIncoming; + final outgoing = data.pendingOutgoing; + final loading = data.isFriendshipsLoading; + final pendingLoading = data.isPendingFriendshipsLoading; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pendingLoading && incoming.isEmpty && outgoing.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Center(child: CircularProgressIndicator()), + ), + if (incoming.isNotEmpty || outgoing.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pending requests', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (outgoing.isNotEmpty) + ...outgoing.map((f) { + final otherUser = _otherUser(f, auth.userId); + return Card( + child: ListTile( + leading: const Icon(Icons.person), + title: Text(otherUser?.displayName ?? 'User'), + subtitle: otherUser?.username.isNotEmpty == true + ? Text('@${otherUser!.username}') + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.north_east, size: 18), + const SizedBox(width: 6), + TextButton( + onPressed: f.id == null || _actionLoading + ? null + : () => _cancelRequest(f), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + }), + if (incoming.isNotEmpty && outgoing.isNotEmpty) + const SizedBox(height: 8), + if (incoming.isNotEmpty) + ...incoming.map((f) { + final otherUser = _otherUser(f, auth.userId); + return Card( + child: ListTile( + leading: const Icon(Icons.person), + title: Text(otherUser?.displayName ?? 'User'), + subtitle: otherUser?.username.isNotEmpty == true + ? Text('@${otherUser!.username}') + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.south_west, size: 18), + const SizedBox(width: 6), + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: f.id == null || _actionLoading + ? null + : () => _accept(f), + child: const Text('Accept'), + ), + TextButton( + onPressed: f.id == null || _actionLoading + ? null + : () => _reject(f), + child: const Text('Reject'), + ), + ], + ), + ], + ), + ), + ); + }), + const SizedBox(height: 12), + ], + ), + Text( + 'Friends', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (loading && friends.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Center(child: CircularProgressIndicator()), + ) + else if (friends.isEmpty) + const Text('No friends yet.') + else + ...friends.map((f) { + final otherUser = _otherUser(f, auth.userId); + return Card( + child: ListTile( + leading: const Icon(Icons.person), + title: Text(otherUser?.displayName ?? 'User'), + subtitle: otherUser?.username.isNotEmpty == true + ? Text('@${otherUser!.username}') + : null, + trailing: TextButton( + onPressed: () { + final user = otherUser; + if (user != null) { + _loadStatus(user); + } + }, + child: const Text('Manage'), + ), + ), + ); + }), + ], + ); } - String _formatAwardDate(DateTime date) { - final y = date.year.toString().padLeft(4, '0'); - final m = date.month.toString().padLeft(2, '0'); - final d = date.day.toString().padLeft(2, '0'); - return '$y-$m-$d'; - } - - Widget? _buildTierIcon(String tier, {double size = 24}) { - final lower = tier.toLowerCase(); - Color? color; - switch (lower) { - case 'bronze': - color = const Color(0xFFCD7F32); - break; - case 'silver': - color = const Color(0xFFC0C0C0); - break; - case 'gold': - color = const Color(0xFFFFD700); - break; + UserSummary? _otherUser(Friendship friendship, String? currentUserId) { + final selfId = currentUserId ?? ''; + if (friendship.requester?.userId == selfId) return friendship.addressee; + if (friendship.addressee?.userId == selfId) return friendship.requester; + if (friendship.addresseeId == selfId && friendship.requester != null) { + return friendship.requester; } - if (color == null) return null; - return Icon(Icons.emoji_events, color: color, size: size); - } - - String? _scopeToShow(BadgeAward award) { - final scope = award.scopeValue?.trim() ?? ''; - if (scope.isEmpty) return null; - final code = award.badgeCode.toLowerCase(); - if (code == 'loco_clearance') { - // Hide numeric loco IDs; loco details are shown separately. - if (int.tryParse(scope) != null) return null; + if (friendship.requesterId == selfId && friendship.addressee != null) { + return friendship.addressee; } - return scope; + if (friendship.addressee != null) return friendship.addressee; + if (friendship.requester != null) return friendship.requester; + return null; } } diff --git a/lib/components/widgets/friend_request_notification_card.dart b/lib/components/widgets/friend_request_notification_card.dart new file mode 100644 index 0000000..d623090 --- /dev/null +++ b/lib/components/widgets/friend_request_notification_card.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class FriendRequestNotificationCard extends StatelessWidget { + const FriendRequestNotificationCard({super.key, required this.notification}); + + final UserNotification notification; + + @override + Widget build(BuildContext context) { + final data = context.read(); + final friendshipId = notification.body.trim(); + if (friendshipId.isEmpty) { + return const Text('Invalid friend request notification.'); + } + final future = data.fetchFriendshipById(friendshipId); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + if (snapshot.hasError) { + return const Text('Failed to load request details.'); + } + final friendship = snapshot.data; + final requester = friendship?.requester; + if (friendship == null || requester == null) { + return const Text('Friend request details unavailable.'); + } + final buttonStyle = ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.person_add_alt, size: 18), + const SizedBox(width: 6), + Text( + requester.displayName.isNotEmpty + ? requester.displayName + : '@${requester.username}', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + style: buttonStyle, + onPressed: () => + _respond(context, friendship.id, accept: true), + child: const Text('Accept'), + ), + ElevatedButton( + style: buttonStyle, + onPressed: () => + _respond(context, friendship.id, accept: false), + child: const Text('Reject'), + ), + ], + ), + ], + ); + }, + ); + } + + Future _respond( + BuildContext context, + String? friendshipId, { + required bool accept, + }) async { + if (friendshipId == null || friendshipId.isEmpty) return; + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + if (accept) { + await data.acceptFriendship(friendshipId); + if (!context.mounted) return; + messenger?.showSnackBar( + const SnackBar(content: Text('Friend request accepted')), + ); + await _dismissNotification(context, messenger); + } else { + await data.rejectFriendship(friendshipId); + if (!context.mounted) return; + messenger?.showSnackBar( + const SnackBar(content: Text('Friend request rejected')), + ); + await _dismissNotification(context, messenger); + } + await data.fetchPendingFriendships(); + } catch (e) { + if (!context.mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Failed to respond: $e')), + ); + } + } + + Future _dismissNotification( + BuildContext context, + ScaffoldMessengerState? messenger, + ) async { + try { + await context.read().dismissNotifications([notification.id]); + } catch (e) { + messenger?.showSnackBar( + SnackBar(content: Text('Failed to dismiss notification: $e')), + ); + } + } +} diff --git a/lib/components/widgets/leg_share_notification_card.dart b/lib/components/widgets/leg_share_notification_card.dart new file mode 100644 index 0000000..5e13fe3 --- /dev/null +++ b/lib/components/widgets/leg_share_notification_card.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class LegShareNotificationCard extends StatelessWidget { + const LegShareNotificationCard({super.key, required this.notification}); + + final UserNotification notification; + + @override + Widget build(BuildContext context) { + final data = context.read(); + final legShareId = notification.body.trim(); + if (legShareId.isEmpty) { + return const Text('Invalid leg share notification.'); + } + final future = data.fetchLegShare(legShareId); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + if (snapshot.hasError) { + return const Text('Failed to load shared entry.'); + } + final share = snapshot.data; + final entry = share?.entry; + if (share == null || entry == null) { + return const Text('Shared entry unavailable.'); + } + final begin = entry.beginTime; + final beginStr = + '${begin.year.toString().padLeft(4, '0')}-${begin.month.toString().padLeft(2, '0')}-${begin.day.toString().padLeft(2, '0')} ${begin.hour.toString().padLeft(2, '0')}:${begin.minute.toString().padLeft(2, '0')}'; + final start = entry.route.isNotEmpty ? entry.route.first : entry.start; + final end = entry.route.isNotEmpty ? entry.route.last : entry.end; + final sharedAt = share.sharedAt; + final sharedAtStr = sharedAt == null + ? null + : '${sharedAt.year.toString().padLeft(4, '0')}-${sharedAt.month.toString().padLeft(2, '0')}-${sharedAt.day.toString().padLeft(2, '0')} ${sharedAt.hour.toString().padLeft(2, '0')}:${sharedAt.minute.toString().padLeft(2, '0')}'; + final from = share.sharedFromName; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$beginStr • $start → $end'), + if (from.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'From $from', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (sharedAtStr != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'Shared at $sharedAtStr', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: () => _accept(context, share), + child: const Text('Accept'), + ), + OutlinedButton( + onPressed: () => _inspect(context, share), + child: const Text('Inspect'), + ), + TextButton( + onPressed: () => _reject(context, share), + child: const Text('Reject'), + ), + ], + ), + ], + ); + }, + ); + } + + Future _accept(BuildContext context, LegShareData share) async { + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + await data.acceptLegShare(share); + if (!context.mounted) return; + await data.dismissNotifications([notification.id]); + // Refresh legs in the background. + unawaited(data.refreshLegs()); + messenger?.showSnackBar( + const SnackBar(content: Text('Shared entry added to logbook')), + ); + } catch (e) { + messenger?.showSnackBar( + SnackBar(content: Text('Failed to add shared entry: $e')), + ); + } + } + + Future _reject(BuildContext context, LegShareData share) async { + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + await data.rejectLegShare(share.id); + if (!context.mounted) return; + await data.dismissNotifications([notification.id]); + messenger?.showSnackBar( + const SnackBar(content: Text('Share rejected')), + ); + } catch (e) { + messenger?.showSnackBar( + SnackBar(content: Text('Failed to reject share: $e')), + ); + } + } + + Future _inspect(BuildContext context, LegShareData share) async { + final router = GoRouter.of(context); + // Close notifications panel if open. + if (Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + await Future.delayed(Duration.zero); + final target = share.copyWith(notificationId: notification.id); + final ts = DateTime.now().millisecondsSinceEpoch; + final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts'; + router.go(path, extra: target); + } +} diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 17d9442..dd24782 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -164,6 +164,187 @@ class UserSummary extends UserData { ); } +class Friendship { + final String? id; + final String status; + final String requesterId; + final String addresseeId; + final UserSummary? requester; + final UserSummary? addressee; + final DateTime? requestedAt; + final DateTime? respondedAt; + + const Friendship({ + required this.id, + required this.status, + required this.requesterId, + required this.addresseeId, + this.requester, + this.addressee, + this.requestedAt, + this.respondedAt, + }); + + String get _statusLower => status.toLowerCase(); + bool get isNone => _statusLower == 'none'; + bool get isPending => _statusLower == 'pending'; + bool get isAccepted => _statusLower == 'accepted'; + bool get isBlocked => _statusLower == 'blocked'; + bool get isDeclined => + _statusLower == 'declined' || _statusLower == 'rejected'; + + factory Friendship.fromJson(Map json) { + DateTime? parseDate(dynamic value) { + if (value is DateTime) return value; + return DateTime.tryParse(value?.toString() ?? ''); + } + + String pickId(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value == null) continue; + final str = value.toString(); + if (str.isNotEmpty) return str; + } + return ''; + } + + UserSummary? parseUser(dynamic raw, {String? usernameKey, String? fullKey, String? idKey}) { + if (raw is Map) { + return UserSummary.fromJson( + raw.map((key, value) => MapEntry(key.toString(), value)), + ); + } + final username = usernameKey != null ? _asString(json[usernameKey]) : ''; + final fullName = fullKey != null ? _asString(json[fullKey]) : ''; + final id = idKey != null ? _asString(json[idKey]) : ''; + if (username.isEmpty && fullName.isEmpty && id.isEmpty) return null; + return UserSummary( + username: username, + fullName: fullName, + userId: id, + email: '', + ); + } + + final requesterJson = json['requester'] ?? + json['requestor'] ?? + json['sender'] ?? + json['from_user']; + final addresseeJson = json['addressee'] ?? + json['receiver'] ?? + json['target_user'] ?? + json['to_user']; + + final requester = parseUser( + requesterJson, + usernameKey: 'requester_username', + fullKey: 'requester_full_name', + idKey: 'requester_id', + ); + final addressee = parseUser( + addresseeJson, + usernameKey: 'addressee_username', + fullKey: 'addressee_full_name', + idKey: 'addressee_id', + ); + + final requesterUsername = _asString(json['requester_username']); + final requesterFullName = _asString(json['requester_full_name']); + final addresseeUsername = _asString(json['addressee_username']); + final addresseeFullName = _asString(json['addressee_full_name']); + + final requesterId = requester?.userId.isNotEmpty == true + ? requester!.userId + : pickId(json, [ + 'requester_id', + 'requestor_id', + 'requestorId', + 'sender_id', + 'from_user_id', + 'from_id', + 'user_id', + ]); + final addresseeId = addressee?.userId.isNotEmpty == true + ? addressee!.userId + : pickId(json, [ + 'addressee_id', + 'addresseeId', + 'receiver_id', + 'target_user_id', + 'to_user_id', + 'to_id', + ]); + + final normalizedStatus = _asString(json['status'], 'none').trim(); + + return Friendship( + id: pickId(json, ['friendship_id', 'friendshipId', 'id']), + status: normalizedStatus.isNotEmpty ? normalizedStatus : 'none', + requesterId: requesterId, + addresseeId: addresseeId, + requester: requester ?? + (requesterUsername.isNotEmpty || requesterFullName.isNotEmpty + ? UserSummary( + username: requesterUsername, + fullName: requesterFullName, + userId: requesterId, + email: '', + ) + : null), + addressee: addressee ?? + (addresseeUsername.isNotEmpty || addresseeFullName.isNotEmpty + ? UserSummary( + username: addresseeUsername, + fullName: addresseeFullName, + userId: addresseeId, + email: '', + ) + : null), + requestedAt: parseDate(json['requested_at'] ?? json['requestedAt']), + respondedAt: parseDate(json['responded_at'] ?? json['respondedAt']), + ); + } + + factory Friendship.fromStatusJson( + Map json, { + String? targetUserId, + }) { + final statusVal = _asString(json['status'], 'none').trim(); + if (statusVal.toLowerCase() == 'none') { + return Friendship( + id: null, + status: 'none', + requesterId: '', + addresseeId: targetUserId ?? '', + ); + } + return Friendship.fromJson(json); + } + + Friendship copyWith({ + String? id, + String? status, + String? requesterId, + String? addresseeId, + UserSummary? requester, + UserSummary? addressee, + DateTime? requestedAt, + DateTime? respondedAt, + }) { + return Friendship( + id: id ?? this.id, + status: status ?? this.status, + requesterId: requesterId ?? this.requesterId, + addresseeId: addresseeId ?? this.addresseeId, + requester: requester ?? this.requester, + addressee: addressee ?? this.addressee, + requestedAt: requestedAt ?? this.requestedAt, + respondedAt: respondedAt ?? this.respondedAt, + ); + } +} + class HomepageStats { final double totalMileage; final List yearlyMileage; @@ -441,6 +622,7 @@ class Loco { final String type, number, locoClass; final String? name, operator, notes, evn; final bool powering; + final int allocPos; Loco({ required this.id, @@ -452,6 +634,7 @@ class Loco { this.notes, this.evn, this.powering = true, + this.allocPos = 0, }); factory Loco.fromJson(Map json) => Loco( @@ -464,6 +647,7 @@ class Loco { notes: json['notes'], evn: json['evn'], powering: _asBool(json['alloc_powering'] ?? json['powering'], true), + allocPos: _asInt(json['alloc_pos'], 0), ); } @@ -497,6 +681,7 @@ class LocoSummary extends Loco { this.location, Map? extra, super.powering = true, + super.allocPos = 0, }) : extra = extra ?? const {}, super( id: locoId, @@ -532,6 +717,7 @@ class LocoSummary extends Loco { location: json['location'], extra: Map.from(json), powering: _asBool(json['alloc_powering'] ?? json['powering'], true), + allocPos: _asInt(json['alloc_pos'], 0), ); } @@ -874,6 +1060,7 @@ class Leg { final String start, end, network, notes, headcode, user; final String origin, destination; final List route; + final String? legShareId; final DateTime beginTime; final DateTime? endTime; final DateTime? originTime; @@ -904,6 +1091,7 @@ class Leg { this.endDelayMinutes, this.origin = '', this.destination = '', + this.legShareId, }); factory Leg.fromJson(Map json) { @@ -944,6 +1132,93 @@ class Leg { : _asInt(json['leg_end_delay']), origin: _asString(json['leg_origin']), destination: _asString(json['leg_destination']), + legShareId: _asString(json['leg_share_id']), + ); + } +} + +class LegShareData { + final String id; + final Leg entry; + final Map metadata; + final UserSummary? requester; + final UserSummary? addressee; + final DateTime? sharedAt; + final String status; + final int? notificationId; + + LegShareData({ + required this.id, + required this.entry, + required this.metadata, + this.requester, + this.addressee, + this.sharedAt, + this.status = 'pending', + this.notificationId, + }); + + LegShareData copyWith({int? notificationId}) { + return LegShareData( + id: id, + entry: entry, + metadata: metadata, + requester: requester, + addressee: addressee, + sharedAt: sharedAt, + status: status, + notificationId: notificationId ?? this.notificationId, + ); + } + + String get sharedFromName => + requester?.displayName.isNotEmpty == true ? requester!.displayName : ''; + + factory LegShareData.fromJson(Map json) { + final metadataRaw = json['metadata']; + final entryRaw = json['entry']; + final metadata = metadataRaw is Map + ? metadataRaw.map((k, v) => MapEntry(k.toString(), v)) + : {}; + final shareId = _asString( + metadata['leg_share_id'] ?? metadata['share_id'] ?? json['leg_share_id'], + ); + final requester = UserSummary.fromJson({ + "user_id": metadata['requester_id'] ?? metadata['from_user_id'] ?? '', + "username": metadata['requester_username'] ?? '', + "full_name": metadata['requester_full_name'] ?? '', + "email": '', + }); + final addressee = UserSummary.fromJson({ + "user_id": metadata['addressee_id'] ?? metadata['target_user_id'] ?? '', + "username": metadata['addressee_username'] ?? '', + "full_name": metadata['addressee_full_name'] ?? '', + "email": '', + }); + DateTime? sharedAt; + for (final key in ['requested_at', 'created_at', 'shared_at']) { + final value = metadata[key]; + if (value != null) { + sharedAt = DateTime.tryParse(value.toString()); + if (sharedAt != null) break; + } + } + final entryMap = entryRaw is Map + ? entryRaw.map((k, v) => MapEntry(k.toString(), v)) + : json.map((k, v) => MapEntry(k.toString(), v)); + final entryObj = Leg.fromJson(entryMap); + final resolvedId = shareId.isNotEmpty + ? shareId + : entryObj.legShareId ?? _asString(json['leg_share_id']); + + return LegShareData( + id: resolvedId, + entry: entryObj, + metadata: metadata, + requester: requester.userId.isEmpty ? null : requester, + addressee: addressee.userId.isEmpty ? null : addressee, + sharedAt: sharedAt, + status: _asString(metadata['status'], 'pending'), ); } } @@ -1223,6 +1498,7 @@ class UserNotification { final String title; final String body; final String channel; + final String type; final DateTime? createdAt; final bool dismissed; @@ -1231,6 +1507,7 @@ class UserNotification { required this.title, required this.body, required this.channel, + required this.type, required this.createdAt, required this.dismissed, }); @@ -1248,6 +1525,7 @@ class UserNotification { title: _asString(json['title']), body: _asString(json['body']), channel: _asString(json['channel']), + type: _asString(json['type'] ?? json['notification_type']), createdAt: createdAt, dismissed: _asBool(json['dismissed'] ?? false, false), ); diff --git a/lib/services/data_service/data_service.dart b/lib/services/data_service/data_service.dart index b298da1..1b6acfb 100644 --- a/lib/services/data_service/data_service.dart +++ b/lib/services/data_service/data_service.dart @@ -12,3 +12,5 @@ part 'data_service_trips.dart'; part 'data_service_notifications.dart'; part 'data_service_badges.dart'; part 'data_service_stats.dart'; +part 'data_service_friendships.dart'; +part 'data_service_leg_share.dart'; diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index b171419..856b864 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -64,6 +64,18 @@ class DataService extends ChangeNotifier { bool isLocoTimelineLoading(int locoId) => _isLocoTimelineLoading[locoId] ?? false; + // Friendships + List _friendships = []; + List _pendingIncoming = []; + List _pendingOutgoing = []; + List get friendships => _friendships; + List get pendingIncoming => _pendingIncoming; + List get pendingOutgoing => _pendingOutgoing; + bool _isFriendshipsLoading = false; + bool get isFriendshipsLoading => _isFriendshipsLoading; + bool _isPendingFriendshipsLoading = false; + bool get isPendingFriendshipsLoading => _isPendingFriendshipsLoading; + // Trips List _trips = []; List get trips => _trips; diff --git a/lib/services/data_service/data_service_friendships.dart b/lib/services/data_service/data_service_friendships.dart new file mode 100644 index 0000000..2183e26 --- /dev/null +++ b/lib/services/data_service/data_service_friendships.dart @@ -0,0 +1,282 @@ +part of 'data_service.dart'; + +extension DataServiceFriendships on DataService { + Future fetchFriendships() async { + _isFriendshipsLoading = true; + try { + final json = await api.get('/friendships'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['friendships', 'data', 'items', 'results']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + final parsed = list + ?.whereType() + .map((e) => Friendship.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .where((f) => f.isAccepted) + .toList(); + _friendships = parsed ?? []; + } catch (e) { + debugPrint('Failed to fetch friendships: $e'); + _friendships = []; + } finally { + _isFriendshipsLoading = false; + _notifyAsync(); + } + } + + Future fetchPendingFriendships() async { + _isPendingFriendshipsLoading = true; + try { + final incoming = await _fetchPending('incoming'); + final outgoing = await _fetchPending('outgoing'); + _pendingIncoming = incoming; + _pendingOutgoing = outgoing; + } catch (e) { + debugPrint('Failed to fetch pending friendships: $e'); + _pendingIncoming = []; + _pendingOutgoing = []; + } finally { + _isPendingFriendshipsLoading = false; + _notifyAsync(); + } + } + + Future> _fetchPending(String direction) async { + final json = await api.get('/friendships/pending?direction=$direction'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['friendships', 'data', 'items', 'results']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + return list + ?.whereType() + .map((e) => Friendship.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .where((f) => f.isPending) + .toList() ?? + const []; + } + + Future fetchFriendshipById(String friendshipId) async { + try { + final json = await api.get('/friendships/$friendshipId'); + if (json is Map) { + return Friendship.fromJson( + json.map((k, v) => MapEntry(k.toString(), v)), + ); + } + } catch (e) { + debugPrint('Failed to fetch friendship $friendshipId: $e'); + } + return null; + } + + Future> searchUsers( + String query, { + bool friendsOnly = false, + }) async { + final encoded = Uri.encodeComponent(query); + final json = await api.get( + '/users/search?search=$encoded${friendsOnly ? '&friends_only=true' : ''}', + ); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['users', 'data', 'results', 'items']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + if (list == null) return const []; + return list + .whereType() + .map((e) => UserSummary.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(); + } + + Future fetchFriendshipStatus(String targetUserId) async { + try { + final json = await api.get('/friendships/status/$targetUserId'); + if (json is Map) { + return Friendship.fromStatusJson( + json.map((k, v) => MapEntry(k.toString(), v)), + targetUserId: targetUserId, + ); + } + } catch (e) { + debugPrint('Failed to fetch friendship status for $targetUserId: $e'); + } + return Friendship( + id: null, + status: 'none', + requesterId: '', + addresseeId: targetUserId, + ); + } + + Future requestFriendship( + String targetUserId, { + UserSummary? targetUser, + }) async { + final json = await api.post('/friendships/request', { + "target_user_id": targetUserId, + }); + final friendship = _parseAndUpsertFriendship( + json, + fallbackStatus: 'pending', + overrideAddressee: targetUser, + ); + _pendingOutgoing = [friendship, ..._pendingOutgoing]; + _notifyAsync(); + return friendship; + } + + Future acceptFriendship(String friendshipId) async { + final json = await api.post('/friendships/$friendshipId/accept', {}); + final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted'); + _pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList(); + _notifyAsync(); + return friendship; + } + + Future rejectFriendship(String friendshipId) async { + final json = await api.post('/friendships/$friendshipId/reject', {}); + final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'declined'); + _pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList(); + _notifyAsync(); + return friendship; + } + + Future cancelFriendship(String friendshipId) async { + final json = await api.post('/friendships/$friendshipId/cancel', {}); + final friendship = + _parseAndRemoveFriendship(json, friendshipId, status: 'none'); + _pendingOutgoing = + _pendingOutgoing.where((f) => f.id != friendshipId).toList(); + _notifyAsync(); + return friendship; + } + + Future deleteFriendship(String friendshipId) async { + try { + await api.delete('/friendships/$friendshipId'); + } catch (e) { + debugPrint('Failed to delete friendship $friendshipId: $e'); + rethrow; + } + _friendships = _friendships.where((f) => f.id != friendshipId).toList(); + _pendingIncoming = + _pendingIncoming.where((f) => f.id != friendshipId).toList(); + _pendingOutgoing = + _pendingOutgoing.where((f) => f.id != friendshipId).toList(); + _notifyAsync(); + } + + Future blockUser(String targetUserId) async { + final json = await api.post('/friendships/block', { + "target_user_id": targetUserId, + }); + final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'blocked'); + _pendingIncoming = _pendingIncoming + .where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId) + .toList(); + _pendingOutgoing = _pendingOutgoing + .where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId) + .toList(); + _notifyAsync(); + return friendship; + } + + Friendship _parseAndUpsertFriendship( + dynamic json, { + String fallbackStatus = 'none', + UserSummary? overrideAddressee, + }) { + Friendship friendship; + if (json is Map) { + friendship = Friendship.fromJson( + json.map((k, v) => MapEntry(k.toString(), v)), + ); + } else { + friendship = Friendship( + id: null, + status: fallbackStatus, + requesterId: '', + addresseeId: '', + ); + } + if (overrideAddressee != null && + (friendship.addressee == null || + friendship.addressee!.userId.isEmpty || + friendship.addressee!.displayName.isEmpty)) { + friendship = friendship.copyWith( + addressee: overrideAddressee, + addresseeId: overrideAddressee.userId, + ); + } + _upsertFriendship(friendship); + return friendship; + } + + Friendship _parseAndRemoveFriendship( + dynamic json, + String friendshipId, { + required String status, + }) { + Friendship friendship; + if (json is Map) { + friendship = Friendship.fromJson( + json.map((k, v) => MapEntry(k.toString(), v)), + ); + } else { + friendship = Friendship( + id: friendshipId, + status: status, + requesterId: '', + addresseeId: '', + ); + } + _friendships = _friendships.where((f) => f.id != friendshipId).toList(); + _notifyAsync(); + return friendship; + } + + void _upsertFriendship(Friendship friendship) { + if (friendship.id == null || friendship.id!.isEmpty) { + _notifyAsync(); + return; + } + final idx = _friendships.indexWhere((f) => f.id == friendship.id); + if (idx >= 0) { + _friendships[idx] = friendship; + } else { + _friendships = [friendship, ..._friendships]; + } + _friendships = _friendships.where((f) => f.isAccepted).toList(); + _notifyAsync(); + } +} diff --git a/lib/services/data_service/data_service_leg_share.dart b/lib/services/data_service/data_service_leg_share.dart new file mode 100644 index 0000000..e55fddd --- /dev/null +++ b/lib/services/data_service/data_service_leg_share.dart @@ -0,0 +1,97 @@ +part of 'data_service.dart'; + +extension DataServiceLegShare on DataService { + Future fetchLegShare(String legShareId) async { + try { + final json = await api.get('/legs/share/$legShareId'); + if (json is Map) { + return LegShareData.fromJson( + json.map((k, v) => MapEntry(k.toString(), v)), + ); + } + } catch (e) { + debugPrint('Failed to fetch leg share $legShareId: $e'); + rethrow; + } + return null; + } + + Future rejectLegShare(String legShareId) async { + try { + await api.post('/legs/share/$legShareId/reject', {}); + } catch (e) { + debugPrint('Failed to reject leg share $legShareId: $e'); + rethrow; + } + } + + Future acceptLegShare(LegShareData share) async { + final entry = share.entry; + final hasRoute = entry.route.isNotEmpty; + final locosPayload = >[]; + for (var i = 0; i < entry.locos.length; i++) { + final loco = entry.locos[i]; + final pos = loco.allocPos != 0 ? loco.allocPos : i; + locosPayload.add({ + "loco_id": loco.id, + "alloc_pos": pos, + "alloc_powering": loco.powering ? 1 : 0, + }); + } + final payload = { + "leg_share_id": share.id, + "leg_trip": null, + "leg_begin_time": entry.beginTime.toIso8601String(), + if (entry.endTime != null) "leg_end_time": entry.endTime!.toIso8601String(), + if (entry.originTime != null) + "leg_origin_time": entry.originTime!.toIso8601String(), + if (entry.destinationTime != null) + "leg_destination_time": entry.destinationTime!.toIso8601String(), + "leg_notes": entry.notes, + "leg_headcode": entry.headcode, + "leg_network": entry.network, + "leg_origin": entry.origin, + "leg_destination": entry.destination, + "leg_begin_delay": entry.beginDelayMinutes ?? 0, + if (entry.endDelayMinutes != null) "leg_end_delay": entry.endDelayMinutes, + "locos": locosPayload, + "share_user_ids": [], + }; + + dynamic response; + if (hasRoute) { + response = await api.post( + '/add', + { + ...payload, + "leg_route": entry.route, + "leg_mileage": entry.mileage, + }, + ); + } else { + response = await api.post( + '/add/manual', + { + ...payload, + "leg_start": entry.start, + "leg_end": entry.end, + "leg_distance": entry.mileage, + "isKilometers": false, + }, + ); + } + + final legId = _parseNullableInt( + response is Map ? response['leg_id'] : null, + ); + if (legId != null) return legId; + return null; + } + + int? _parseNullableInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index a9e5f6d..7c845f8 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -13,12 +13,16 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/logbook.dart'; import 'package:mileograph_flutter/components/pages/more.dart'; +import 'package:mileograph_flutter/components/pages/badges.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; +import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; +import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; @@ -206,7 +210,15 @@ class _MyAppState extends State { path: '/traction/new', builder: (context, state) => const NewTractionPage(), ), - GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), + GoRoute( + path: '/add', + builder: (context, state) { + final extra = state.extra; + return NewEntryPage( + legShare: extra is LegShareData ? extra : null, + ); + }, + ), GoRoute( path: '/more', builder: (context, state) => const MorePage(), @@ -215,6 +227,10 @@ class _MyAppState extends State { path: '/more/profile', builder: (context, state) => const ProfilePage(), ), + GoRoute( + path: '/more/badges', + builder: (context, state) => const BadgesPage(), + ), GoRoute( path: '/more/stats', builder: (context, state) => const StatsPage(), @@ -350,6 +366,13 @@ class _MyHomePageState extends State { if (data.notifications.isEmpty) { data.fetchNotifications(); } + if (data.friendships.isEmpty && !data.isFriendshipsLoading) { + data.fetchFriendships(); + } + if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) && + !data.isPendingFriendshipsLoading) { + data.fetchPendingFriendships(); + } _startNotificationPolling(); }); }); @@ -435,6 +458,11 @@ class _MyHomePageState extends State { ), actions: [ _buildNotificationAction(context, data), + IconButton( + tooltip: 'Profile', + onPressed: () => context.go('/more/profile'), + icon: const Icon(Icons.person), + ), IconButton( tooltip: 'Settings', onPressed: () => context.go('/more/settings'), @@ -645,6 +673,13 @@ class _MyHomePageState extends State { Widget _buildNotificationsContent(BuildContext context, bool isWide) { final data = context.watch(); final notifications = data.notifications; + final dismissibleIds = notifications + .where( + (n) => + !_isFriendRequestNotification(n) && !_isLegShareNotification(n), + ) + .map((e) => e.id) + .toList(); final loading = data.isNotificationsLoading; final listHeight = isWide ? 380.0 @@ -671,6 +706,9 @@ class _MyHomePageState extends State { separatorBuilder: (_, index) => const SizedBox(height: 8), itemBuilder: (ctx, index) { final item = notifications[index]; + final isFriendRequest = _isFriendRequestNotification(item); + final isLegShare = _isLegShareNotification(item); + final isSpecial = isFriendRequest || isLegShare; return Card( child: Padding( padding: const EdgeInsets.all(12.0), @@ -693,7 +731,11 @@ class _MyHomePageState extends State { ), const SizedBox(height: 4), Text( - item.body, + isFriendRequest || isLegShare + ? isFriendRequest + ? 'Accept to share entries' + : 'Shared entry details below.' + : item.body, style: Theme.of(context).textTheme.bodyMedium, ), if (item.createdAt != null) ...[ @@ -716,9 +758,10 @@ class _MyHomePageState extends State { ), ), ], - ], - ), + ], ), + ), + if (!isSpecial) ...[ const SizedBox(width: 8), TextButton( onPressed: () => @@ -726,7 +769,20 @@ class _MyHomePageState extends State { child: const Text('Dismiss'), ), ], - ), + ], + ), + if (isFriendRequest) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: FriendRequestNotificationCard( + notification: item, + ), + ), + if (isLegShare) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: LegShareNotificationCard(notification: item), + ), ], ), ), @@ -756,10 +812,7 @@ class _MyHomePageState extends State { TextButton( onPressed: notifications.isEmpty ? null - : () => _dismissNotifications( - context, - notifications.map((e) => e.id).toList(), - ), + : () => _dismissNotifications(context, dismissibleIds), child: const Text('Dismiss all'), ), ], @@ -794,6 +847,29 @@ class _MyHomePageState extends State { return '$y-$m-$d $hh:$mm'; } + bool _isFriendRequestNotification(UserNotification notification) { + final type = notification.type.trim().toLowerCase(); + final channel = notification.channel.trim().toLowerCase(); + final title = notification.title.trim().toLowerCase(); + bool matchesChannel = + channel == 'friend_request' || channel == 'friend-request'; + if (!matchesChannel) { + matchesChannel = channel.contains('friend_request') || + channel.contains('friend-request') || + channel.contains('friend'); + } + return matchesChannel || + type == 'friend_request' || + type == 'friend-request' || + title.contains('friend request'); + } + + bool _isLegShareNotification(UserNotification notification) { + final channel = notification.channel.trim().toLowerCase(); + final type = notification.type.trim().toLowerCase(); + return channel.contains('leg_share') || type.contains('leg_share'); + } + Widget _buildBadge(String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), diff --git a/pubspec.yaml b/pubspec.yaml index a150fcc..f0cbec7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.6+2 +version: 0.6.0+3 environment: sdk: ^3.8.1