This commit is contained in:
Dmitry 2025-11-21 19:59:11 +03:00
parent baaa08e98e
commit be55610403
20 changed files with 460 additions and 166 deletions

2
.vscode/launch.json vendored
View file

@ -131,7 +131,7 @@
}, },
{ {
"name": "mnemo_cards_web", "name": "mnemo_cards_web",
"cwd": "mnemo_cards_web", "cwd": "mnemo_cards_web_v2",
"request": "launch", "request": "launch",
"type": "dart" "type": "dart"
}, },

View file

@ -1,5 +1,42 @@
# Progress Log # Progress Log
## 2025-01-XX - Buy Page Access Without Authentication ✅ COMPLETED
**Feature:** Allow unauthenticated access to pack buy page endpoint
**Completed Tasks:**
- ✅ Modified `/api/v2/packs/<packId>/buy` GET endpoint to work without authentication
- ✅ Unauthenticated users can now view buy page using `getPublicBuyPage`
- ✅ Authenticated users still use `getBuyPage` with ownership checks
- ✅ POST endpoint `/api/v2/purchases/packs/<packId>` still requires authentication (for payment creation)
- ✅ Added comprehensive unit tests for buy page endpoint covering all scenarios
**Technical Implementation:**
- **GET `/api/v2/packs/<packId>/buy`**: Now accessible without authentication
- Unauthenticated users: Returns public buy page via `PackManager.getPublicBuyPage()`
- Authenticated users: Returns buy page with ownership check via `PackManager.getBuyPage()`
- Returns 409 Conflict if authenticated user already owns the pack
- **POST `/api/v2/purchases/packs/<packId>`**: Still requires authentication (unchanged)
- This endpoint creates the actual payment, so authentication is required
**Error Handling:**
- Invalid pack ID format: Returns 400 Bad Request
- Pack not found: Returns 404 Not Found
- Pack already purchased (authenticated): Returns 409 Conflict
- All errors properly handled for both authenticated and unauthenticated requests
**Tests Added:**
- Test for unauthenticated user accessing buy page
- Test for authenticated user without pack accessing buy page
- Test for authenticated user who already owns pack (409 Conflict)
- Test for non-existent pack (404) for both authenticated and unauthenticated
- Test for invalid pack ID format (400)
**Files Modified:**
- `lib/api/v2/packs_api_v2.dart` - Updated `getPackBuyPage` method
- `test/api/v2/packs_api_v2_test.dart` - Added comprehensive test suite
## 2025-11-16 (Evening) - Let's Encrypt SSL Certificate Setup ✅ COMPLETED ## 2025-11-16 (Evening) - Let's Encrypt SSL Certificate Setup ✅ COMPLETED
**Feature:** SSL Certificate Configuration for API Domain (api.mnemo-cards.online) **Feature:** SSL Certificate Configuration for API Domain (api.mnemo-cards.online)

View file

@ -252,21 +252,46 @@ class PacksApiV2 {
/// GET /api/v2/packs/{packId}/buy /// GET /api/v2/packs/{packId}/buy
/// Returns pack purchase details (includes rewarded ads offer when available) /// Returns pack purchase details (includes rewarded ads offer when available)
/// Works for both authenticated and unauthenticated users
@Route.get('/packs/<packId>/buy') @Route.get('/packs/<packId>/buy')
@OpenApiRoute() @OpenApiRoute()
Future<Response> getPackBuyPage(Request request, String packId) async { Future<Response> getPackBuyPage(Request request, String packId) async {
try { try {
// Validate pack ID format first
final packIdInt = int.tryParse(packId);
if (packIdInt == null) {
return _badRequest('Invalid pack ID');
}
final user = request.user; final user = request.user;
// For unauthenticated users, return public buy page
if (user == null) { if (user == null) {
return _unauthorized('Authentication required'); final buyDto = await _packManager.getPublicBuyPage(packId);
if (buyDto == null) {
return _notFound('Pack not found');
}
return _ok(buyDto.toJson());
} }
final buyDto = await _packManager.getBuyPage(packId, user); // For authenticated users, check if they already own the pack
if (buyDto == null) { try {
return _conflict('Pack already purchased'); final buyDto = await _packManager.getBuyPage(packId, user);
if (buyDto == null) {
return _conflict('Pack already purchased');
}
return _ok(buyDto.toJson());
} catch (e) {
// getBuyPage may throw if pack doesn't exist (via _fetchPackModel)
// _fetchPackModel uses ! operator which throws on null
final errorString = e.toString();
if (errorString.contains('Null check') ||
errorString.contains('null') ||
e is StateError) {
return _notFound('Pack not found');
}
rethrow;
} }
return _ok(buyDto.toJson());
} on FormatException catch (_) { } on FormatException catch (_) {
return _badRequest('Invalid pack ID'); return _badRequest('Invalid pack ID');
} on StateError catch (_) { } on StateError catch (_) {

View file

@ -649,5 +649,116 @@ void main() {
expect(responseBody['error'], equals('Not Found')); expect(responseBody['error'], equals('Not Found'));
}); });
}); });
group('PacksApiV2 - Get Pack Buy Page', () {
test('should return buy page for unauthenticated user', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
});
test('should return buy page for authenticated user without pack', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: testUser,
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
});
test('should return 409 for authenticated user who already owns pack', () async {
// First, add pack to user
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(11);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
}
}
});
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: updatedUser,
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(409));
expect(responseBody['error'], equals('Conflict'));
expect(responseBody['message'], equals('Pack already purchased'));
});
test('should return 404 for non-existent pack (unauthenticated)', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/999/buy',
);
final response = await packsApiV2.getPackBuyPage(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return 404 for non-existent pack (authenticated)', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/999/buy',
user: testUser,
);
final response = await packsApiV2.getPackBuyPage(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return 400 for invalid pack ID', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/invalid/buy',
);
final response = await packsApiV2.getPackBuyPage(request, 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
});
});
} }

