diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c16cc99..d4244df06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ## Changelog +### v3.0.8 +- Refined the edit profile flow and redesigned the profile completeness widget +- Applied minor profile UI polish and layout rework +- Additional Sentry fixes and test stability improvements +- Version and build number update for release + +### v3.0.7 +- Added user blocking, with follow-up fixes for notification auth, cooldowns, and review feedback +- Revamped the collections page, personalised feed editor, AI generation screen, streak UI, and bottom bar sizing +- Grouped notifications and improved in-app notification behaviour +- Added wallpaper reporting and migrated wall/setup view tracking from GitHub-backed data to Firestore +- Fixed Wall of the Day generation, profile refresh, and edit profile reliability +- Reworked the wallpaper detail sheet with liquid-glass styling, richer color handling, better author/date presentation, and tap animations +- Improved startup performance and added an email export utility +- iOS release stability fixes, including build/review compliance updates, Firebase/plugin updates, media host support, and app icon refresh +- Website refresh plus Kotlin, CI, pigeon, and formatting fixes for release stability + ### v3.0.6 - Version and build number update for release diff --git a/lib/auth/google_auth.dart b/lib/auth/google_auth.dart index 9fc560e90..ab05de14d 100644 --- a/lib/auth/google_auth.dart +++ b/lib/auth/google_auth.dart @@ -196,10 +196,13 @@ class GoogleAuth { bool _isSignInCancelled(Object error) { if (error is GoogleSignInException) { - return error.code == GoogleSignInExceptionCode.canceled; + return error.code == GoogleSignInExceptionCode.canceled || error.code == GoogleSignInExceptionCode.unknownError; } final String message = error.toString().toLowerCase(); - return message.contains('user canceled') || message.contains('cancelled'); + return message.contains('user canceled') || + message.contains('cancelled') || + message.contains('no credential') || + message.contains('no credentials available'); } Future signOutGoogle() async { diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 2512e3e02..abe2ae166 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -5,8 +5,8 @@ import 'package:Prism/auth/userModel.dart'; const String defaultProfilePhotoUrl = 'https://firebasestorage.googleapis.com/v0/b/prism-wallpapers.appspot.com/o/Replacement%20Thumbnails%2Fpost%20bg.png?alt=media&token=d708b5e3-a7ee-421b-beae-3b10946678c4'; -const String currentAppVersion = '3.0.7'; -const String currentAppVersionCode = '330'; +const String currentAppVersion = '3.0.8'; +const String currentAppVersionCode = '331'; const String defaultObsoleteAppVersion = '2.6.0'; const String defaultBannerText = 'Join our Telegram'; diff --git a/lib/core/firestore/firestore_tracked_client.dart b/lib/core/firestore/firestore_tracked_client.dart index b1aad7d61..27e1a779f 100644 --- a/lib/core/firestore/firestore_tracked_client.dart +++ b/lib/core/firestore/firestore_tracked_client.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:Prism/core/firestore/firestore_client.dart'; import 'package:Prism/core/firestore/firestore_error.dart'; @@ -7,6 +8,10 @@ import 'package:Prism/core/firestore/firestore_telemetry.dart'; import 'package:Prism/logger/logger.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; +const _transientFirestoreCodes = {'unavailable', 'deadline-exceeded', 'internal', 'resource-exhausted'}; + +bool _isTransientFirestoreError(FirestoreError e) => e.code != null && _transientFirestoreCodes.contains(e.code); + class _RawQueryDoc { const _RawQueryDoc(this.id, this.data); @@ -539,48 +544,61 @@ class FirestoreTrackedClient implements FirestoreClient { required String sourceTag, required String collection, String? docId, + int maxRetries = 2, }) async { final Stopwatch sw = Stopwatch()..start(); - try { - final T result = await _firestore.runTransaction((Transaction transaction) { - final _FirestoreTransactionBridge bridge = _FirestoreTransactionBridge(_firestore, transaction); - return action(bridge); - }); - await _emitTelemetry( - FirestoreTelemetryEvent( - timestamp: DateTime.now(), - sourceTag: sourceTag, - operation: FirestoreOperation.transaction, - collection: collection, - filtersHash: docId == null ? collection : '$collection:$docId', - durationMs: sw.elapsedMilliseconds, - docId: docId, - success: true, - ), - ); - return result; - } catch (error) { - final FirestoreError mapped = mapFirestoreError(error); - if (mapped.code == 'permission-denied') { - logger.w( - '[Firestore] permission-denied on transaction — collection: $collection, sourceTag: $sourceTag', - error: mapped, + int attempt = 0; + while (true) { + try { + final T result = await _firestore.runTransaction((Transaction transaction) { + final _FirestoreTransactionBridge bridge = _FirestoreTransactionBridge(_firestore, transaction); + return action(bridge); + }); + await _emitTelemetry( + FirestoreTelemetryEvent( + timestamp: DateTime.now(), + sourceTag: sourceTag, + operation: FirestoreOperation.transaction, + collection: collection, + filtersHash: docId == null ? collection : '$collection:$docId', + durationMs: sw.elapsedMilliseconds, + docId: docId, + success: true, + ), ); + return result; + } catch (error) { + final FirestoreError mapped = mapFirestoreError(error); + if (_isTransientFirestoreError(mapped) && attempt < maxRetries) { + attempt++; + final int delayMs = (500 * math.pow(2, attempt - 1)).round(); + logger.w( + '[Firestore] transient error (${mapped.code}) on transaction — retrying ($attempt/$maxRetries) after ${delayMs}ms, sourceTag: $sourceTag', + ); + await Future.delayed(Duration(milliseconds: delayMs)); + continue; + } + if (mapped.code == 'permission-denied') { + logger.w( + '[Firestore] permission-denied on transaction — collection: $collection, sourceTag: $sourceTag', + error: mapped, + ); + } + await _emitTelemetry( + FirestoreTelemetryEvent( + timestamp: DateTime.now(), + sourceTag: sourceTag, + operation: FirestoreOperation.transaction, + collection: collection, + filtersHash: docId == null ? collection : '$collection:$docId', + durationMs: sw.elapsedMilliseconds, + docId: docId, + success: false, + errorCode: mapped.code, + ), + ); + throw mapped; } - await _emitTelemetry( - FirestoreTelemetryEvent( - timestamp: DateTime.now(), - sourceTag: sourceTag, - operation: FirestoreOperation.transaction, - collection: collection, - filtersHash: docId == null ? collection : '$collection:$docId', - durationMs: sw.elapsedMilliseconds, - docId: docId, - success: false, - errorCode: mapped.code, - ), - ); - throw mapped; } } diff --git a/lib/core/widgets/menuButton/favWallpaperButton.dart b/lib/core/widgets/menuButton/favWallpaperButton.dart index 235904e99..28923e517 100644 --- a/lib/core/widgets/menuButton/favWallpaperButton.dart +++ b/lib/core/widgets/menuButton/favWallpaperButton.dart @@ -83,9 +83,11 @@ class _FavouriteWallpaperButtonState extends State { } context.favouriteWallsAdapter(listen: false).favCheck(wall).then((value) { analytics.track(FavStatusChangedEvent(wallId: wall.id, provider: wall.source.legacyProviderString)); - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } }); } } diff --git a/lib/core/widgets/popup/editProfilePanel.dart b/lib/core/widgets/popup/editProfilePanel.dart index 8f94464c6..61bfb186a 100644 --- a/lib/core/widgets/popup/editProfilePanel.dart +++ b/lib/core/widgets/popup/editProfilePanel.dart @@ -10,6 +10,7 @@ import 'package:Prism/core/state/app_state.dart' as app_state; import 'package:Prism/env/env.dart'; import 'package:Prism/global/svgAssets.dart'; import 'package:Prism/logger/logger.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:Prism/theme/jam_icons_icons.dart'; import 'package:Prism/theme/toasts.dart' as toasts; import 'package:animations/animations.dart'; @@ -74,13 +75,6 @@ class _EditProfilePanelState extends State { late String coverUrl; final picker2 = ImagePicker(); List<_ProfileLinkOption> linkIcons = [ - // { - // 'name': 'Edit links...', - // 'link': 'Select your link first', - // 'icon': JamIcons.link, - // 'value': '', - // 'validator': '', - // }, _ProfileLinkOption(name: 'github', link: 'https://github.com/username', icon: JamIcons.github, validator: 'github'), _ProfileLinkOption( name: 'twitter', @@ -186,6 +180,7 @@ class _EditProfilePanelState extends State { _ProfileLinkOption(name: 'custom link', link: '', icon: JamIcons.link, validator: ''), ]; _ProfileLinkOption? _link; + @override void initState() { linkIcons.sort((a, b) => a.name.compareTo(b.name)); @@ -293,56 +288,60 @@ class _EditProfilePanelState extends State { } Future showRemoveAlertDialog(BuildContext context, Future Function() remove, String removeWhat) async { - if (!mounted) { - return; - } + if (!mounted) return; await showModal( context: context, builder: (BuildContext dialogContext) { + final cs = Theme.of(dialogContext).colorScheme; return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PrismProfile.dialogBorderRadius)), title: Text( - 'Delete the $removeWhat?', + 'Remove $removeWhat?', style: TextStyle( + fontFamily: PrismFonts.proximaNova, fontWeight: FontWeight.w700, - fontSize: 16, - color: Theme.of(dialogContext).colorScheme.secondary, + fontSize: PrismProfile.dialogTitleFontSize, + color: cs.secondary, ), ), content: Text( - "This is permanent, and this action can't be undone!", + "This can't be undone.", style: TextStyle( - fontFamily: "Proxima Nova", + fontFamily: PrismFonts.proximaNova, fontWeight: FontWeight.normal, - fontSize: 14, - color: Theme.of(dialogContext).colorScheme.secondary, + fontSize: PrismProfile.dialogBodyFontSize, + color: cs.secondary.withValues(alpha: PrismProfile.dialogBodyOpacity), ), ), actions: [ - MaterialButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), - color: Theme.of(dialogContext).hintColor, + TextButton( + onPressed: () => Navigator.of(dialogContext, rootNavigator: true).pop(), + child: Text( + 'Cancel', + style: TextStyle(fontFamily: PrismFonts.proximaNova, color: cs.secondary), + ), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: PrismColors.brandPink, + foregroundColor: PrismColors.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PrismProfile.dialogButtonRadius)), + ), onPressed: () async { Navigator.of(dialogContext, rootNavigator: true).pop(); - if (!mounted) { - return; - } + if (!mounted) return; await remove(); }, - child: const Text('DELETE', style: TextStyle(fontSize: 16.0, color: Colors.white)), - ), - MaterialButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), - color: Theme.of(dialogContext).colorScheme.error, - onPressed: () { - Navigator.of(dialogContext, rootNavigator: true).pop(); - }, - child: const Text('CANCEL', style: TextStyle(fontSize: 16.0, color: Colors.white)), + child: const Text( + 'Remove', + style: TextStyle(fontFamily: PrismFonts.proximaNova, fontWeight: FontWeight.w600), + ), ), ], backgroundColor: Theme.of(dialogContext).primaryColor, - actionsPadding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), ); }, ); @@ -367,694 +366,581 @@ class _EditProfilePanelState extends State { return users.isEmpty; } + bool get _hasChanges => + (!usernameEdit && (pfpEdit || bioEdit || linkEdit || coverEdit || nameEdit)) || (usernameEdit && enabled); + + Future _saveProfile() async { + setState(() => isLoading = true); + + if (usernameEdit && usernameController.text.isNotEmpty && usernameController.text.length >= 8) { + app_state.prismUser.username = usernameController.text; + app_state.persistPrismUser(); + await _updateCurrentUser({"username": usernameController.text}, 'profile.edit.username'); + } + if (_pfp != null && pfpEdit) { + await processImage(); + } + if (_cover != null && coverEdit) { + await processImageCover(); + } + if (bioEdit && bioController.text.isNotEmpty) { + app_state.prismUser.bio = bioController.text; + app_state.persistPrismUser(); + await _updateCurrentUser({"bio": bioController.text}, 'profile.edit.bio'); + } + if (nameEdit && nameController.text.isNotEmpty) { + app_state.prismUser.name = nameController.text; + app_state.persistPrismUser(); + await _updateCurrentUser({"name": nameController.text}, 'profile.edit.name'); + } + if (linkEdit) { + final Map links = Map.from(app_state.prismUser.links); + for (final icon in linkIcons) { + if (icon.value.isNotEmpty) { + links[icon.name] = icon.value; + } + } + app_state.prismUser.links = links; + app_state.persistPrismUser(); + await _updateCurrentUser({"links": links}, 'profile.edit.links'); + } + + await CoinsService.instance.maybeAwardProfileCompletion(); + setState(() => isLoading = false); + if (mounted) { + Navigator.pop(context); + toasts.codeSend("Profile updated!"); + } + } + + InputDecoration _fieldDecoration({required String label, Widget? prefixIcon, Widget? suffixIcon, String? hintText}) { + final secondary = Theme.of(context).colorScheme.secondary; + final borderColor = secondary.withValues(alpha: PrismFormField.restingBorderOpacity); + final borderSide = BorderSide(color: borderColor, width: PrismFormField.borderWidth); + final radius = BorderRadius.circular(PrismFormField.borderRadius); + return InputDecoration( + contentPadding: PrismFormField.contentPadding, + border: OutlineInputBorder(borderRadius: radius, borderSide: borderSide), + disabledBorder: OutlineInputBorder(borderRadius: radius, borderSide: borderSide), + enabledBorder: OutlineInputBorder(borderRadius: radius, borderSide: borderSide), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: const BorderSide(color: PrismColors.brandPink, width: PrismFormField.borderWidth), + ), + labelText: label, + labelStyle: TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismFormField.labelFontSize, + color: secondary.withValues(alpha: PrismFormField.labelOpacity), + ), + hintText: hintText, + hintStyle: TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismFormField.hintFontSize, + color: secondary.withValues(alpha: PrismFormField.hintOpacity), + ), + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + ); + } + @override Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width * 0.85; + final theme = Theme.of(context); + final secondary = theme.colorScheme.secondary; + final screenWidth = MediaQuery.of(context).size.width; + // Percentage-based padding keeps avatar positioning consistent across screen widths. + final hPad = screenWidth * 0.06; + return Scaffold( + backgroundColor: theme.primaryColor, appBar: AppBar( + backgroundColor: theme.primaryColor, + elevation: 0, leading: IconButton( - icon: const Icon(JamIcons.close), - onPressed: () { - Navigator.pop(context); - }, + icon: Icon(JamIcons.close, color: secondary), + onPressed: () => Navigator.pop(context), ), - title: Text("Edit Profile", style: Theme.of(context).textTheme.displaySmall), + title: Text('Edit Profile', style: PrismTextStyles.panelTitle(context)), ), - backgroundColor: Theme.of(context).primaryColor, body: SingleChildScrollView( - child: Container( - height: MediaQuery.of(context).size.height, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), - ), - child: Column( - children: [ - Stack( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + _buildCoverArea(theme, screenWidth), + Positioned( + left: hPad, + bottom: -PrismProfile.avatarOverlap, + child: _buildAvatar(theme, PrismProfile.avatarSize), + ), + ], + ), + const SizedBox(height: PrismProfile.avatarOverlap + 16), + Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - height: MediaQuery.of(context).size.width * 508 / 1234, - width: MediaQuery.of(context).size.width, - decoration: const BoxDecoration( - border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)), - ), - child: (_cover == null) - ? (app_state.prismUser.coverPhoto != null && - Uri.tryParse(app_state.prismUser.coverPhoto!)?.hasAuthority == true) - ? CachedNetworkImage(imageUrl: app_state.prismUser.coverPhoto!, fit: BoxFit.cover) - : SvgPicture.string( - defaultHeader - .replaceAll( - "#181818", - "#${Theme.of(context).primaryColor.toARGB32().toRadixString(16).substring(2)}", - ) - .replaceAll( - "#E77597", - "#${Theme.of(context).colorScheme.error.toARGB32().toRadixString(16).substring(2)}", - ), - fit: BoxFit.cover, - ) - : Image.file(_cover!, fit: BoxFit.cover), - ), - Material( - child: InkWell( - onTap: () async { - await getCover(); - }, - child: Container( - height: MediaQuery.of(context).size.width * 508 / 1234, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)), - color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5), - ), - child: const Icon(JamIcons.pencil, color: Colors.white), - ), - ), - ), - Positioned( - right: 0, - child: IconButton( - onPressed: () async { - showRemoveAlertDialog(context, () async { - _cover = null; - app_state.prismUser.coverPhoto = null; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "coverPhoto": null, - }, 'profile.edit.removeCoverPhoto'); - }, "Cover photo"); - }, - icon: const Icon(JamIcons.close), - ), - ), + _buildNameField(secondary), + const SizedBox(height: PrismProfile.fieldGap), + _buildUsernameField(secondary), + const SizedBox(height: PrismProfile.fieldGap), + _buildBioField(secondary), + const SizedBox(height: PrismProfile.fieldGap), + _buildLinkRow(theme, secondary), + const SizedBox(height: PrismProfile.preSaveGap), + _buildSaveButton(secondary), + const SizedBox(height: PrismProfile.postSaveGap), + Center(child: _buildUsernameHint(screenWidth)), + const SizedBox(height: PrismProfile.bottomPadding), ], ), - const Spacer(), - ClipOval( - child: Material( - child: InkWell( - onTap: () async { - await getPFP(); - }, - child: Stack( - children: [ - Container( - height: 100, - width: 100, - decoration: const BoxDecoration( - shape: BoxShape.circle, - border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)), - ), - child: (_pfp == null) - ? (Uri.tryParse(app_state.prismUser.profilePhoto)?.hasAuthority == true) - ? CachedNetworkImage(imageUrl: app_state.prismUser.profilePhoto, fit: BoxFit.cover) - : const Icon(Icons.person, size: 60) - : Image.file(_pfp!, fit: BoxFit.cover), - ), - Container( - height: 100, - width: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)), - color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5), - ), - child: const Icon(JamIcons.pencil, color: Colors.white), - ), - ], - ), + ), + ], + ), + ), + ); + } + + Widget _buildCoverArea(ThemeData theme, double screenWidth) { + final coverHeight = screenWidth * 508 / 1234; + return SizedBox( + height: coverHeight, + width: screenWidth, + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + onTap: getCover, + child: (_cover == null) + ? (app_state.prismUser.coverPhoto != null && + Uri.tryParse(app_state.prismUser.coverPhoto!)?.hasAuthority == true) + ? CachedNetworkImage( + imageUrl: app_state.prismUser.coverPhoto!, + fit: BoxFit.cover, + errorWidget: (context, url, error) => const SizedBox.shrink(), + ) + : SvgPicture.string( + defaultHeader + .replaceAll("#181818", "#${theme.primaryColor.toARGB32().toRadixString(16).substring(2)}") + .replaceAll( + "#E77597", + "#${theme.colorScheme.error.toARGB32().toRadixString(16).substring(2)}", + ), + fit: BoxFit.cover, + ) + : Image.file(_cover!, fit: BoxFit.cover), + ), + // "Edit cover" scrim hint — always legible over any cover image. + Positioned( + bottom: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: Container( + height: PrismProfile.coverScrimHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.5)], ), ), - ), - const Spacer(), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: 80, - width: width - 24, - child: Center( - child: TextField( - cursorColor: const Color(0xFFE57697), - style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Colors.white), - controller: nameController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only(left: 30, top: 15), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - labelText: "Name", - labelStyle: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(fontSize: 14, color: Colors.white), - prefixIcon: const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "Name", - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), - ), - ), - ), - onChanged: (value) async { - if (value == app_state.prismUser.name || value == "") { - setState(() { - nameEdit = false; - }); - } else { - setState(() { - nameEdit = true; - }); - } - }, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + JamIcons.camera, + color: PrismColors.onPrimary.withValues(alpha: 0.85), + size: PrismProfile.coverEditIconSize, ), - ), - SizedBox( - height: 80, - width: width - 24, - child: Center( - child: TextField( - cursorColor: const Color(0xFFE57697), - style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Colors.white), - controller: usernameController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only(left: 30, top: 15), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - labelText: "username", - labelStyle: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(fontSize: 14, color: Colors.white), - prefixIcon: const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "@", - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), - ), - ), - suffixIcon: isCheckingUsername - ? Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(color: Theme.of(context).colorScheme.error), - ), - ) - : Padding( - padding: EdgeInsets.all(available == null ? 16.0 : 8), - child: available == null - ? const Text( - "", - style: TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ) - : Icon( - available! ? JamIcons.check : JamIcons.close, - color: available! ? Colors.green : Colors.red, - size: 24, - ), - ), - ), - onChanged: (value) async { - if (value != "" && value.length >= 8 && !value.contains(RegExp(r"(?: |[^\w\s])+"))) { - setState(() { - enabled = true; - }); - } else { - setState(() { - enabled = false; - }); - } - if (enabled) { - setState(() { - isCheckingUsername = true; - }); - final isAvailable = await _isUsernameAvailable(value); - setState(() { - available = isAvailable; - }); - setState(() { - isCheckingUsername = false; - }); - } else { - setState(() { - available = null; - }); - } - if (value == app_state.prismUser.username || value == "") { - setState(() { - usernameEdit = false; - available = null; - }); - } else { - setState(() { - usernameEdit = true; - }); - } - }, + const SizedBox(width: PrismProfile.coverEditIconGap), + Text( + 'Edit cover', + style: PrismTextStyles.photoOverlayLabel.copyWith( + color: PrismColors.onPrimary.withValues(alpha: 0.85), ), ), - ), - SizedBox( - height: 80, - width: width - 24, - child: Center( - child: TextField( - cursorColor: const Color(0xFFE57697), - style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Colors.white), - controller: bioController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only(left: 30, top: 15), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - labelText: "Bio", - labelStyle: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(fontSize: 14, color: Colors.white), - prefixIcon: const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "bio", - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), - ), - ), - suffixIcon: IconButton( - onPressed: () async { - showRemoveAlertDialog(context, () async { - bioController.text = ""; - app_state.prismUser.bio = ""; - app_state.persistPrismUser(); - await _updateCurrentUser({"bio": ""}, 'profile.edit.clearBio'); - }, "bio"); - }, - icon: const Icon(JamIcons.close, color: Colors.red, size: 24), - ), - ), - onChanged: (value) { - if (value == app_state.prismUser.bio || value == "") { - setState(() { - bioEdit = false; - }); - } else { - setState(() { - bioEdit = true; - }); - } - }, - ), - ), - ), - SizedBox( - height: 80, - width: width - 24, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - left: 0, - child: SizedBox( - height: 80, - width: 130, - child: Center( - child: DropdownButton<_ProfileLinkOption>( - isExpanded: true, - items: linkIcons.map((link) { - return DropdownMenuItem( - value: link, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Icon(link.icon), - const SizedBox(width: 16), - Text( - link.name.inCaps, - style: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(color: Colors.white, fontSize: 14), - ), - ], - ), - ); - }).toList(), - underline: Container(), - onChanged: (value) { - setState(() => _link = value); - linkController.text = _link?.value ?? ''; - }, - icon: Container(), - value: _link, - dropdownColor: Theme.of(context).primaryColor, - selectedItemBuilder: (BuildContext context) { - return linkIcons.map((link) { - return Padding( - padding: const EdgeInsets.only(left: 12.0), - child: Row( - children: [Icon(link.icon), const Icon(JamIcons.chevron_down, size: 14)], - ), - ); - }).toList(); - }, + ], + ), + ), + ), + ), + // Remove cover photo button. + Positioned( + top: PrismProfile.removeChipPositionOffset, + right: PrismProfile.removeChipPositionOffset, + child: _iconChip( + icon: JamIcons.close, + onTap: () => showRemoveAlertDialog(context, () async { + setState(() => _cover = null); + app_state.prismUser.coverPhoto = null; + app_state.persistPrismUser(); + await _updateCurrentUser({"coverPhoto": null}, 'profile.edit.removeCoverPhoto'); + }, "cover photo"), + ), + ), + ], + ), + ); + } + + Widget _buildAvatar(ThemeData theme, double size) { + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: getPFP, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: theme.primaryColor, width: PrismProfile.avatarBorderWidth), + ), + child: ClipOval( + child: (_pfp == null) + ? (Uri.tryParse(app_state.prismUser.profilePhoto)?.hasAuthority == true) + ? CachedNetworkImage( + imageUrl: app_state.prismUser.profilePhoto, + fit: BoxFit.cover, + errorWidget: (context, url, error) => ColoredBox( + color: PrismColors.brandPink.withValues(alpha: 0.12), + child: Icon( + Icons.person, + size: size * 0.5, + color: PrismColors.brandPink.withValues(alpha: 0.5), ), ), - ), - ), - Positioned( - right: 0, - child: SizedBox( - height: 80, - width: width - 80, - child: Center( - child: TextField( - cursorColor: const Color(0xFFE57697), - style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Colors.white), - controller: linkController, - decoration: InputDecoration( - enabled: _link?.name != "Edit links...", - contentPadding: const EdgeInsets.only(left: 30, top: 15), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.white, width: 2), - ), - labelText: _link?.name.inCaps ?? "", - labelStyle: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(fontSize: 14, color: Colors.white), - hintText: _link?.link ?? "", - hintStyle: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(fontSize: 14, color: Colors.white), - suffixIcon: IconButton( - onPressed: () async { - showRemoveAlertDialog(context, () async { - linkController.text = ""; - final links = app_state.prismUser.links; - links.remove(_link?.name); - app_state.prismUser.links = links; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "links": app_state.prismUser.links, - }, 'profile.edit.removeLink'); - }, "${_link?.name.inCaps}"); - }, - icon: const Icon(JamIcons.close, color: Colors.red, size: 24), - ), - ), - onChanged: (value) { - if (value.toLowerCase().contains('${_link?.validator.toLowerCase()}')) { - setState(() { - if (_link != null) { - _link!.value = value; - } - }); - bool changed = false; - for (int i = 0; i < linkIcons.length; i++) { - if (linkIcons[i].value.isNotEmpty) { - changed = true; - break; - } - } - setState(() { - linkEdit = changed; - }); - } else if (value == "") { - bool changed = false; - for (int i = 0; i < linkIcons.length; i++) { - if (linkIcons[i].value.isNotEmpty) { - changed = true; - break; - } - } - setState(() { - linkEdit = changed; - }); - } else { - setState(() { - linkEdit = false; - }); - } - }, - ), + ) + : ColoredBox( + color: PrismColors.brandPink.withValues(alpha: 0.12), + child: Icon( + Icons.person, + size: size * 0.5, + color: PrismColors.brandPink.withValues(alpha: 0.5), ), - ), - ), - ], - ), - ), - ], + ) + : Image.file(_pfp!, fit: BoxFit.cover), + ), + ), + ), + // Pink camera badge — brand-locked so it never goes cyan/blue. + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: getPFP, + child: Container( + width: PrismProfile.cameraChipSize, + height: PrismProfile.cameraChipSize, + decoration: BoxDecoration( + color: PrismColors.brandPink, + shape: BoxShape.circle, + border: Border.all(color: theme.primaryColor, width: PrismProfile.cameraChipBorderWidth), ), - const Spacer(), - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: (!usernameEdit && (pfpEdit || bioEdit || linkEdit || coverEdit || nameEdit)) - ? () async { - setState(() { - isLoading = true; - }); - if (_pfp != null && pfpEdit) { - await processImage(); - } - if (_cover != null && coverEdit) { - await processImageCover(); - } - if (bioEdit && bioController.text != "") { - app_state.prismUser.bio = bioController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({"bio": bioController.text}, 'profile.edit.bio'); - } - if (linkEdit) { - final Map links = Map.from(app_state.prismUser.links); - for (int p = 0; p < linkIcons.length; p++) { - if (linkIcons[p].value.isNotEmpty) { - links[linkIcons[p].name] = linkIcons[p].value; - } - } - app_state.prismUser.links = links; - app_state.persistPrismUser(); - await _updateCurrentUser({"links": links}, 'profile.edit.links'); - } - if (nameEdit && nameController.text != "") { - app_state.prismUser.name = nameController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "name": nameController.text, - }, 'profile.edit.name'); - } - await CoinsService.instance.maybeAwardProfileCompletion(); - setState(() { - isLoading = false; - }); - Navigator.pop(context); - toasts.codeSend("Details updated!"); - } - : (usernameEdit && enabled) - ? () async { - setState(() { - isLoading = true; - }); - if (usernameEdit && usernameController.text != "" && usernameController.text.length >= 8) { - app_state.prismUser.username = usernameController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "username": usernameController.text, - }, 'profile.edit.username'); - } - if (_pfp != null && pfpEdit) { - await processImage(); - } - if (_cover != null && coverEdit) { - await processImageCover(); - } - if (bioEdit && bioController.text != "") { - app_state.prismUser.bio = bioController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "bio": bioController.text, - }, 'profile.edit.bio.withUsername'); - } - if (nameEdit && nameController.text != "") { - app_state.prismUser.name = nameController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "name": nameController.text, - }, 'profile.edit.name.withUsername'); - } - if (linkEdit) { - final Map links = Map.from(app_state.prismUser.links); - for (int p = 0; p < linkIcons.length; p++) { - if (linkIcons[p].value.isNotEmpty) { - links[linkIcons[p].name] = linkIcons[p].value; - } - if (_pfp != null && pfpEdit) { - await processImage(); - } - if (_cover != null && coverEdit) { - await processImageCover(); - } - if (bioEdit && bioController.text != "") { - app_state.prismUser.bio = bioController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "bio": bioController.text, - }, 'profile.edit.bio.withUsername'); - } - if (nameEdit && nameController.text != "") { - app_state.prismUser.name = nameController.text; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "name": nameController.text, - }, 'profile.edit.name.withUsername'); - } - if (linkEdit) { - final Map links = Map.from(app_state.prismUser.links); - for (int p = 0; p < linkIcons.length; p++) { - if (linkIcons[p].value.isNotEmpty) { - links[linkIcons[p].name] = linkIcons[p].value; - } - } - app_state.prismUser.links = links; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "links": links, - }, 'profile.edit.links.withUsername'); - } - await CoinsService.instance.maybeAwardProfileCompletion(); - setState(() { - isLoading = false; - }); - Navigator.pop(context); - toasts.codeSend("Details updated!"); - } - app_state.prismUser.links = links; - app_state.persistPrismUser(); - await _updateCurrentUser({ - "links": links, - }, 'profile.edit.links.withUsername'); - } - setState(() { - isLoading = false; - }); - Navigator.pop(context); - toasts.codeSend("Details updated!"); - } - : null, - child: SizedBox( - width: width - 20, - height: 60, - child: Container( - width: width - 14, - height: 60, - decoration: BoxDecoration( - color: - !((!usernameEdit && (pfpEdit || bioEdit || linkEdit || coverEdit || nameEdit)) || - (usernameEdit && enabled)) - ? Theme.of(context).primaryColor - : Theme.of(context).colorScheme.error.withValues(alpha: 0.2), - border: Border.all( - color: - !((!usernameEdit && (pfpEdit || bioEdit || linkEdit || coverEdit || nameEdit)) || - (usernameEdit && enabled)) - ? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5) - : Theme.of(context).colorScheme.error, - width: 3, - ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: isLoading - ? CircularProgressIndicator(color: Theme.of(context).primaryColor) - : Text( - "Update", - style: TextStyle( - fontSize: 16, - color: - !((!usernameEdit && (pfpEdit || bioEdit || linkEdit || coverEdit || nameEdit)) || - (usernameEdit && enabled)) - ? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5) - : Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), + child: const Icon(JamIcons.camera, size: PrismProfile.cameraChipIconSize, color: PrismColors.onPrimary), + ), + ), + ), + ], + ); + } + + Widget _buildNameField(Color secondary) { + return TextField( + cursorColor: PrismColors.brandPink, + style: PrismTextStyles.fieldInput(context), + controller: nameController, + decoration: _fieldDecoration(label: 'Name'), + onChanged: (value) { + setState(() { + nameEdit = value.isNotEmpty && value != app_state.prismUser.name; + }); + }, + ); + } + + Widget _buildUsernameField(Color secondary) { + return TextField( + cursorColor: PrismColors.brandPink, + style: PrismTextStyles.fieldInput(context), + controller: usernameController, + decoration: _fieldDecoration( + label: 'Username', + prefixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), + child: Text( + '@', + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismFormField.inputFontSize, + fontWeight: FontWeight.w600, + color: secondary.withValues(alpha: 0.45), + ), + ), + ), + suffixIcon: SizedBox( + width: PrismFormField.availabilityIndicatorSize, + height: PrismFormField.availabilityIndicatorSize, + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child), + child: isCheckingUsername + ? const SizedBox( + key: ValueKey('loading'), + width: PrismFormField.availabilitySpinnerSize, + height: PrismFormField.availabilitySpinnerSize, + child: CircularProgressIndicator(strokeWidth: 2, color: PrismColors.brandPink), + ) + : available == null + ? const SizedBox.shrink(key: ValueKey('none')) + : Icon( + available! ? JamIcons.check : JamIcons.close, + key: ValueKey(available), + // Soft semantic green for available; theme error for taken. + color: available! ? Colors.green.shade400 : Colors.red.shade400, + size: PrismFormField.availabilityIconSize, + ), + ), + ), + ), + ), + onChanged: (value) async { + final valid = value.isNotEmpty && value.length >= 8 && !value.contains(RegExp(r"(?: |[^\w\s])+")); + setState(() => enabled = valid); + + if (valid) { + setState(() => isCheckingUsername = true); + final isAvailable = await _isUsernameAvailable(value); + if (mounted) { + setState(() { + available = isAvailable; + isCheckingUsername = false; + }); + } + } else { + setState(() => available = null); + } + + setState(() { + usernameEdit = value.isNotEmpty && value != app_state.prismUser.username; + if (!usernameEdit) available = null; + }); + }, + ); + } + + Widget _buildBioField(Color secondary) { + return Stack( + children: [ + TextField( + cursorColor: PrismColors.brandPink, + style: PrismTextStyles.fieldInput(context), + controller: bioController, + maxLength: 150, + maxLines: 2, + decoration: _fieldDecoration(label: 'Bio', hintText: 'Tell people about yourself…').copyWith( + counterStyle: PrismTextStyles.fieldCaption(context).copyWith(fontSize: 10), + contentPadding: PrismFormField.contentPadding.add(const EdgeInsets.only(right: 36)), + ), + onChanged: (value) { + setState(() { + bioEdit = value.isNotEmpty && value != app_state.prismUser.bio; + }); + }, + ), + Positioned( + top: 0, + right: 0, + child: IconButton( + onPressed: () => showRemoveAlertDialog(context, () async { + bioController.text = ''; + app_state.prismUser.bio = ''; + app_state.persistPrismUser(); + await _updateCurrentUser({"bio": ""}, 'profile.edit.clearBio'); + }, "bio"), + icon: Icon(JamIcons.close, color: secondary.withValues(alpha: PrismFormField.iconOpacity), size: 20), + ), + ), + ], + ); + } + + Widget _buildLinkRow(ThemeData theme, Color secondary) { + final borderColor = secondary.withValues(alpha: PrismFormField.restingBorderOpacity); + return Row( + children: [ + Container( + height: PrismProfile.linkSelectorHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(PrismFormField.borderRadius), + border: Border.all(color: borderColor, width: PrismFormField.borderWidth), + ), + padding: const EdgeInsets.symmetric(horizontal: PrismProfile.linkSelectorHorizontalPadding), + child: DropdownButton<_ProfileLinkOption>( + menuWidth: 200, + items: linkIcons.map((link) { + return DropdownMenuItem( + value: link, + child: Row( + children: [ + Icon(link.icon, size: PrismProfile.linkDropdownIconSize, color: secondary), + const SizedBox(width: PrismProfile.linkDropdownTextGap), + Text( + link.name.inCaps, + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismProfile.linkDropdownFontSize, + color: secondary, ), ), - ), + ], ), - ), - const Spacer(flex: 2), - Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 32), - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.8, - child: Text( - "Usernames are unique names through which fans can view your profile/search for you. They should be greater than 8 characters, and cannot contain any symbol except for underscore (_).", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.secondary), + ); + }).toList(), + underline: const SizedBox.shrink(), + onChanged: (value) { + setState(() => _link = value); + linkController.text = _link?.value ?? ''; + }, + icon: const SizedBox.shrink(), + value: _link, + dropdownColor: theme.primaryColor, + selectedItemBuilder: (BuildContext context) { + return linkIcons.map((link) { + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(link.icon, size: PrismProfile.linkSelectorIconSize, color: secondary), + const SizedBox(width: 4), + Icon( + JamIcons.chevron_down, + size: PrismProfile.linkSelectorCaretSize, + color: secondary.withValues(alpha: 0.5), + ), + ], ), - ), + ); + }).toList(); + }, + ), + ), + const SizedBox(width: PrismProfile.linkSelectorGap), + Expanded( + child: TextField( + cursorColor: PrismColors.brandPink, + style: PrismTextStyles.fieldInputSmall(context), + controller: linkController, + decoration: _fieldDecoration( + label: _link?.name.inCaps ?? '', + hintText: _link?.link, + suffixIcon: IconButton( + onPressed: () => showRemoveAlertDialog(context, () async { + linkController.text = ''; + final links = app_state.prismUser.links; + links.remove(_link?.name); + app_state.prismUser.links = links; + app_state.persistPrismUser(); + await _updateCurrentUser({ + "links": app_state.prismUser.links, + }, 'profile.edit.removeLink'); + }, "${_link?.name.inCaps} link"), + icon: Icon(JamIcons.close, color: secondary.withValues(alpha: PrismFormField.iconOpacity), size: 20), ), - const Spacer(flex: 3), - ], + ), + onChanged: (value) { + if (value.toLowerCase().contains('${_link?.validator.toLowerCase()}')) { + if (_link != null) _link!.value = value; + } else if (value.isEmpty) { + if (_link != null) _link!.value = ''; + } + final changed = linkIcons.any((icon) => icon.value.isNotEmpty); + setState(() => linkEdit = changed); + }, ), ), + ], + ); + } + + Widget _buildSaveButton(Color secondary) { + final isActive = _hasChanges; + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutQuart, + height: PrismProfile.saveButtonHeight, + decoration: BoxDecoration( + // Brand pink tint when active — consistent with all other primary actions. + color: isActive ? PrismColors.brandPink.withValues(alpha: 0.12) : Colors.transparent, + border: Border.all( + color: isActive ? PrismColors.brandPink : secondary.withValues(alpha: 0.18), + width: PrismFormField.borderWidth, + ), + borderRadius: BorderRadius.circular(PrismFormField.borderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(PrismFormField.borderRadius), + onTap: isActive && !isLoading ? _saveProfile : null, + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: isLoading + ? const SizedBox( + key: ValueKey('loading'), + width: PrismProfile.savingIndicatorSize, + height: PrismProfile.savingIndicatorSize, + child: CircularProgressIndicator( + strokeWidth: PrismProfile.savingIndicatorStrokeWidth, + color: PrismColors.brandPink, + ), + ) + : Text( + 'Update', + key: const ValueKey('text'), + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: 16, + fontWeight: FontWeight.w700, + color: isActive ? secondary : secondary.withValues(alpha: 0.28), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildUsernameHint(double screenWidth) { + return SizedBox( + width: screenWidth * 0.75, + child: Text( + "Usernames must be 8+ characters with no symbols except underscore (_).", + textAlign: TextAlign.center, + style: PrismTextStyles.fieldCaption(context), + ), + ); + } + + Widget _iconChip({required IconData icon, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + width: PrismProfile.removeChipSize, + height: PrismProfile.removeChipSize, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: PrismProfile.removeChipScrimAlpha), + shape: BoxShape.circle, + ), + child: Icon(icon, color: PrismColors.onPrimary, size: PrismProfile.removeChipIconSize), ), ); } diff --git a/lib/data/pexels/provider/pexelsWithoutProvider.dart b/lib/data/pexels/provider/pexelsWithoutProvider.dart index aafc1d007..d63c8ea3a 100644 --- a/lib/data/pexels/provider/pexelsWithoutProvider.dart +++ b/lib/data/pexels/provider/pexelsWithoutProvider.dart @@ -83,7 +83,7 @@ Future> getWallsPbyColor(String query) async { final result = await _repo.fetchFeed(categoryName: query, refresh: true); result.fold( onSuccess: (List fetched) { - wallsC = fetched; + wallsC = List.of(fetched); pageColorsP = 2; logger.d("getWallsPbyColor done: ${wallsC.length}"); }, diff --git a/lib/features/category_feed/views/widgets/collections_grid.dart b/lib/features/category_feed/views/widgets/collections_grid.dart index c50ec2a2d..e4e580166 100644 --- a/lib/features/category_feed/views/widgets/collections_grid.dart +++ b/lib/features/category_feed/views/widgets/collections_grid.dart @@ -155,7 +155,7 @@ class _CollectionTileSkeletonState extends State<_CollectionTileSkeleton> with S } } -class _CollectionsGridState extends State { +class _CollectionsGridState extends State with TickerProviderStateMixin { Future _handleCollectionTap({required bool isPremium, required String collectionName}) async { final String normalizedCollectionName = collectionName.trim().toLowerCase(); if (!isPremium) { diff --git a/lib/features/navigation/views/widgets/personalized_feed_settings_bottom_sheet.dart b/lib/features/navigation/views/widgets/personalized_feed_settings_bottom_sheet.dart index 55cb06d90..7f4ec3d42 100644 --- a/lib/features/navigation/views/widgets/personalized_feed_settings_bottom_sheet.dart +++ b/lib/features/navigation/views/widgets/personalized_feed_settings_bottom_sheet.dart @@ -6,11 +6,13 @@ import 'package:Prism/core/state/app_state.dart' as app_state; import 'package:Prism/core/utils/result.dart'; import 'package:Prism/features/onboarding_v2/src/domain/usecases/save_interests_usecase.dart'; import 'package:Prism/features/onboarding_v2/src/utils/onboarding_v2_config.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/material.dart'; -/// Opens the same "Your feed" interests / mix sheet used from the home tab logo. +const String _kDefaultFeedMix = 'balanced'; + Future openPersonalizedFeedSettingsBottomSheet(BuildContext context, {VoidCallback? onPreferencesSaved}) async { final SettingsLocalDataSource settingsLocal = getIt(); final List catalog = await PersonalizedInterestsCatalog.load( @@ -25,7 +27,7 @@ Future openPersonalizedFeedSettingsBottomSheet(BuildContext context, {Void if (selected.isEmpty) { selected = PersonalizedInterestsCatalog.defaultSelection(catalog).toSet(); } - final String currentMix = settingsLocal.get(personalizedFeedMixLocalKey, defaultValue: 'balanced'); + final String currentMix = settingsLocal.get(personalizedFeedMixLocalKey, defaultValue: _kDefaultFeedMix); if (!context.mounted) { return; @@ -96,66 +98,80 @@ class _PersonalizedFeedSettingsSheetState extends State defaults = PersonalizedInterestsCatalog.defaultSelection(widget.catalog); setState(() { _selectedInterests = defaults.toSet(); - _feedMix = 'balanced'; + _feedMix = _kDefaultFeedMix; }); } Future _save() async { - if (!_canSave) { - return; - } + if (!_canSave) return; setState(() => _saving = true); await widget.onSave(_selectedInterests.toList(growable: false), _feedMix); - if (mounted) { - Navigator.of(context).pop(); - } + if (mounted) Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final ColorScheme cs = Theme.of(context).colorScheme; - final TextTheme tt = Theme.of(context).textTheme; final int count = _selectedInterests.length; final bool belowMin = count < OnboardingV2Config.minInterests; final double keyboardPadding = MediaQuery.viewInsetsOf(context).bottom; + // Count badge color: pink when something is selected, error when below min, + // muted when nothing is selected yet. + final Color countColor = belowMin + ? cs.error + : count > 0 + ? PrismColors.brandPink + : cs.onSurfaceVariant; + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 12), + const SizedBox(height: PrismBottomSheet.topGap), const _DragHandle(), - const SizedBox(height: 16), + const SizedBox(height: PrismBottomSheet.headerGap), + + // -- Header ----------------------------------------------------------- Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: PrismBottomSheet.horizontalPadding), child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - Text('Your feed', style: tt.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + Text('Your feed', style: PrismTextStyles.sheetTitle(context)), const Spacer(), - Text('$count selected', style: tt.bodySmall?.copyWith(color: belowMin ? cs.error : cs.onSurfaceVariant)), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: PrismTextStyles.sheetSectionLabel(context).copyWith(color: countColor), + child: Text('$count selected'), + ), ], ), ), - const SizedBox(height: 16), + const SizedBox(height: PrismBottomSheet.headerGap), + + // -- Interests -------------------------------------------------------- Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 4), - child: Text('Interests', style: tt.labelLarge?.copyWith(color: cs.onSurfaceVariant)), + padding: const EdgeInsets.only( + left: PrismBottomSheet.horizontalPadding, + right: PrismBottomSheet.horizontalPadding, + bottom: PrismBottomSheet.sectionLabelBottomGap, + ), + child: Text('Interests', style: PrismTextStyles.sheetSectionLabel(context)), ), Flexible( child: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: PrismBottomSheet.chipAreaPadding, child: Wrap( - spacing: 8, - runSpacing: 8, + spacing: PrismBottomSheet.chipSpacing, + runSpacing: PrismBottomSheet.chipRunSpacing, children: [ for (final PersonalizedInterest entry in widget.catalog) - FilterChip( - label: Text(entry.name), + _InterestChip( + entry: entry, selected: _selectedInterests.contains(entry.name), - avatar: CircleAvatar(backgroundImage: CachedNetworkImageProvider(entry.imageUrl)), - onSelected: (bool selected) { + onToggle: (bool selected) { setState(() { if (selected) { _selectedInterests.add(entry.name); @@ -169,36 +185,108 @@ class _PersonalizedFeedSettingsSheetState extends State setState(() => _feedMix = v)), ), - const Divider(height: 1, indent: 20, endIndent: 20), + + // -- Action bar ------------------------------------------------------- + const Divider( + height: 1, + indent: PrismBottomSheet.horizontalPadding, + endIndent: PrismBottomSheet.horizontalPadding, + ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: PrismBottomSheet.horizontalPadding, + vertical: PrismBottomSheet.actionsVerticalPadding, + ), child: Row( children: [ - TextButton(onPressed: _saving ? null : _resetToDefaults, child: const Text('Reset to defaults')), + TextButton( + onPressed: _saving ? null : _resetToDefaults, + style: TextButton.styleFrom(foregroundColor: PrismColors.brandPink), + child: const Text('Reset to defaults'), + ), const Spacer(), FilledButton( onPressed: _canSave ? _save : null, + style: FilledButton.styleFrom( + backgroundColor: PrismColors.brandPink, + foregroundColor: PrismColors.onPrimary, + ), child: _saving - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox( + width: PrismBottomSheet.savingIndicatorSize, + height: PrismBottomSheet.savingIndicatorSize, + child: CircularProgressIndicator( + strokeWidth: PrismBottomSheet.savingIndicatorStrokeWidth, + color: PrismColors.onPrimary, + ), + ) : const Text('Save'), ), ], ), ), - SizedBox(height: keyboardPadding + 8), + SizedBox(height: keyboardPadding + PrismBottomSheet.keyboardSafetyBuffer), ], ); } } +/// A single interest chip that always uses Prism brand pink for its selected +/// state, regardless of which theme is active. +class _InterestChip extends StatelessWidget { + const _InterestChip({required this.entry, required this.selected, required this.onToggle}); + + final PersonalizedInterest entry; + final bool selected; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + final ColorScheme cs = Theme.of(context).colorScheme; + + return FilterChip( + label: Text(entry.name), + selected: selected, + // Lock selected colours to Prism brand pink so they never go cyan/blue. + selectedColor: PrismColors.brandPink.withValues(alpha: 0.15), + checkmarkColor: PrismColors.brandPink, + side: BorderSide( + color: selected ? PrismColors.brandPink : cs.outline.withValues(alpha: 0.5), + width: selected ? 1.5 : 1.0, + ), + labelStyle: TextStyle( + color: selected ? PrismColors.brandPink : cs.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + ), + avatar: CircleAvatar( + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: CachedNetworkImageProvider(entry.imageUrl), + ), + onSelected: onToggle, + ); + } +} + +/// Feed-mix segmented button with explicit brand-pink selected state. class _FeedMixSelector extends StatelessWidget { const _FeedMixSelector({required this.value, required this.onChanged}); @@ -210,23 +298,36 @@ class _FeedMixSelector extends StatelessWidget { final ColorScheme cs = Theme.of(context).colorScheme; return SegmentedButton( segments: const >[ - ButtonSegment(value: 'balanced', label: Text('Balanced')), - ButtonSegment(value: 'creators', label: Text('Creators')), - ButtonSegment(value: 'discovery', label: Text('Discovery')), + ButtonSegment( + value: 'balanced', + label: Text('Balanced', maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ButtonSegment( + value: 'creators', + label: Text('Creators', maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ButtonSegment( + value: 'discovery', + label: Text('Discovery', maxLines: 1, overflow: TextOverflow.ellipsis), + ), ], selected: {value}, onSelectionChanged: (Set s) { - if (s.isNotEmpty) { - onChanged(s.first); - } + if (s.isNotEmpty) onChanged(s.first); }, expandedInsets: EdgeInsets.zero, style: ButtonStyle( + // Compact horizontal padding so "Discovery" never wraps on narrow screens. + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 8)), + // Brand pink replaces whatever cs.primary happens to be in the active theme. backgroundColor: WidgetStateProperty.resolveWith( - (Set states) => states.contains(WidgetState.selected) ? cs.primary : null, + (Set states) => states.contains(WidgetState.selected) ? PrismColors.brandPink : null, ), foregroundColor: WidgetStateProperty.resolveWith( - (Set states) => states.contains(WidgetState.selected) ? cs.onPrimary : cs.onSurface, + (Set states) => states.contains(WidgetState.selected) ? PrismColors.onPrimary : cs.onSurface, + ), + iconColor: WidgetStateProperty.resolveWith( + (Set states) => states.contains(WidgetState.selected) ? PrismColors.onPrimary : cs.onSurface, ), ), ); @@ -240,9 +341,12 @@ class _DragHandle extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Container( - height: 4, - width: 32, - decoration: BoxDecoration(color: Theme.of(context).hintColor, borderRadius: BorderRadius.circular(99)), + width: PrismBottomSheet.dragHandleWidth, + height: PrismBottomSheet.dragHandleHeight, + decoration: BoxDecoration( + color: Theme.of(context).hintColor, + borderRadius: BorderRadius.circular(PrismBottomSheet.dragHandleRadius), + ), ), ); } diff --git a/lib/features/navigation/views/widgets/prism_top_app_bar.dart b/lib/features/navigation/views/widgets/prism_top_app_bar.dart index 3e9eb13a6..5dcc9780b 100644 --- a/lib/features/navigation/views/widgets/prism_top_app_bar.dart +++ b/lib/features/navigation/views/widgets/prism_top_app_bar.dart @@ -2,7 +2,7 @@ import 'package:Prism/core/router/app_router.dart'; import 'package:Prism/core/state/app_state.dart' as app_state; import 'package:Prism/features/in_app_notifications/biz/bloc/in_app_notifications_bloc.j.dart'; import 'package:Prism/global/svgAssets.dart'; -import 'package:Prism/theme/jam_icons_icons.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -16,7 +16,7 @@ class PrismTopAppBar extends StatelessWidget implements PreferredSizeWidget { final VoidCallback onLogoTap; @override - Size get preferredSize => const Size.fromHeight(56); + Size get preferredSize => const Size.fromHeight(PrismAppBarSizes.height); @override Widget build(BuildContext context) { @@ -25,9 +25,9 @@ class PrismTopAppBar extends StatelessWidget implements PreferredSizeWidget { child: SafeArea( bottom: false, child: SizedBox( - height: 56, + height: PrismAppBarSizes.height, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), + padding: const EdgeInsets.symmetric(horizontal: PrismAppBarSizes.horizontalPadding), child: Row( children: [ BlocBuilder( @@ -44,18 +44,9 @@ class PrismTopAppBar extends StatelessWidget implements PreferredSizeWidget { children: [ _PrismLogo(), SizedBox(width: 4), - Text( - 'prism', - style: TextStyle( - fontFamily: 'Fraunces', - fontWeight: FontWeight.bold, - fontSize: 14, - color: Colors.white, - fontVariations: [FontVariation('WONK', 1)], - ), - ), + Text('prism', style: PrismTextStyles.brandName), SizedBox(width: 2), - Icon(Icons.expand_more_rounded, color: Colors.white, size: 16), + Icon(PrismIcons.dropdownCaret, color: PrismColors.onPrimary, size: PrismAppBarSizes.iconSize), ], ), ), @@ -80,7 +71,7 @@ class _PrismLogo extends StatelessWidget { prismVector, width: 10, height: 12, - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), + colorFilter: const ColorFilter.mode(PrismColors.onPrimary, BlendMode.srcIn), ); } } @@ -102,8 +93,8 @@ class _NotificationButton extends StatelessWidget { customBorder: const CircleBorder(), onTap: () => context.router.push(const NotificationRoute()), child: SizedBox( - width: 44, - height: 44, + width: PrismAppBarSizes.iconButtonTouchTarget, + height: PrismAppBarSizes.iconButtonTouchTarget, child: Stack( alignment: Alignment.center, children: [ @@ -111,8 +102,8 @@ class _NotificationButton extends StatelessWidget { left: 14, top: 14, child: Icon( - JamIcons.bell_f, - size: 16, + PrismIcons.notificationBell, + size: PrismAppBarSizes.iconSize, color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.75), ), ), @@ -121,12 +112,18 @@ class _NotificationButton extends StatelessWidget { top: 13, left: 24, child: Container( - width: 6, - height: 6, + width: PrismAppBarSizes.notificationBadgeSize, + height: PrismAppBarSizes.notificationBadgeSize, decoration: const BoxDecoration( shape: BoxShape.circle, - color: Color(0xFFFF69A9), - boxShadow: [BoxShadow(color: Color(0x80E57697), blurRadius: 4, spreadRadius: 1)], + color: PrismColors.brandPink, + boxShadow: [ + BoxShadow( + color: PrismColors.notificationBadgeShadow, + blurRadius: PrismAppBarSizes.notificationBadgeBlurRadius, + spreadRadius: PrismAppBarSizes.notificationBadgeSpreadRadius, + ), + ], ), ), ), @@ -148,11 +145,16 @@ class _ProfileAvatar extends StatelessWidget { return GestureDetector( onTap: () => context.router.push(ProfileRoute(profileIdentifier: app_state.prismUser.email)), child: Container( - width: 40, - height: 40, - padding: const EdgeInsets.all(8), + width: PrismAppBarSizes.profileAvatarSize, + height: PrismAppBarSizes.profileAvatarSize, + padding: const EdgeInsets.all(PrismAppBarSizes.profileAvatarInnerPadding), child: ClipOval( - child: CachedNetworkImage(imageUrl: photoUrl, fit: BoxFit.cover), + child: CachedNetworkImage( + imageUrl: photoUrl, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const ColoredBox(color: Colors.transparent, child: Icon(Icons.person, size: 24)), + ), ), ), ); diff --git a/lib/features/onboarding_v2/src/biz/onboarding_v2_bloc.j.dart b/lib/features/onboarding_v2/src/biz/onboarding_v2_bloc.j.dart index 0f83da866..39f5244f8 100644 --- a/lib/features/onboarding_v2/src/biz/onboarding_v2_bloc.j.dart +++ b/lib/features/onboarding_v2/src/biz/onboarding_v2_bloc.j.dart @@ -295,7 +295,9 @@ class OnboardingV2Bloc extends Bloc { final success = await _firstWallpaperService.performAction(wallpaperVm.fullUrl); final elapsedMs = DateTime.now().millisecondsSinceEpoch - startMs; - add(OnboardingV2Event.firstWallpaperActionCompleted(success: success, elapsedMs: elapsedMs)); + if (!isClosed) { + add(OnboardingV2Event.firstWallpaperActionCompleted(success: success, elapsedMs: elapsedMs)); + } } void _onFirstWallpaperActionCompleted(_FirstWallpaperActionCompleted event, Emitter emit) { diff --git a/lib/features/palette/domain/bloc/wallpaper_detail_bloc.dart b/lib/features/palette/domain/bloc/wallpaper_detail_bloc.dart index 402aad923..c5c0eab19 100644 --- a/lib/features/palette/domain/bloc/wallpaper_detail_bloc.dart +++ b/lib/features/palette/domain/bloc/wallpaper_detail_bloc.dart @@ -109,7 +109,10 @@ class WallpaperDetailBloc extends Bloc= colors.length) return; + final nextColor = colors[nextIndex]; emit(currentState.copyWith(accent: nextColor, colorChanged: true)); } diff --git a/lib/features/personalized_feed/views/pages/personalized_feed_screen.dart b/lib/features/personalized_feed/views/pages/personalized_feed_screen.dart index d4246cbe7..6b29bd714 100644 --- a/lib/features/personalized_feed/views/pages/personalized_feed_screen.dart +++ b/lib/features/personalized_feed/views/pages/personalized_feed_screen.dart @@ -15,6 +15,7 @@ import 'package:Prism/features/palette/domain/entities/wallpaper_detail_entity.d import 'package:Prism/features/personalized_feed/biz/bloc/personalized_feed_bloc.j.dart'; import 'package:Prism/features/personalized_feed/views/widgets/empty_card.dart'; import 'package:Prism/features/wall_of_the_day/wall_of_the_day.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider/carousel_slider.dart'; @@ -29,7 +30,7 @@ class PersonalizedFeedScreen extends StatefulWidget { } class _PersonalizedFeedScreenState extends State with AutomaticKeepAliveClientMixin { - static const int _carouselPreviewCount = 4; + static const int _carouselPreviewCount = PrismFeedLayout.carouselPreviewCount; late final PersonalizedFeedBloc _bloc; final ScrollController _scrollController = ScrollController(); @@ -60,7 +61,7 @@ class _PersonalizedFeedScreenState extends State with Au return; } - if (metrics.pixels >= metrics.maxScrollExtent - 400) { + if (metrics.pixels >= metrics.maxScrollExtent - PrismFeedLayout.prefetchThreshold) { _bloc.add(const PersonalizedFeedEvent.fetchMoreRequested()); } } @@ -83,7 +84,9 @@ class _PersonalizedFeedScreenState extends State with Au .whereType() .take(_carouselPreviewCount) .toList(growable: false); - final crossAxisCount = MediaQuery.of(context).orientation == Orientation.portrait ? 3 : 5; + final crossAxisCount = MediaQuery.of(context).orientation == Orientation.portrait + ? PrismFeedLayout.gridColumnCountPortrait + : PrismFeedLayout.gridColumnCountLandscape; final tileMemCacheHeight = ((MediaQuery.sizeOf(context).width / crossAxisCount) * 1.5 * 2).toInt(); if (state.status == LoadStatus.initial || (state.status == LoadStatus.loading && state.items.isEmpty)) { @@ -95,7 +98,7 @@ class _PersonalizedFeedScreenState extends State with Au onRefresh: () async => _bloc.add(const PersonalizedFeedEvent.refreshRequested()), child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(24, 120, 24, 32), + padding: PrismFeedLayout.errorStatePadding, children: [ PersonalizedFeedEditorialNote( title: "Couldn't load your feed", @@ -125,7 +128,7 @@ class _PersonalizedFeedScreenState extends State with Au SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - childAspectRatio: 0.5, + childAspectRatio: PrismFeedLayout.gridTileAspectRatio, ), delegate: SliverChildBuilderDelegate( (context, index) => WallpaperTile( @@ -150,7 +153,7 @@ class _PersonalizedFeedScreenState extends State with Au Widget _bottomState(BuildContext context, PersonalizedFeedState state) { if (state.items.isEmpty) { return const Padding( - padding: EdgeInsets.fromLTRB(24, 12, 24, 28), + padding: PrismFeedLayout.contentStatePadding, child: PersonalizedEmptyCard( title: 'Shape this feed', detail: 'Follow creators or choose interests so we can surface more of what you like.', @@ -160,17 +163,17 @@ class _PersonalizedFeedScreenState extends State with Au if (state.isFetchingMore) { return const Padding( - padding: EdgeInsets.fromLTRB(0, 8, 0, 26), - child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), + padding: PrismFeedLayout.loadingStatePadding, + child: Center(child: CircularProgressIndicator(strokeWidth: PrismFeedLayout.loadingIndicatorStrokeWidth)), ); } if (state.hasMore) { - return const SizedBox(height: 22); + return const SizedBox(height: PrismFeedLayout.endOfPageSpacerHeight); } return const Padding( - padding: EdgeInsets.fromLTRB(24, 12, 24, 28), + padding: PrismFeedLayout.contentStatePadding, child: PersonalizedEmptyCard( title: "You're caught up", detail: 'Pull down to refresh — new picks will land here.', @@ -208,7 +211,7 @@ class _FeedCarouselState extends State<_FeedCarousel> { @override Widget build(BuildContext context) { final previewWalls = widget.previewWalls; - final height = MediaQuery.of(context).size.width * 2 / 3; + final height = MediaQuery.of(context).size.width * PrismFeedLayout.carouselHeightRatio; return SizedBox( height: height, child: Stack( @@ -255,7 +258,9 @@ class _FeedCarouselState extends State<_FeedCarousel> { child: Center( child: ColoredBox( color: app_state.bannerTextOn - ? Theme.of(context).colorScheme.scrim.withValues(alpha: 0.45) + ? Theme.of( + context, + ).colorScheme.scrim.withValues(alpha: PrismOverlay.carouselBannerScrimAlpha) : Colors.transparent, child: Padding( padding: const EdgeInsets.all(8.0), @@ -263,12 +268,8 @@ class _FeedCarouselState extends State<_FeedCarousel> { app_state.bannerTextOn ? app_state.bannerText.toUpperCase() : "", textAlign: TextAlign.center, maxLines: 1, - style: Theme.of(context).textTheme.displayMedium!.copyWith( - fontSize: 20, - // High-contrast on arbitrary photography under [ColorScheme.scrim]. - color: Colors.white, - fontWeight: FontWeight.bold, - ), + // High-contrast on arbitrary photography under [ColorScheme.scrim]. + style: PrismTextStyles.carouselBannerHeadline(context), ), ), ), diff --git a/lib/features/personalized_feed/views/widgets/empty_card.dart b/lib/features/personalized_feed/views/widgets/empty_card.dart index 5313aaf69..6b6ad00df 100644 --- a/lib/features/personalized_feed/views/widgets/empty_card.dart +++ b/lib/features/personalized_feed/views/widgets/empty_card.dart @@ -1,3 +1,4 @@ +import 'package:Prism/theme/app_tokens.dart'; import 'package:flutter/material.dart'; /// Medium-like editorial note: typographic hierarchy, thin accent rule, no heavy card chrome. @@ -12,38 +13,33 @@ class PersonalizedFeedEditorialNote extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final accent = accentColor ?? scheme.outline; - final titleStyle = theme.textTheme.titleMedium?.copyWith( - color: scheme.onSurface, - fontWeight: FontWeight.w600, - height: 1.25, - ); - final detailStyle = theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant, height: 1.45); + final accent = accentColor ?? Theme.of(context).colorScheme.outline; return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), + constraints: const BoxConstraints(maxWidth: PrismEditorialNote.maxWidth), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: PrismEditorialNote.horizontalPadding), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 3, - height: 52, - decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)), + width: PrismEditorialNote.accentBarWidth, + height: PrismEditorialNote.accentBarHeight, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(PrismEditorialNote.accentBarBorderRadius), + ), ), - const SizedBox(width: 16), + const SizedBox(width: PrismEditorialNote.accentBarTextGap), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: titleStyle), + Text(title, style: PrismTextStyles.editorialTitle(context)), if (detail != null && detail!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text(detail!, style: detailStyle), + const SizedBox(height: PrismEditorialNote.titleDetailSpacing), + Text(detail!, style: PrismTextStyles.editorialDetail(context)), ], ], ), diff --git a/lib/features/profile_completeness/views/widgets/profile_completeness_card.dart b/lib/features/profile_completeness/views/widgets/profile_completeness_card.dart index ac5c0eb70..93b4ad295 100644 --- a/lib/features/profile_completeness/views/widgets/profile_completeness_card.dart +++ b/lib/features/profile_completeness/views/widgets/profile_completeness_card.dart @@ -1,96 +1,199 @@ +import 'dart:async'; + import 'package:Prism/core/profile/profile_completeness_evaluator.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; -class ProfileCompletenessCard extends StatelessWidget { +class ProfileCompletenessCard extends StatefulWidget { const ProfileCompletenessCard({super.key, required this.status, this.onCompleteNow}); final ProfileCompletenessStatus status; final Future Function()? onCompleteNow; + @override + State createState() => _ProfileCompletenessCardState(); +} + +class _ProfileCompletenessCardState extends State with SingleTickerProviderStateMixin { + late final AnimationController _entranceController; + late final Animation _fade; + late final Animation _slide; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _entranceController = AnimationController(vsync: this, duration: const Duration(milliseconds: 420)); + _fade = CurvedAnimation(parent: _entranceController, curve: Curves.easeOut); + _slide = Tween( + begin: const Offset(0, 0.06), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _entranceController, curve: Curves.easeOutQuart)); + _entranceController.forward(); + } + + @override + void dispose() { + _entranceController.dispose(); + super.dispose(); + } + + Future _handleComplete() async { + if (_isLoading || widget.onCompleteNow == null) return; + setState(() => _isLoading = true); + SemanticsService.announce('Opening profile editor', TextDirection.ltr); + try { + await widget.onCompleteNow!(); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + @override Widget build(BuildContext context) { - if (status.isComplete) { + if (widget.status.isComplete) { return const SizedBox.shrink(); } final ThemeData theme = Theme.of(context); - return Card( - color: theme.colorScheme.secondary.withValues(alpha: 0.06), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + final ColorScheme colorScheme = theme.colorScheme; + // Lerp from brand pink (start) toward primary as the profile fills up — + // keeps the ring on-brand at low completion and themed at 100%. + final Color progressColor = + Color.lerp(PrismColors.brandPink, colorScheme.primary, widget.status.progress.clamp(0.0, 1.0)) ?? + PrismColors.brandPink; + final int remainingSteps = (widget.status.totalSteps - widget.status.completedSteps).clamp( + 0, + widget.status.totalSteps, + ); + + return FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: Card( + elevation: 0, + // onSurface at low opacity is hue-neutral across all 12 themes — + // secondaryContainer picked up saturated tints from theme accents. + color: colorScheme.onSurface.withValues(alpha: 0.07), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( children: [ - SizedBox( - width: 52, - height: 52, - child: Stack( - fit: StackFit.expand, - children: [ - CircularProgressIndicator( - value: status.progress, - strokeWidth: 6, - backgroundColor: theme.colorScheme.secondary.withValues(alpha: 0.15), - valueColor: AlwaysStoppedAnimation(theme.colorScheme.error), - ), - Center( - child: Text( - '${status.percent}%', - style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), - ), - ), - ], - ), - ), + _ProgressRing(status: widget.status, progressColor: progressColor), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( - 'Profile completeness', - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + 'Profile ${widget.status.percent}% done', + style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 2), - Text( - '${status.completedSteps}/${status.totalSteps} completed • Earn 25 coins at 100%', - style: theme.textTheme.bodySmall, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + remainingSteps == 1 ? '1 step · ' : '$remainingSteps steps · ', + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const Icon( + Icons.monetization_on_rounded, + size: 12, + color: Colors.amber, + semanticLabel: 'coin', + ), + Text( + ' 25 coins', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], ), ], ), ), + const SizedBox(width: 10), + FilledButton( + onPressed: widget.onCompleteNow == null ? null : _handleComplete, + style: FilledButton.styleFrom( + backgroundColor: PrismColors.brandPink, + foregroundColor: PrismColors.onPrimary, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: _isLoading + ? SizedBox( + key: const ValueKey('loading'), + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary), + ) + : const Text('Finish', key: ValueKey('label')), + ), + ), ], ), - const SizedBox(height: 10), - ...status.missingSteps.map( - (step) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Icon(Icons.radio_button_unchecked, size: 14, color: theme.colorScheme.secondary), - const SizedBox(width: 8), - Expanded(child: Text(step.label, style: theme.textTheme.bodyMedium)), - ], - ), - ), + ), + ), + ), + ); + } +} + +class _ProgressRing extends StatelessWidget { + const _ProgressRing({required this.status, this.progressColor}); + + final ProfileCompletenessStatus status; + final Color? progressColor; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Semantics( + label: 'Profile ${status.percent}% complete', + value: '${status.percent}%', + child: SizedBox( + width: 44, + height: 44, + child: Stack( + fit: StackFit.expand, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0, end: status.progress), + duration: const Duration(milliseconds: 650), + curve: Curves.easeOutCubic, + builder: (BuildContext context, double value, Widget? child) { + return CircularProgressIndicator( + value: value, + strokeWidth: 4.5, + strokeCap: StrokeCap.round, + backgroundColor: theme.colorScheme.secondary.withValues(alpha: 0.15), + valueColor: AlwaysStoppedAnimation(progressColor ?? theme.colorScheme.primary), + ); + }, ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: ElevatedButton( - onPressed: onCompleteNow == null - ? null - : () async { - await onCompleteNow?.call(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE57697), - foregroundColor: Colors.white, + // Excluded from semantics — the outer Semantics node already + // conveys the percentage as a structured value. + Center( + child: ExcludeSemantics( + child: TweenAnimationBuilder( + tween: IntTween(begin: 0, end: status.percent), + duration: const Duration(milliseconds: 650), + curve: Curves.easeOutCubic, + builder: (BuildContext context, int value, Widget? child) { + return Text('$value%', style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w800)); + }, ), - child: const Text('Complete now'), ), ), ], diff --git a/lib/features/public_profile/views/pages/profile_screen.dart b/lib/features/public_profile/views/pages/profile_screen.dart index 6a8856687..df47957da 100644 --- a/lib/features/public_profile/views/pages/profile_screen.dart +++ b/lib/features/public_profile/views/pages/profile_screen.dart @@ -21,6 +21,7 @@ import 'package:Prism/features/user_blocks/domain/repositories/user_block_reposi import 'package:Prism/features/user_blocks/user_block_actions.dart'; import 'package:Prism/features/user_blocks/views/blocked_user_profile_shell.dart'; import 'package:Prism/global/svgAssets.dart'; +import 'package:Prism/theme/app_tokens.dart'; import 'package:Prism/theme/jam_icons_icons.dart'; import 'package:Prism/theme/toasts.dart' as toasts; import 'package:auto_route/auto_route.dart'; @@ -559,14 +560,15 @@ class _ProfileChildState extends State<_ProfileChild> { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontFamily: "Proxima Nova", + fontFamily: PrismFonts.proximaNova, color: Theme.of(context).colorScheme.secondary, fontSize: 22, - fontWeight: FontWeight.w500, + // w600 gives the name more presence over the muted username below. + fontWeight: FontWeight.w600, ), ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), SizedBox( width: MediaQuery.of(context).size.width * 0.7, child: Text( @@ -575,35 +577,41 @@ class _ProfileChildState extends State<_ProfileChild> { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontFamily: "Proxima Nova", - color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.6), - fontSize: 16, + fontFamily: PrismFonts.proximaNova, + color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.55), + fontSize: 14, fontWeight: FontWeight.normal, + letterSpacing: 0.2, ), ), ), const SizedBox(height: 8), - SizedBox( - width: MediaQuery.of(context).size.width * 0.7, - child: Text( - widget.bio ?? "", - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: "Proxima Nova", - color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.6), - fontSize: 14, - fontWeight: FontWeight.normal, + if ((widget.bio ?? "").isNotEmpty) + SizedBox( + width: MediaQuery.of(context).size.width * 0.72, + child: Text( + widget.bio!, + textAlign: TextAlign.center, + // 2 lines: bios up to 150 chars deserve more space. + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.65), + fontSize: 13, + fontWeight: FontWeight.normal, + height: 1.45, + ), ), ), - ), + if ((widget.bio ?? "").isNotEmpty) const SizedBox(height: 2), const SizedBox(height: 8), SizedBox( width: MediaQuery.of(context).size.width * 0.7, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ + // Following count — tappable on own profile only // Following count — tappable on own profile only GestureDetector( onTap: (widget.ownProfile ?? false) && app_state.prismUser.loggedIn @@ -615,33 +623,17 @@ class _ProfileChildState extends State<_ProfileChild> { ), ) : null, - child: RichText( - text: TextSpan( - text: "${(widget.following ?? []).length}", - style: TextStyle( - fontFamily: "Proxima Nova", - color: Theme.of(context).colorScheme.secondary.withValues(alpha: 1), - fontSize: 16, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: " Following", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.6), - fontWeight: FontWeight.normal, - ), - ), - ], - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: _StatPill( + count: (widget.following ?? []).length, + label: 'Following', ), ), - const SizedBox(width: 24), + Container( + width: 1, + height: 16, + margin: const EdgeInsets.symmetric(horizontal: 16), + color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.2), + ), // Followers count — tappable on both own and other profiles GestureDetector( onTap: () => context.router.push( @@ -651,30 +643,9 @@ class _ProfileChildState extends State<_ProfileChild> { ), ), ), - child: RichText( - text: TextSpan( - text: "${(widget.followers ?? []).length}", - style: TextStyle( - fontFamily: "Proxima Nova", - color: Theme.of(context).colorScheme.secondary.withValues(alpha: 1), - fontSize: 16, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: " Followers", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.6), - fontWeight: FontWeight.normal, - ), - ), - ], - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: _StatPill( + count: (widget.followers ?? []).length, + label: 'Followers', ), ), ], @@ -692,21 +663,26 @@ class _ProfileChildState extends State<_ProfileChild> { .toList() .map( (e) => IconButton( - padding: const EdgeInsets.all(2), + padding: const EdgeInsets.all(4), icon: Container( - padding: const EdgeInsets.all(6.0), + padding: const EdgeInsets.all(7.0), decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of( context, ).colorScheme.secondary.withValues(alpha: 0.1), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.12), + ), ), child: Icon( linksIconData[e.toString()] ?? JamIcons.link, - size: 20, + size: 18, color: Theme.of( context, - ).colorScheme.secondary.withValues(alpha: 0.8), + ).colorScheme.secondary.withValues(alpha: 0.85), ), ), onPressed: () async { @@ -744,21 +720,26 @@ class _ProfileChildState extends State<_ProfileChild> { ), if ((widget.links ?? {}).keys.toList().length > 3) IconButton( - padding: const EdgeInsets.all(2), + padding: const EdgeInsets.all(4), icon: Container( - padding: const EdgeInsets.all(6.0), + padding: const EdgeInsets.all(7.0), decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of( context, ).colorScheme.secondary.withValues(alpha: 0.1), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.12), + ), ), child: Icon( JamIcons.more_horizontal, - size: 20, + size: 18, color: Theme.of( context, - ).colorScheme.secondary.withValues(alpha: 0.8), + ).colorScheme.secondary.withValues(alpha: 0.85), ), ), onPressed: () { @@ -785,7 +766,9 @@ class _ProfileChildState extends State<_ProfileChild> { child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Theme.of(context).colorScheme.error, width: 4), + // Brand-locked pink ring — consistent with the + // camera badge in the edit panel. + border: Border.all(color: PrismColors.brandPink, width: 4), color: Theme.of(context).colorScheme.secondary, ), child: ClipOval( @@ -796,15 +779,12 @@ class _ProfileChildState extends State<_ProfileChild> { height: 78, fit: BoxFit.cover, ) - : Container( + : SizedBox( width: 78, height: 78, - color: Theme.of(context).primaryColor, - alignment: Alignment.center, - child: Icon( - JamIcons.user, - color: Theme.of(context).colorScheme.error, - size: 30, + child: ColoredBox( + color: Theme.of(context).primaryColor, + child: const Icon(JamIcons.user, color: PrismColors.brandPink, size: 30), ), ), ), @@ -898,6 +878,48 @@ class _ProfileChildState extends State<_ProfileChild> { } } +/// Compact stat display used in the profile header (e.g. "9182 Followers"). +/// +/// Separates the bold count from the muted label using clear weight and color +/// contrast — no size difference needed since both are on one line. +class _StatPill extends StatelessWidget { + const _StatPill({required this.count, required this.label}); + + final int count; + final String label; + + @override + Widget build(BuildContext context) { + final secondary = Theme.of(context).colorScheme.secondary; + return RichText( + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + // Bold count — larger optical weight draws the eye first. + text: count >= 1000 ? '${(count / 1000).toStringAsFixed(count >= 10000 ? 0 : 1)}k' : '$count', + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + color: secondary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: ' $label', + style: TextStyle( + fontFamily: PrismFonts.proximaNova, + color: secondary.withValues(alpha: 0.55), + fontSize: 13, + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ); + } +} + Map linksIconData = { 'github': JamIcons.github, 'twitter': JamIcons.twitter, diff --git a/lib/theme/app_tokens.dart b/lib/theme/app_tokens.dart new file mode 100644 index 000000000..d358b5fd9 --- /dev/null +++ b/lib/theme/app_tokens.dart @@ -0,0 +1,483 @@ +import 'package:Prism/theme/jam_icons_icons.dart'; +import 'package:flutter/material.dart'; + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +/// Hard-coded semantic color tokens for Prism UI chrome. +/// +/// Prefer [Theme.of(context).colorScheme] for surface/content colors that +/// change with the active theme. Use [PrismColors] only for values that must +/// remain constant across all themes — e.g. brand accents and overlay helpers. +abstract final class PrismColors { + /// Brand pink — notification badge fill and primary accent in the default + /// theme's color scheme. + static const Color brandPink = Color(0xFFFF69A9); + + /// Semi-transparent brand pink used as a glow shadow on the notification dot. + static const Color notificationBadgeShadow = Color(0x80E57697); + + /// Foreground color on primary / app-bar surfaces. + /// Always white so that content stays legible regardless of the active theme. + static const Color onPrimary = Colors.white; +} + +// --------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------- + +/// Font family name constants. +/// +/// Keep font strings in one place so renaming a family only requires one edit. +abstract final class PrismFonts { + static const String proximaNova = 'Proxima Nova'; + static const String fraunces = 'Fraunces'; + static const String roboto = 'Roboto'; +} + +// --------------------------------------------------------------------------- +// Text styles +// --------------------------------------------------------------------------- + +/// Pre-built text styles for recurring chrome and editorial patterns. +/// +/// Where a style must adapt to the active theme use the static helper methods +/// (which accept a [BuildContext]). Purely structural styles that do not vary +/// by theme are exposed as `const` values. +abstract final class PrismTextStyles { + /// Brand wordmark ("prism") shown in the top app-bar. + /// + /// Uses the Fraunces variable font with the WONK axis set to maximum + /// to achieve the characteristic Prism logo look. + static const TextStyle brandName = TextStyle( + fontFamily: PrismFonts.fraunces, + fontWeight: FontWeight.bold, + fontSize: 14, + color: PrismColors.onPrimary, + fontVariations: [FontVariation('WONK', 1)], + ); + + /// Large bold headline overlaid on full-bleed carousel banners. + /// + /// Inherits the theme's [displayMedium] as a base so that font family and + /// letter-spacing stay consistent, then overrides size, weight, and color + /// for legibility on arbitrary photography. + static TextStyle carouselBannerHeadline(BuildContext context) { + return (Theme.of(context).textTheme.displayMedium ?? const TextStyle()).copyWith( + fontSize: PrismFeedLayout.carouselBannerFontSize, + color: PrismColors.onPrimary, + fontWeight: FontWeight.bold, + ); + } + + /// Primary label in editorial note / empty-state cards. + static TextStyle editorialTitle(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.titleMedium ?? const TextStyle()).copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + height: 1.25, + ); + } + + /// Supporting body copy in editorial note / empty-state cards. + static TextStyle editorialDetail(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.bodyMedium ?? const TextStyle()).copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ); + } + + /// AppBar title for full-screen edit panels (e.g. "Edit Profile"). + /// + /// Fraunces at 17 sp keeps the branded feel while fitting comfortably in an + /// AppBar without overpowering the content below. Use [sheetTitle] (20 sp) + /// for modal bottom-sheet headers where more vertical space is available. + static TextStyle panelTitle(BuildContext context) { + return TextStyle( + fontFamily: PrismFonts.fraunces, + fontWeight: FontWeight.bold, + fontSize: 17, + color: Theme.of(context).colorScheme.onSurface, + ); + } + + /// Primary headline for bottom-sheet and panel headers (e.g. "Your feed"). + /// + /// Uses the Fraunces brand font — the same family as the app-bar wordmark — + /// so every sheet feels like a first-class Prism surface. No WONK variation + /// at this display size; the natural Fraunces character is expressive enough. + static TextStyle sheetTitle(BuildContext context) { + return TextStyle( + fontFamily: PrismFonts.fraunces, + fontWeight: FontWeight.bold, + fontSize: 20, + color: Theme.of(context).colorScheme.onSurface, + ); + } + + /// Muted section label used inside bottom sheets and settings panels. + /// + /// Uses [bodyMedium] (14 sp, Proxima Nova w500) as the base so it sits + /// clearly below the [sheetTitle] in the hierarchy — avoiding the inverted + /// weight that occurs when [labelLarge] (16 sp w800) is used here instead. + /// A subtle letter-spacing gives it the editorial label feel without weight. + static TextStyle sheetSectionLabel(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.bodyMedium ?? const TextStyle()).copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, + ); + } + + /// Standard input text inside form fields (name, username, bio, link). + static TextStyle fieldInput(BuildContext context) { + return TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismFormField.inputFontSize, + color: Theme.of(context).colorScheme.secondary, + ); + } + + /// Slightly smaller input text for compact field contexts (e.g. link row). + static TextStyle fieldInputSmall(BuildContext context) { + return TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: PrismFormField.inputFontSizeSmall, + color: Theme.of(context).colorScheme.secondary, + ); + } + + /// Small muted caption below form fields (e.g. username constraints hint). + static TextStyle fieldCaption(BuildContext context) { + return TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: 12, + color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.4), + height: 1.5, + ); + } + + /// Overlay label on top of photography (e.g. "Edit cover" hint). + static const TextStyle photoOverlayLabel = TextStyle( + fontFamily: PrismFonts.proximaNova, + fontSize: 12, + fontWeight: FontWeight.w600, + color: PrismColors.onPrimary, + ); +} + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +/// Icon data constants so individual widgets don't scatter icon literals. +/// +/// Centralising icon choices makes it easy to swap an icon app-wide or audit +/// which icons the app uses. +abstract final class PrismIcons { + /// Filled bell — primary notification icon used in the app-bar and surfaces + /// that surface notification state. + static const IconData notificationBell = JamIcons.bell_f; + + /// Trailing chevron indicating a dropdown / expandable section. + static const IconData dropdownCaret = Icons.expand_more_rounded; +} + +// --------------------------------------------------------------------------- +// App-bar sizes +// --------------------------------------------------------------------------- + +/// Fixed-size dimensions for app-bar chrome. +/// +/// Heights and touch targets follow Material 3 guidance. Adjust these values +/// to tune the entire app-bar in one place. +abstract final class PrismAppBarSizes { + /// Total height of the Prism custom app-bar (excluding status bar inset). + static const double height = 56; + + /// Symmetric horizontal padding inside the app-bar row. + static const double horizontalPadding = 18; + + /// Touch-target size for icon buttons (44 × 44 px meets a11y minimums). + static const double iconButtonTouchTarget = 44; + + /// Visual icon size inside app-bar buttons. + static const double iconSize = 16; + + // -- Profile avatar -------------------------------------------------------- + + static const double profileAvatarSize = 40; + static const double profileAvatarInnerPadding = 8; + + // -- Notification badge dot ------------------------------------------------ + + static const double notificationBadgeSize = 6; + static const double notificationBadgeBlurRadius = 4; + static const double notificationBadgeSpreadRadius = 1; +} + +// --------------------------------------------------------------------------- +// Feed layout +// --------------------------------------------------------------------------- + +/// Layout constants for the personalized feed carousel and wallpaper grid. +abstract final class PrismFeedLayout { + // -- Carousel -------------------------------------------------------------- + + /// Carousel height = screen width × this ratio (2 : 3 portrait aspect). + static const double carouselHeightRatio = 2 / 3; + + /// Number of wallpaper previews shown inside the carousel. + static const int carouselPreviewCount = 4; + + /// Font size for the carousel banner overlay headline. + static const double carouselBannerFontSize = 20; + + // -- Grid ------------------------------------------------------------------ + + /// Grid tile aspect ratio (width : height). + static const double gridTileAspectRatio = 0.5; + + /// Number of grid columns in portrait orientation. + static const int gridColumnCountPortrait = 3; + + /// Number of grid columns in landscape orientation. + static const int gridColumnCountLandscape = 5; + + /// How many logical pixels from the scroll end to trigger next-page fetch. + static const double prefetchThreshold = 400; + + /// Stroke width for the inline "fetching more" progress indicator. + static const double loadingIndicatorStrokeWidth = 2.4; + + // -- Padding presets ------------------------------------------------------- + + /// Padding for the error state (generous top space pushes the note to + /// roughly the vertical centre of the visible area). + static const EdgeInsets errorStatePadding = EdgeInsets.fromLTRB(24, 120, 24, 32); + + /// Padding for empty / end-of-feed messages. + static const EdgeInsets contentStatePadding = EdgeInsets.fromLTRB(24, 12, 24, 28); + + /// Padding wrapping the inline "fetching more" spinner. + static const EdgeInsets loadingStatePadding = EdgeInsets.fromLTRB(0, 8, 0, 26); + + /// Minimal spacer appended when the feed still has more pages to load. + static const double endOfPageSpacerHeight = 22; +} + +// --------------------------------------------------------------------------- +// Editorial note / empty-state cards +// --------------------------------------------------------------------------- + +/// Visual parameters for the `PersonalizedFeedEditorialNote` pattern. +/// +/// Reuse these constants whenever you build a typographic call-out that follows +/// the same accent-bar + text column layout anywhere in the app. +abstract final class PrismEditorialNote { + // -- Accent bar ------------------------------------------------------------ + + static const double accentBarWidth = 3; + static const double accentBarHeight = 52; + static const double accentBarBorderRadius = 2; + + /// Horizontal gap between the accent bar and the text column. + static const double accentBarTextGap = 16; + + // -- Text ------------------------------------------------------------------ + + /// Vertical spacing between the title and the detail paragraph. + static const double titleDetailSpacing = 8; + + // -- Container ------------------------------------------------------------- + + /// Maximum width of the note container — keeps line length readable on + /// wider screens and tablets. + static const double maxWidth = 360; + + /// Symmetric horizontal padding inside the note container. + static const double horizontalPadding = 8; +} + +// --------------------------------------------------------------------------- +// Overlay / scrim +// --------------------------------------------------------------------------- + +/// Opacity values for overlay layers applied on top of imagery. +abstract final class PrismOverlay { + /// Scrim alpha for the banner text overlay on top of carousel photography. + static const double carouselBannerScrimAlpha = 0.45; +} + +// --------------------------------------------------------------------------- +// Bottom sheet chrome +// --------------------------------------------------------------------------- + +/// Layout and sizing tokens for modal bottom sheets. +/// +/// Keeping these in one place ensures all sheets share the same visual +/// language — drag handles, section labels, action bars, and spacing all +/// derive from here. +abstract final class PrismBottomSheet { + // -- Drag handle ----------------------------------------------------------- + + static const double dragHandleWidth = 32; + static const double dragHandleHeight = 4; + + /// Fully-rounded pill radius for the drag handle. + static const double dragHandleRadius = 99; + + // -- Vertical rhythm ------------------------------------------------------- + + /// Space between the sheet top edge and the drag handle. + static const double topGap = 12; + + /// Space between the drag handle and the first content section. + static const double headerGap = 16; + + /// Vertical gap inserted before each new section heading. + static const double sectionTopGap = 16; + + /// Space between a section label and the content below it. + static const double sectionLabelBottomGap = 4; + + // -- Horizontal padding ---------------------------------------------------- + + /// Consistent horizontal inset used for headers, section labels, and + /// the action bar — keeps the vertical rhythm of the whole sheet aligned. + static const double horizontalPadding = 20; + + // -- Interest chips -------------------------------------------------------- + + static const double chipSpacing = 8; + static const double chipRunSpacing = 8; + + /// Uniform padding inside the scrollable chip area. + static const EdgeInsets chipAreaPadding = EdgeInsets.all(16); + + // -- Action bar ------------------------------------------------------------ + + /// Vertical padding inside the action bar row. + static const double actionsVerticalPadding = 12; + + // -- Saving spinner -------------------------------------------------------- + + /// Size of the inline saving indicator inside the "Save" button. + static const double savingIndicatorSize = 16; + + /// Stroke width for the inline saving spinner. + static const double savingIndicatorStrokeWidth = 2; + + // -- Keyboard avoidance ---------------------------------------------------- + + /// Extra bottom clearance added on top of the keyboard inset. + static const double keyboardSafetyBuffer = 8; +} + +// --------------------------------------------------------------------------- +// Form fields +// --------------------------------------------------------------------------- + +/// Dimensions and opacities shared by all text-input fields across the app. +abstract final class PrismFormField { + static const double borderRadius = 8; + static const double borderWidth = 1.5; + + /// Opacity of the resting (unfocused) border. + static const double restingBorderOpacity = 0.22; + + /// Opacity of field labels in their resting state. + static const double labelOpacity = 0.65; + + /// Opacity of hint / placeholder text. + static const double hintOpacity = 0.45; + + /// Opacity of trailing/prefix icons inside fields. + static const double iconOpacity = 0.4; + + static const EdgeInsets contentPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 18); + + static const double inputFontSize = 15; + static const double inputFontSizeSmall = 14; + static const double labelFontSize = 14; + static const double hintFontSize = 13; + + /// Width/height of the username-availability icon well. + static const double availabilityIndicatorSize = 48; + + /// Size of the username-check icon (tick / cross). + static const double availabilityIconSize = 20; + + /// Size of the checking spinner. + static const double availabilitySpinnerSize = 18; +} + +// --------------------------------------------------------------------------- +// Profile editing +// --------------------------------------------------------------------------- + +/// Dimensions and spacing for the edit-profile screen. +abstract final class PrismProfile { + // -- Avatar ---------------------------------------------------------------- + + static const double avatarSize = 88; + + /// How far the avatar overlaps below the cover image. + static const double avatarOverlap = 44; + static const double avatarBorderWidth = 3; + + // -- Camera chip (avatar badge) -------------------------------------------- + + static const double cameraChipSize = 26; + static const double cameraChipBorderWidth = 2; + static const double cameraChipIconSize = 13; + + // -- Cover area ------------------------------------------------------------ + + static const double coverScrimHeight = 52; + static const double coverEditIconSize = 15; + static const double coverEditIconGap = 6; + + // -- Remove chip (top-right corner of cover) ------------------------------- + + static const double removeChipSize = 32; + static const double removeChipIconSize = 15; + static const double removeChipScrimAlpha = 0.45; + static const double removeChipPositionOffset = 10; + + // -- Form field vertical rhythm -------------------------------------------- + + static const double fieldGap = 12; + static const double preSaveGap = 28; + static const double postSaveGap = 16; + static const double bottomPadding = 40; + + // -- Link row -------------------------------------------------------------- + + static const double linkSelectorHeight = 56; + static const double linkSelectorHorizontalPadding = 12; + static const double linkSelectorGap = 10; + static const double linkSelectorIconSize = 20; + static const double linkSelectorCaretSize = 12; + static const double linkDropdownIconSize = 18; + static const double linkDropdownTextGap = 12; + static const double linkDropdownFontSize = 14; + + // -- Save button ----------------------------------------------------------- + + static const double saveButtonHeight = 56; + static const double savingIndicatorSize = 22; + static const double savingIndicatorStrokeWidth = 2.5; + + // -- Dialog ---------------------------------------------------------------- + + static const double dialogBorderRadius = 16; + static const double dialogButtonRadius = 8; + static const double dialogTitleFontSize = 17; + static const double dialogBodyFontSize = 14; + static const double dialogBodyOpacity = 0.65; +} diff --git a/pubspec.yaml b/pubspec.yaml index cba486f7a..4277e9363 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.0.7+330 +version: 3.0.8+331 environment: sdk: ">=3.8.0 <4.0.0" diff --git a/test/features/profile_completeness/profile_completeness_widgets_test.dart b/test/features/profile_completeness/profile_completeness_widgets_test.dart index 9e2d8276c..5c9a2e222 100644 --- a/test/features/profile_completeness/profile_completeness_widgets_test.dart +++ b/test/features/profile_completeness/profile_completeness_widgets_test.dart @@ -19,10 +19,10 @@ void main() { ); await tester.pumpWidget(_wrap(const ProfileCompletenessCard(status: status))); + await tester.pump(const Duration(milliseconds: 700)); expect(find.text('50%'), findsOneWidget); - expect(find.text('Write bio'), findsOneWidget); - expect(find.text('Add one social link'), findsOneWidget); + expect(find.text('2 steps · '), findsOneWidget); }); testWidgets('card is hidden when status is 100% complete', (tester) async { @@ -65,7 +65,7 @@ void main() { ), ); - await tester.tap(find.text('Complete now')); + await tester.tap(find.text('Finish')); await tester.pumpAndSettle(); expect(tapped, isTrue);