View file

@ -144,7 +144,10 @@ class _HomePageState extends State<HomePage> {
), ),
itemCount: packs.length, itemCount: packs.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return PackCard(pack: packs[index]); return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: PackCard(pack: packs[index]),
);
}, },
); );
} else { } else {

View file

@ -272,36 +272,41 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
/// Builds the Buy Pack button /// Builds the Buy Pack button
Widget _buildBuyPackButton(Color packColor) { Widget _buildBuyPackButton(Color packColor) {
final colorScheme = Theme.of(context).colorScheme;
// Используем контрастный цвет для текста на цветном фоне
// Для темной темы используем белый, для светлой - тоже белый для контраста
final textColor = colorScheme.onPrimary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: FilledButton( child: FilledButton(
onPressed: _isNavigatingToPurchase ? null : _navigateToPurchase, onPressed: _isNavigatingToPurchase ? null : _navigateToPurchase,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: packColor, backgroundColor: packColor,
foregroundColor: Colors.white, foregroundColor: textColor,
minimumSize: const Size.fromHeight(56), minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
), ),
), ),
child: _isNavigatingToPurchase child: _isNavigatingToPurchase
? const SizedBox( ? SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), valueColor: AlwaysStoppedAnimation<Color>(textColor),
), ),
) )
: Row( : Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.shopping_cart, size: 20), Icon(Icons.shopping_cart, size: 20, color: textColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Buy Pack', 'Buy Pack',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white, color: textColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -486,20 +491,28 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
), ),
if (test.count > 0) ...[ if (test.count > 0) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Container( Builder(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), builder: (context) {
decoration: BoxDecoration( final colorScheme = Theme.of(context).colorScheme;
color: packColor, return Container(
borderRadius: BorderRadius.circular(10), padding: const EdgeInsets.symmetric(
), horizontal: 6,
child: Text( vertical: 2,
'${test.count}', ),
style: const TextStyle( decoration: BoxDecoration(
fontSize: 12, color: packColor,
fontWeight: FontWeight.w700, borderRadius: BorderRadius.circular(10),
color: Colors.white, ),
), child: Text(
), '${test.count}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
),
),
);
},
), ),
], ],
], ],
@ -767,21 +780,26 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
// Иконка избранного // Иконка избранного
GestureDetector( GestureDetector(
onTap: () async => await _toggleCardFavorite(card.id), onTap: () async => await _toggleCardFavorite(card.id),
child: Container( child: Builder(
padding: const EdgeInsets.all(4), builder: (context) {
decoration: BoxDecoration( final colorScheme = Theme.of(context).colorScheme;
color: Colors.white.withValues(alpha: 0.9), return Container(
borderRadius: BorderRadius.circular(4), padding: const EdgeInsets.all(4),
), decoration: BoxDecoration(
child: Icon( color: colorScheme.surface.withValues(alpha: 0.9),
_isCardFavorite(card.id) borderRadius: BorderRadius.circular(4),
? Icons.favorite ),
: Icons.favorite_border, child: Icon(
size: 16, _isCardFavorite(card.id)
color: _isCardFavorite(card.id) ? Icons.favorite
? Colors.red : Icons.favorite_border,
: Colors.grey, size: 16,
), color: _isCardFavorite(card.id)
? Colors.red
: colorScheme.onSurface.withOpacity(0.6),
),
);
},
), ),
), ),
@ -796,17 +814,22 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
name: 'PackDetailsPage', name: 'PackDetailsPage',
); );
}, },
child: Container( child: Builder(
padding: const EdgeInsets.all(4), builder: (context) {
decoration: BoxDecoration( final colorScheme = Theme.of(context).colorScheme;
color: Colors.white.withValues(alpha: 0.9), return Container(
borderRadius: BorderRadius.circular(4), padding: const EdgeInsets.all(4),
), decoration: BoxDecoration(
child: const Icon( color: colorScheme.surface.withValues(alpha: 0.9),
Icons.visibility_outlined, borderRadius: BorderRadius.circular(4),
size: 16, ),
color: Colors.grey, child: Icon(
), Icons.visibility_outlined,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
);
},
), ),
), ),
], ],

View file

@ -202,9 +202,9 @@ class _ProfilePageState extends State<ProfilePage> {
? user.email![0] ? user.email![0]
: 'U') : 'U')
.toUpperCase(), .toUpperCase(),
style: const TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
color: Colors.white, color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),

View file

@ -57,20 +57,33 @@ class _StatisticsPageState extends State<StatisticsPage> with TickerProviderStat
foregroundColor: theme.colorScheme.onSurface, foregroundColor: theme.colorScheme.onSurface,
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(48), preferredSize: const Size.fromHeight(48),
child: Container( child: Container(
color: theme.colorScheme.surface, decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: theme.dividerColor,
width: 1,
),
),
),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
isScrollable: true, isScrollable: true,
tabAlignment: TabAlignment.start, tabAlignment: TabAlignment.start,
labelColor: theme.colorScheme.primary, labelColor: theme.colorScheme.onSurface,
unselectedLabelColor: theme.colorScheme.onSurfaceVariant, unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
indicatorColor: theme.colorScheme.primary, indicatorColor: theme.colorScheme.primary,
indicatorWeight: 3, indicatorWeight: 3,
overlayColor: WidgetStateProperty.all(Colors.transparent),
dividerColor: Colors.transparent,
labelStyle: theme.textTheme.labelLarge?.copyWith( labelStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
), ),
unselectedLabelStyle: theme.textTheme.labelLarge,
tabs: const [ tabs: const [
Tab( Tab(
icon: Icon(Icons.dashboard_outlined), icon: Icon(Icons.dashboard_outlined),

View file

@ -90,15 +90,32 @@ class _TasksPageState extends State<TasksPage> with TickerProviderStateMixin {
), ),
// Tab bar // Tab bar
TabBar( Builder(
controller: _tabController, builder: (context) {
tabs: const [ final theme = Theme.of(context);
Tab(text: 'Все'), return TabBar(
Tab(text: 'Доступные'), controller: _tabController,
Tab(text: 'Выполненные'), labelColor: theme.colorScheme.onSurface,
], unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
onTap: (index) { indicatorColor: theme.colorScheme.primary,
// Handle tab change if needed indicatorWeight: 3,
dividerColor: Colors.transparent,
labelStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
tabs: const [
Tab(text: 'Все'),
Tab(text: 'Доступные'),
Tab(text: 'Выполненные'),
],
onTap: (index) {
// Handle tab change if needed
},
);
}, },
), ),
], ],

View file

@ -381,7 +381,17 @@ class _TestPageState extends State<TestPage> {
), ),
), ),
child: isSelected child: isSelected
? const Icon(Icons.check, color: Colors.white, size: 16) ? Builder(
builder: (context) {
final colorScheme =
Theme.of(context).colorScheme;
return Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
);
},
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),

View file

@ -39,6 +39,15 @@ class AppTheme {
foregroundColor: AppColors.black, foregroundColor: AppColors.black,
), ),
// TabBar тема для светлой темы
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.black,
unselectedLabelColor: AppColors.borderGray,
indicatorColor: AppColors.black,
labelStyle: TextStyle(fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
),
// Card тема - минимальная elevation, скругленные углы // Card тема - минимальная elevation, скругленные углы
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 1, elevation: 1,
@ -111,6 +120,10 @@ class AppTheme {
// TabBar тема для темной темы // TabBar тема для темной темы
tabBarTheme: const TabBarThemeData( tabBarTheme: const TabBarThemeData(
labelColor: AppColors.white, labelColor: AppColors.white,
unselectedLabelColor: Colors.grey,
indicatorColor: AppColors.white,
labelStyle: TextStyle(fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
), ),
// Card тема // Card тема

View file

@ -677,16 +677,19 @@ class _CardSide extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container( return Container(
width: width, width: width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: packColor, width: 2), border: Border.all(color: packColor, width: 2),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: colorScheme.shadow.withOpacity(0.1),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 5), offset: const Offset(0, 5),
), ),
@ -783,15 +786,20 @@ class _CardSide extends StatelessWidget {
if (card.translation != null && card.translation!.isNotEmpty) ...[ if (card.translation != null && card.translation!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
MnemoText( Builder(
card.translation, builder: (context) {
textStyle: const TextStyle( final colorScheme = Theme.of(context).colorScheme;
fontSize: 18, return MnemoText(
fontWeight: FontWeight.w400, card.translation,
color: Colors.black54, textStyle: TextStyle(
), fontSize: 18,
textAlign: TextAlign.center, fontWeight: FontWeight.w400,
maxLines: 2, color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
maxLines: 2,
);
},
), ),
], ],
], ],
@ -818,14 +826,19 @@ class _CardSide extends StatelessWidget {
maxLines: 4, maxLines: 4,
) )
else else
Text( Builder(
'Нет мнемоники', builder: (context) {
style: TextStyle( final colorScheme = Theme.of(context).colorScheme;
fontSize: 18, return Text(
fontWeight: FontWeight.w400, 'Нет мнемоники',
color: Colors.black38, style: TextStyle(
), fontSize: 18,
textAlign: TextAlign.center, fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.38),
),
textAlign: TextAlign.center,
);
},
), ),
], ],
), ),

View file

@ -60,9 +60,11 @@ class _CardViewerState extends State<CardViewer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final packColor = widget.packColor ?? AppColors.borderGray; final packColor = widget.packColor ?? AppColors.borderGray;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: theme.scaffoldBackgroundColor,
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
children: [ children: [
@ -109,12 +111,12 @@ class _CardViewerState extends State<CardViewer> {
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color: colorScheme.surface.withOpacity(0.7),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.close, Icons.close,
color: Colors.white, color: colorScheme.onSurface,
), ),
), ),
), ),
@ -130,13 +132,13 @@ class _CardViewerState extends State<CardViewer> {
vertical: 8, vertical: 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color: colorScheme.surface.withOpacity(0.7),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
'${_currentIndex + 1} / ${widget.cards.length}', '${_currentIndex + 1} / ${widget.cards.length}',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: colorScheme.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -230,12 +232,14 @@ class _CardSide extends StatelessWidget {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
final cardWidth = math.min(screenSize.width * 0.9, 600.0); final cardWidth = math.min(screenSize.width * 0.9, 600.0);
final cardHeight = math.min(screenSize.height * 0.7, 800.0); final cardHeight = math.min(screenSize.height * 0.7, 800.0);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container( return Container(
width: cardWidth, width: cardWidth,
height: cardHeight, height: cardHeight,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: colorScheme.surface,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: packColor, color: packColor,
@ -243,7 +247,7 @@ class _CardSide extends StatelessWidget {
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 10), offset: const Offset(0, 10),
), ),
@ -345,15 +349,20 @@ class _CardSide extends StatelessWidget {
if (card.translation != null && card.translation!.isNotEmpty) ...[ if (card.translation != null && card.translation!.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
MnemoText( Builder(
card.translation, builder: (context) {
textStyle: const TextStyle( final colorScheme = Theme.of(context).colorScheme;
fontSize: 24, return MnemoText(
fontWeight: FontWeight.w400, card.translation,
color: Colors.black54, textStyle: TextStyle(
), fontSize: 24,
textAlign: TextAlign.center, fontWeight: FontWeight.w400,
maxLines: 3, color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
maxLines: 3,
);
},
), ),
], ],
], ],
@ -380,14 +389,19 @@ class _CardSide extends StatelessWidget {
maxLines: 5, maxLines: 5,
) )
else else
Text( Builder(
'Нет мнемоники', builder: (context) {
style: TextStyle( final colorScheme = Theme.of(context).colorScheme;
fontSize: 24, return Text(
fontWeight: FontWeight.w400, 'Нет мнемоники',
color: Colors.black38, style: TextStyle(
), fontSize: 24,
textAlign: TextAlign.center, fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.38),
),
textAlign: TextAlign.center,
);
},
), ),
], ],
), ),

View file

@ -173,7 +173,17 @@ class AnswerOptions extends StatelessWidget {
child: child:
(isSelected || (isSelected ||
(isAnswerSubmitted && isCorrectOption)) (isAnswerSubmitted && isCorrectOption))
? Icon(Icons.check, size: 12, color: Colors.white) ? Builder(
builder: (context) {
final colorScheme =
Theme.of(context).colorScheme;
return Icon(
Icons.check,
size: 12,
color: colorScheme.onPrimary,
);
},
)
: null, : null,
), ),

View file

@ -21,7 +21,7 @@ class GameCardShimmer extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
), ),
), ),
// Text placeholders // Text placeholders
@ -34,7 +34,7 @@ class GameCardShimmer extends StatelessWidget {
height: 16, height: 16,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@ -43,7 +43,7 @@ class GameCardShimmer extends StatelessWidget {
height: 12, height: 12,
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),

View file

@ -34,7 +34,7 @@ class PackCardShimmer extends StatelessWidget {
height: cardHeight - 2, height: cardHeight - 2,
margin: const EdgeInsets.all(1), margin: const EdgeInsets.all(1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(11),
), ),
), ),
@ -54,7 +54,7 @@ class PackCardShimmer extends StatelessWidget {
height: 24, height: 24,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@ -64,7 +64,7 @@ class PackCardShimmer extends StatelessWidget {
height: 16, height: 16,
width: 150, width: 150,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@ -74,7 +74,7 @@ class PackCardShimmer extends StatelessWidget {
height: 14, height: 14,
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),

View file

@ -31,7 +31,7 @@ class PackCardVerticalShimmer extends StatelessWidget {
child: Container( child: Container(
margin: const EdgeInsets.all(1), margin: const EdgeInsets.all(1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(11), topLeft: Radius.circular(11),
topRight: Radius.circular(11), topRight: Radius.circular(11),
@ -54,7 +54,7 @@ class PackCardVerticalShimmer extends StatelessWidget {
height: 20, height: 20,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@ -64,7 +64,7 @@ class PackCardVerticalShimmer extends StatelessWidget {
height: 14, height: 14,
width: double.infinity * 0.7, width: double.infinity * 0.7,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@ -74,7 +74,7 @@ class PackCardVerticalShimmer extends StatelessWidget {
height: 14, height: 14,
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? Colors.grey[800]! : Colors.grey[200]!,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),

View file

@ -41,7 +41,6 @@ class PackCard extends StatelessWidget {
} }
}, },
child: Container( child: Container(
margin: const EdgeInsets.symmetric(vertical: 4.0),
height: cardHeight, height: cardHeight,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent, color: Colors.transparent,

View file

@ -144,43 +144,52 @@ import 'mnemo_text.dart';
Positioned( Positioned(
bottom: 4, bottom: 4,
right: 4, right: 4,
child: Row( child: Builder(
mainAxisSize: MainAxisSize.min, builder: (context) {
children: [ final colorScheme = Theme.of(context).colorScheme;
// Иконка избранного return Row(
GestureDetector( mainAxisSize: MainAxisSize.min,
onTap: onToggleFavorite, children: [
child: Container( // Иконка избранного
padding: const EdgeInsets.all(2), GestureDetector(
decoration: BoxDecoration( onTap: onToggleFavorite,
color: Colors.white.withOpacity(0.9), child: Container(
borderRadius: BorderRadius.circular(4), padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_border,
size: 12,
color: isFavorite
? Colors.red
: colorScheme.onSurface.withOpacity(0.6),
),
),
), ),
child: Icon( const SizedBox(width: 4),
isFavorite ? Icons.favorite : Icons.favorite_border, // Иконка просмотра
size: 12, GestureDetector(
color: isFavorite ? Colors.red : Colors.grey, onTap: onView ?? onTap,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.visibility_outlined,
size: 12,
color: colorScheme.onSurface.withOpacity(0.6),
),
),
), ),
), ],
), );
const SizedBox(width: 4), },
// Иконка просмотра
GestureDetector(
onTap: onView ?? onTap,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.visibility_outlined,
size: 12,
color: Colors.grey,
),
),
),
],
), ),
), ),
], ],

View file

@ -158,10 +158,7 @@ class PackCardVertical extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: tip.bgColor?.asColor ?? packColor.withOpacity(0.4), color: tip.bgColor?.asColor ?? packColor.withOpacity(0.4),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.all(Radius.circular(12.0)),
bottomLeft: Radius.circular(12.0),
bottomRight: Radius.circular(12.0),
),
), ),
child: tip.build(context), child: tip.build(context),
), ),